From e365e5df4d503dafa7019ef5e86cc1317b4b395f Mon Sep 17 00:00:00 2001 From: MercuryToolbox Release Date: Sat, 18 Jul 2026 15:12:47 +0800 Subject: [PATCH] chore(release): prepare public source release --- .cargo/config.toml | 26 + .gitattributes | 14 + .gitignore | 19 + CHANGELOG.md | 16 + CONTRIBUTING.md | 49 + Cargo.lock | 5763 +++++++++++++++++ Cargo.toml | 248 + LICENSE | 8 + PSScriptAnalyzerSettings.psd1 | 6 + README.md | 1028 +++ bacon.toml | 210 + cliff.toml | 44 + crates/argv/Cargo.toml | 26 + crates/argv/src/lib.rs | 922 +++ crates/argv/src/main.rs | 5 + crates/argv/tests/argv_cli.rs | 233 + crates/asmapi/Cargo.toml | 25 + crates/asmapi/src/lib.rs | 511 ++ crates/asmapi/src/main.rs | 5 + crates/asmapi/tests/asmapi_cli.rs | 148 + crates/asmflow/Cargo.toml | 27 + crates/asmflow/src/cli.rs | 1063 +++ crates/asmflow/src/lib.rs | 5 + crates/asmflow/src/main.rs | 5 + crates/asmflow/tests/asmflow_cli.rs | 27 + crates/asmmember/Cargo.toml | 27 + crates/asmmember/src/cli.rs | 1107 ++++ crates/asmmember/src/lib.rs | 5 + crates/asmmember/src/main.rs | 5 + crates/asmmember/tests/asmmember_cli.rs | 105 + crates/asmref/Cargo.toml | 26 + crates/asmref/src/lib.rs | 598 ++ crates/asmref/src/main.rs | 5 + crates/asmref/tests/asmref_cli.rs | 227 + crates/asmtype/Cargo.toml | 27 + crates/asmtype/src/lib.rs | 1078 +++ crates/asmtype/src/main.rs | 5 + crates/asmtype/tests/asmtype_cli.rs | 137 + crates/await/Cargo.toml | 31 + crates/await/src/lib.rs | 1656 +++++ crates/await/src/main.rs | 9 + crates/await/tests/await_cli.rs | 175 + crates/binmeta/Cargo.toml | 39 + crates/binmeta/src/lib.rs | 2432 +++++++ crates/binmeta/src/main.rs | 5 + crates/binmeta/tests/binmeta_cli.rs | 182 + crates/chunkcat/Cargo.toml | 26 + crates/chunkcat/src/lib.rs | 1015 +++ crates/chunkcat/src/main.rs | 5 + crates/chunkcat/tests/chunkcat_cli.rs | 196 + crates/cjson/Cargo.toml | 25 + crates/cjson/src/lib.rs | 829 +++ crates/cjson/src/main.rs | 5 + crates/cjson/tests/cjson_cli.rs | 247 + crates/codeindex/Cargo.toml | 49 + crates/codeindex/src/lib.rs | 1560 +++++ crates/codeshape/Cargo.toml | 25 + crates/codeshape/src/lib.rs | 700 ++ crates/codeshape/src/main.rs | 5 + crates/codeshape/tests/codeshape_cli.rs | 92 + crates/common/Cargo.toml | 24 + crates/common/src/formats.rs | 4 + crates/common/src/formats/ison.rs | 365 ++ crates/common/src/formats/tonl.rs | 144 + crates/common/src/formats/toon.rs | 1203 ++++ crates/common/src/formats/zon.rs | 168 + crates/common/src/lib.rs | 1502 +++++ crates/common/tests/ai_prompt.rs | 276 + crates/common/tests/contract.rs | 150 + crates/common/tests/jade_workspace.rs | 484 ++ crates/common/tests/miri_json_family.rs | 63 + crates/config/Cargo.toml | 29 + crates/config/src/lib.rs | 2540 ++++++++ crates/config/src/main.rs | 5 + crates/config/tests/config_cli.rs | 247 + crates/configsupport/Cargo.toml | 20 + crates/configsupport/src/lib.rs | 614 ++ crates/context/Cargo.toml | 31 + crates/context/src/lib.rs | 1685 +++++ crates/context/src/main.rs | 5 + crates/context/tests/context_cli.rs | 129 + crates/csvshape/Cargo.toml | 26 + crates/csvshape/src/lib.rs | 1811 ++++++ crates/csvshape/src/main.rs | 5 + crates/csvshape/tests/csvshape_cli.rs | 45 + crates/defsnip/Cargo.toml | 26 + crates/defsnip/src/lib.rs | 672 ++ crates/defsnip/src/main.rs | 5 + crates/defsnip/tests/defsnip_cli.rs | 85 + crates/diagpick/Cargo.toml | 27 + crates/diagpick/src/lib.rs | 1186 ++++ crates/diagpick/src/main.rs | 5 + crates/diagpick/tests/diagpick_cli.rs | 178 + crates/dotnetshape/Cargo.toml | 28 + crates/dotnetshape/src/lib.rs | 1566 +++++ crates/dotnetshape/src/main.rs | 5 + crates/dotnetshape/tests/dotnetshape_cli.rs | 386 ++ crates/envdiff/Cargo.toml | 25 + crates/envdiff/src/lib.rs | 1624 +++++ crates/envdiff/src/main.rs | 6 + crates/envdiff/tests/envdiff_cli.rs | 80 + crates/fileprobe/Cargo.toml | 27 + crates/fileprobe/src/lib.rs | 921 +++ crates/fileprobe/src/main.rs | 5 + crates/fileprobe/tests/fileprobe_cli.rs | 163 + crates/gitshape/Cargo.toml | 25 + crates/gitshape/src/lib.rs | 1368 ++++ crates/gitshape/src/main.rs | 5 + crates/gitshape/tests/gitshape_cli.rs | 288 + crates/hitsnip/Cargo.toml | 27 + crates/hitsnip/src/lib.rs | 1272 ++++ crates/hitsnip/src/main.rs | 5 + crates/hitsnip/tests/hitsnip_cli.rs | 184 + crates/ison/Cargo.toml | 25 + crates/ison/src/lib.rs | 920 +++ crates/ison/src/main.rs | 5 + crates/ison/tests/ison_cli.rs | 120 + crates/isonl/Cargo.toml | 26 + crates/isonl/src/lib.rs | 452 ++ crates/isonl/src/main.rs | 5 + crates/isonl/tests/isonl_cli.rs | 108 + crates/jsonlgrep/Cargo.toml | 27 + crates/jsonlgrep/src/lib.rs | 1144 ++++ crates/jsonlgrep/src/main.rs | 5 + crates/jsonlgrep/tests/query_and_cli.rs | 256 + crates/jsonshape/Cargo.toml | 26 + crates/jsonshape/src/lib.rs | 1068 +++ crates/jsonshape/src/main.rs | 5 + crates/jsonshape/tests/jsonshape_cli.rs | 126 + crates/llvmtools/Cargo.toml | 39 + crates/llvmtools/src/backend.rs | 316 + crates/llvmtools/src/cli.rs | 908 +++ crates/llvmtools/src/lib.rs | 28 + crates/llvmtools/src/main_nm.rs | 5 + crates/llvmtools/src/main_objdump.rs | 5 + crates/llvmtools/src/main_readobj.rs | 5 + crates/llvmtools/src/parse_nm.rs | 398 ++ crates/llvmtools/src/parse_objdump.rs | 631 ++ crates/llvmtools/src/parse_readobj.rs | 874 +++ crates/llvmtools/src/pe_summary.rs | 284 + crates/llvmtools/tests/llvmtools_cli.rs | 260 + crates/logshape/Cargo.toml | 26 + crates/logshape/src/lib.rs | 931 +++ crates/logshape/src/main.rs | 5 + crates/logshape/tests/logshape_cli.rs | 161 + crates/managed/Cargo.toml | 22 + crates/managed/src/flow.rs | 1471 +++++ crates/managed/src/flow_opcode_table.rs | 233 + crates/managed/src/lib.rs | 2500 +++++++ crates/managed/src/reference_diagnose.rs | 1117 ++++ crates/mhash/Cargo.toml | 47 + crates/mhash/src/lib.rs | 3176 +++++++++ crates/mhash/src/main.rs | 5 + crates/mhash/tests/mhash_cli.rs | 364 ++ crates/msudo/Cargo.toml | 23 + crates/msudo/src/lib.rs | 2313 +++++++ crates/msudo/src/main.rs | 6 + crates/msudo/tests/contract.rs | 125 + crates/msudo/tests/msudo_cli.rs | 93 + crates/outline/Cargo.toml | 27 + crates/outline/src/lib.rs | 1627 +++++ crates/outline/src/main.rs | 5 + crates/outline/tests/outline_cli.rs | 189 + crates/pathshadow/Cargo.toml | 26 + crates/pathshadow/src/lib.rs | 1352 ++++ crates/pathshadow/src/main.rs | 5 + crates/pathshadow/tests/pathshadow_cli.rs | 157 + crates/petools/Cargo.toml | 57 + crates/petools/src/lib.rs | 3865 +++++++++++ crates/petools/src/main_drvshape.rs | 5 + crates/petools/src/main_ioctlscan.rs | 5 + crates/petools/src/main_pecalls.rs | 5 + crates/petools/src/main_peexports.rs | 5 + crates/petools/src/main_peimports.rs | 5 + crates/petools/src/main_pesig.rs | 5 + crates/petools/src/main_pestrrefs.rs | 5 + crates/petools/tests/petools_cli.rs | 124 + crates/petools/tests/petools_core.rs | 75 + crates/portping/Cargo.toml | 26 + crates/portping/src/lib.rs | 1419 ++++ crates/portping/src/main.rs | 9 + crates/portping/tests/portping_cli.rs | 258 + crates/portunlock/Cargo.toml | 27 + crates/portunlock/src/lib.rs | 1161 ++++ crates/portunlock/src/main.rs | 6 + crates/portunlock/tests/portunlock_cli.rs | 95 + crates/proctree/Cargo.toml | 27 + crates/proctree/src/lib.rs | 900 +++ crates/proctree/src/main.rs | 6 + crates/proctree/tests/proctree_cli.rs | 38 + crates/recent/Cargo.toml | 30 + crates/recent/src/cli.rs | 278 + crates/recent/src/lib.rs | 648 ++ crates/recent/src/main.rs | 5 + crates/recent/tests/recent_cli.rs | 178 + crates/refs/Cargo.toml | 26 + crates/refs/src/lib.rs | 1396 ++++ crates/refs/src/main.rs | 5 + crates/refs/tests/refs_cli.rs | 184 + crates/reposhape/Cargo.toml | 28 + crates/reposhape/src/lib.rs | 1780 +++++ crates/reposhape/src/main.rs | 5 + crates/reposhape/tests/reposhape_cli.rs | 189 + crates/runprobe/Cargo.toml | 27 + crates/runprobe/src/lib.rs | 752 +++ crates/runprobe/src/main.rs | 5 + crates/runprobe/tests/runprobe_cli.rs | 148 + crates/runtimekit/Cargo.toml | 23 + crates/runtimekit/src/lib.rs | 827 +++ crates/runtimekit/tests/loom_capture.rs | 57 + crates/snip/Cargo.toml | 27 + crates/snip/src/lib.rs | 867 +++ crates/snip/src/main.rs | 5 + crates/snip/tests/snip_cli.rs | 159 + crates/sqliteshape/Cargo.toml | 27 + crates/sqliteshape/src/lib.rs | 1590 +++++ crates/sqliteshape/src/main.rs | 6 + crates/sqliteshape/tests/sqliteshape_cli.rs | 183 + crates/sqlshape/Cargo.toml | 37 + crates/sqlshape/build.rs | 10 + crates/sqlshape/src/adapters.rs | 1767 +++++ crates/sqlshape/src/cli.rs | 275 + crates/sqlshape/src/connection.rs | 138 + crates/sqlshape/src/diff.rs | 297 + crates/sqlshape/src/lib.rs | 230 + crates/sqlshape/src/main.rs | 6 + crates/sqlshape/src/model.rs | 329 + crates/sqlshape/src/render.rs | 229 + crates/sqlshape/tests/sqlshape_cli.rs | 501 ++ crates/stringscan/Cargo.toml | 26 + crates/stringscan/src/lib.rs | 1247 ++++ crates/stringscan/src/main.rs | 5 + crates/stringscan/tests/stringscan_cli.rs | 125 + crates/sysshape/Cargo.toml | 31 + crates/sysshape/src/lib.rs | 2073 ++++++ crates/sysshape/src/main.rs | 6 + crates/sysshape/tests/sysshape_cli.rs | 57 + crates/tonl/Cargo.toml | 26 + crates/tonl/src/lib.rs | 1221 ++++ crates/tonl/src/main.rs | 5 + crates/tonl/tests/tonl_cli.rs | 224 + crates/toon/Cargo.toml | 26 + crates/toon/src/lib.rs | 3315 ++++++++++ crates/toon/src/main.rs | 5 + crates/toon/tests/shared_toon_core.rs | 60 + crates/toon/tests/toon_cli.rs | 79 + crates/unityasset/Cargo.toml | 27 + crates/unityasset/src/cli.rs | 1787 +++++ crates/unityasset/src/lib.rs | 6 + crates/unityasset/src/main.rs | 6 + crates/unityasset/tests/unityasset_cli.rs | 852 +++ crates/unitydiag/Cargo.toml | 27 + crates/unitydiag/src/cli.rs | 700 ++ crates/unitydiag/src/lib.rs | 6 + crates/unitydiag/src/main.rs | 6 + crates/unitydiag/tests/fixtures/LogOutput.log | 9 + crates/unitydiag/tests/fixtures/player.log | 16 + crates/unitydiag/tests/unitydiag_cli.rs | 269 + crates/unityprobe/Cargo.toml | 26 + crates/unityprobe/src/cli.rs | 1159 ++++ crates/unityprobe/src/lib.rs | 6 + crates/unityprobe/src/main.rs | 6 + crates/unityprobe/tests/unityprobe_cli.rs | 107 + crates/unitysupport/Cargo.toml | 29 + crates/unitysupport/src/assets.rs | 2242 +++++++ crates/unitysupport/src/lib.rs | 48 + crates/unitysupport/src/logs.rs | 1298 ++++ crates/unitysupport/src/probe.rs | 1729 +++++ crates/unlock/Cargo.toml | 28 + crates/unlock/src/lib.rs | 1348 ++++ crates/unlock/src/main.rs | 6 + crates/unlock/tests/unlock_cli.rs | 185 + crates/windowsupport/Cargo.toml | 37 + crates/windowsupport/src/lib.rs | 1700 +++++ crates/windowsupport/src/sudo.rs | 2091 ++++++ crates/zon/Cargo.toml | 26 + crates/zon/src/lib.rs | 1165 ++++ crates/zon/src/main.rs | 5 + crates/zon/tests/zon_cli.rs | 181 + deny.toml | 26 + docs/ai/mercury-toolbox-ai-prompt.md | 62 + docs/ai/toolbox-ai-prompt-notes.json | 777 +++ docs/benchmarks/README.md | 90 + docs/benchmarks/mhash-benchmark-2026-04-25.md | 77 + docs/jade-discipline.md | 103 + docs/maintainer-notes.md | 73 + docs/releases/v0.1.0.md | 46 + .../2026-04-21-mercury-toolbox-v2-plan.md | 172 + .../2026-04-21-toolbox-reading-v2-plan.md | 96 + .../plans/2026-04-21-toolbox-v1-5-plan.md | 95 + .../2026-04-21-mercury-toolbox-v2-design.md | 329 + .../2026-04-21-toolbox-reading-v2-design.md | 137 + .../specs/2026-04-21-toolbox-v1-5-design.md | 80 + docs/templates/daily-maintenance-template.md | 83 + docs/templates/jade-feature-template.md | 133 + docs/templates/release-notes-template.md | 71 + fixtures/binaries/stringscan-sample.bin | Bin 0 -> 244 bytes fixtures/binmeta/plain.txt | 1 + fixtures/cjson/records.jsonl | 2 + fixtures/cjson/sample.json | 14 + fixtures/csvshape/sample.csv | 5 + fixtures/csvshape/sample.tsv | 5 + fixtures/diag/rust-errors.txt | 11 + fixtures/diag/unity-errors.txt | 2 + fixtures/hits/rg-output.txt | 4 + fixtures/json-family/ison/README.md | 6 + fixtures/json-family/ison/users.ison | 4 + fixtures/json-family/ison/users.isonl | 2 + fixtures/json-family/ison/users.json | 1 + fixtures/json-family/tonl/README.md | 15 + fixtures/json-family/tonl/invalid.tonl | 3 + fixtures/json-family/tonl/records.jsonl | 2 + fixtures/json-family/tonl/records.tonl | 11 + fixtures/json-family/tonl/sample.json | 1 + fixtures/json-family/tonl/sample.tonl | 7 + fixtures/json-family/zon/invalid-nested.zon | 3 + fixtures/json-family/zon/person.json | 10 + fixtures/json-family/zon/person.zon | 5 + fixtures/json-family/zon/records.jsonl | 2 + fixtures/json-family/zon/records.zon | 3 + fixtures/jsonl/events.jsonl | 4 + fixtures/jsonshape/events.jsonl | 2 + fixtures/logs/repetitive.log | 5 + .../FixtureSupport/FixtureSupport.csproj | 11 + .../managed/FixtureSupport/SupportTypes.cs | 22 + .../managed/GameAssembly/GameAssembly.csproj | 14 + fixtures/managed/GameAssembly/GameTypes.cs | 65 + fixtures/managed/bin/FixtureSupport.dll | Bin 0 -> 4096 bytes fixtures/managed/bin/GameAssembly.dll | Bin 0 -> 5120 bytes .../managed/diagnose-bin/compile/0Harmony.dll | Bin 0 -> 3584 bytes .../diagnose-bin/compile/MissingOnly.dll | Bin 0 -> 3584 bytes .../compile/RuntimeDependency.dll | Bin 0 -> 4096 bytes .../diagnose-bin/compile/TestOnlySupport.dll | Bin 0 -> 3584 bytes .../managed/diagnose-bin/root/RootPlugin.dll | Bin 0 -> 4096 bytes .../diagnose-bin/server-a/0Harmony.dll | Bin 0 -> 3584 bytes .../diagnose-bin/server-a/NotManaged.dll | 1 + .../server-a/RuntimeDependency.dll | Bin 0 -> 4096 bytes .../diagnose-bin/server-a/TestOnlySupport.dll | Bin 0 -> 3584 bytes .../server-b/RuntimeDependency.dll | Bin 0 -> 4096 bytes .../diagnose-src/MissingOnly/MissingMarker.cs | 9 + .../MissingOnly/MissingOnly.csproj | 13 + .../diagnose-src/RootPlugin/PluginEntry.cs | 18 + .../diagnose-src/RootPlugin/RootPlugin.csproj | 31 + .../RuntimeDependencyCompile.csproj | 13 + .../RuntimeFeature.cs | 17 + .../RuntimeDependencyServer.csproj | 13 + .../RuntimeDependencyServer/RuntimeFeature.cs | 10 + .../TestOnlySupport/TestMarker.cs | 9 + .../TestOnlySupport/TestOnlySupport.csproj | 13 + .../diagnose-src/ZeroHarmony/Harmony.cs | 10 + .../ZeroHarmony/ZeroHarmony.csproj | 13 + fixtures/pathshadow/commands.txt | 3 + fixtures/polyglot/repo/nested/worker.py | 12 + fixtures/polyglot/repo/scripts/module.psm1 | 7 + fixtures/polyglot/repo/scripts/tools.ps1 | 28 + fixtures/polyglot/repo/src/Game.cs | 28 + fixtures/polyglot/repo/src/Service.java | 9 + fixtures/polyglot/repo/src/lib.rs | 29 + fixtures/polyglot/repo/src/service.go | 21 + fixtures/polyglot/repo/web/app.js | 18 + fixtures/polyglot/repo/web/app.ts | 25 + fixtures/polyglot/repo/web/view.tsx | 13 + fixtures/portping/targets.txt | 2 + fixtures/reading/binary.bin | Bin 0 -> 13 bytes fixtures/reading/config.json | 15 + fixtures/reading/config.toml | 8 + fixtures/reading/config.yaml | 9 + fixtures/reading/generated.lock | 3 + fixtures/reading/minified.js | 1 + fixtures/reading/sample.cs | 17 + fixtures/reading/sample.go | 27 + fixtures/reading/sample.java | 25 + fixtures/reading/sample.js | 15 + fixtures/reading/sample.ps1 | 15 + fixtures/reading/sample.py | 13 + fixtures/reading/sample.rs | 35 + fixtures/reading/sample.ts | 21 + fixtures/sqliteshape/sample.db | Bin 0 -> 16384 bytes fixtures/sqliteshape/sample.db-shm | Bin 0 -> 32768 bytes fixtures/sqliteshape/sample.db-wal | 0 fixtures/sqliteshape/sample.sql | 29 + fixtures/text/phrases.txt | 3 + fixtures/toon-spec/decode/arrays-nested.json | 217 + .../toon-spec/decode/arrays-primitive.json | 127 + fixtures/toon-spec/decode/arrays-tabular.json | 85 + fixtures/toon-spec/decode/blank-lines.json | 153 + fixtures/toon-spec/decode/delimiters.json | 246 + .../toon-spec/decode/indentation-errors.json | 184 + fixtures/toon-spec/decode/numbers.json | 175 + fixtures/toon-spec/decode/objects.json | 265 + fixtures/toon-spec/decode/path-expansion.json | 173 + fixtures/toon-spec/decode/primitives.json | 158 + fixtures/toon-spec/decode/root-form.json | 17 + .../toon-spec/decode/validation-errors.json | 83 + fixtures/toon-spec/decode/whitespace.json | 61 + fixtures/toon-spec/encode/arrays-nested.json | 105 + fixtures/toon-spec/encode/arrays-objects.json | 158 + .../toon-spec/encode/arrays-primitive.json | 103 + fixtures/toon-spec/encode/arrays-tabular.json | 73 + fixtures/toon-spec/encode/delimiters.json | 253 + fixtures/toon-spec/encode/key-folding.json | 218 + fixtures/toon-spec/encode/objects.json | 220 + fixtures/toon-spec/encode/primitives.json | 251 + fixtures/toon-spec/encode/whitespace.json | 44 + fixtures/toon/config.json | 25 + fixtures/toon/config.toon | 7 + fixtures/toon/records.jsonl | 2 + fuzz/.gitignore | 4 + fuzz/Cargo.lock | 299 + fuzz/Cargo.toml | 23 + fuzz/fuzz_targets/json_family_decode.rs | 23 + justfile | 294 + opcode-table.txt | 226 + renovate.json | 23 + scripts/benchmark-mhash-smoke.ps1 | 138 + scripts/benchmark-mhash.ps1 | 1041 +++ scripts/cargo-flamegraph-windows.ps1 | 294 + scripts/check-ai-prompt.ps1 | 74 + scripts/check-ai-skill.ps1 | 74 + scripts/check-conventional-commits.ps1 | 22 + scripts/check-ecosystem.ps1 | 1669 +++++ scripts/check-gitea-ci.ps1 | 365 ++ scripts/check-jade-hardening.ps1 | 321 + scripts/check-jade.ps1 | 424 ++ scripts/check-no-panic.ps1 | 131 + scripts/check-powershell.ps1 | 274 + scripts/check-release-version.ps1 | 47 + scripts/generate-ai-prompt.ps1 | 341 + scripts/generate-ai-skill.ps1 | 468 ++ scripts/install-jade-tooling.ps1 | 100 + scripts/install-package-toolbox.ps1 | 557 ++ scripts/install-toolbox.ps1 | 507 ++ scripts/invoke-gitea-git.ps1 | 189 + scripts/package-toolbox.ps1 | 356 + scripts/probe-toolbox.ps1 | 510 ++ scripts/publish-gitea-release.ps1 | 280 + scripts/setup-gitea-runner.ps1 | 319 + scripts/sync-json-family-specs.ps1 | 57 + scripts/toolbox-commands.ps1 | 180 + scripts/uninstall-package-toolbox.ps1 | 221 + scripts/uninstall-toolbox.ps1 | 221 + skills/mercury-toolbox/SKILL.md | 58 + skills/mercury-toolbox/agents/openai.yaml | 10 + skills/mercury-toolbox/assets/logo.png | Bin 0 -> 147968 bytes .../references/command-catalog.md | 478 ++ .../MercuryUnityProbePlugin.cs | 872 +++ .../MercuryUnityProbeProtocol.cs | 615 ++ vendor/tree-sitter/.cargo-ok | 1 + vendor/tree-sitter/.cargo_vcs_info.json | 6 + vendor/tree-sitter/Cargo.lock | 1187 ++++ vendor/tree-sitter/Cargo.toml | 165 + vendor/tree-sitter/Cargo.toml.orig | 66 + vendor/tree-sitter/README.md | 31 + vendor/tree-sitter/binding_rust/README.md | 116 + vendor/tree-sitter/binding_rust/bindings.rs | 963 +++ vendor/tree-sitter/binding_rust/build.rs | 122 + vendor/tree-sitter/binding_rust/ffi.rs | 216 + vendor/tree-sitter/binding_rust/lib.rs | 3948 +++++++++++ vendor/tree-sitter/binding_rust/util.rs | 45 + .../tree-sitter/binding_rust/wasm_language.rs | 147 + vendor/tree-sitter/include/tree_sitter/api.h | 1478 +++++ vendor/tree-sitter/src/alloc.c | 48 + vendor/tree-sitter/src/alloc.h | 41 + vendor/tree-sitter/src/array.h | 291 + vendor/tree-sitter/src/atomic.h | 68 + vendor/tree-sitter/src/clock.h | 146 + vendor/tree-sitter/src/error_costs.h | 11 + vendor/tree-sitter/src/get_changed_ranges.c | 523 ++ vendor/tree-sitter/src/get_changed_ranges.h | 36 + vendor/tree-sitter/src/host.h | 21 + vendor/tree-sitter/src/language.c | 293 + vendor/tree-sitter/src/language.h | 293 + vendor/tree-sitter/src/length.h | 52 + vendor/tree-sitter/src/lexer.c | 483 ++ vendor/tree-sitter/src/lexer.h | 54 + vendor/tree-sitter/src/lib.c | 12 + vendor/tree-sitter/src/node.c | 875 +++ vendor/tree-sitter/src/parser.c | 2297 +++++++ vendor/tree-sitter/src/parser.h | 286 + vendor/tree-sitter/src/point.h | 48 + vendor/tree-sitter/src/portable/endian.h | 241 + vendor/tree-sitter/src/query.c | 4347 +++++++++++++ vendor/tree-sitter/src/reduce_action.h | 34 + vendor/tree-sitter/src/reusable_node.h | 95 + vendor/tree-sitter/src/stack.c | 912 +++ vendor/tree-sitter/src/stack.h | 133 + vendor/tree-sitter/src/subtree.c | 1034 +++ vendor/tree-sitter/src/subtree.h | 399 ++ vendor/tree-sitter/src/tree.c | 170 + vendor/tree-sitter/src/tree.h | 31 + vendor/tree-sitter/src/tree_cursor.c | 716 ++ vendor/tree-sitter/src/tree_cursor.h | 48 + vendor/tree-sitter/src/ts_assert.h | 11 + vendor/tree-sitter/src/unicode.h | 75 + vendor/tree-sitter/src/unicode/ICU_SHA | 1 + vendor/tree-sitter/src/unicode/LICENSE | 414 ++ vendor/tree-sitter/src/unicode/README.md | 29 + vendor/tree-sitter/src/unicode/ptypes.h | 1 + vendor/tree-sitter/src/unicode/umachine.h | 448 ++ vendor/tree-sitter/src/unicode/urename.h | 1 + vendor/tree-sitter/src/unicode/utf.h | 1 + vendor/tree-sitter/src/unicode/utf16.h | 733 +++ vendor/tree-sitter/src/unicode/utf8.h | 881 +++ .../tree-sitter/src/wasm/stdlib-symbols.txt | 24 + vendor/tree-sitter/src/wasm/stdlib.c | 137 + vendor/tree-sitter/src/wasm/wasm-stdlib.h | 1314 ++++ vendor/tree-sitter/src/wasm_store.c | 1937 ++++++ vendor/tree-sitter/src/wasm_store.h | 31 + 508 files changed, 163373 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 PSScriptAnalyzerSettings.psd1 create mode 100644 README.md create mode 100644 bacon.toml create mode 100644 cliff.toml create mode 100644 crates/argv/Cargo.toml create mode 100644 crates/argv/src/lib.rs create mode 100644 crates/argv/src/main.rs create mode 100644 crates/argv/tests/argv_cli.rs create mode 100644 crates/asmapi/Cargo.toml create mode 100644 crates/asmapi/src/lib.rs create mode 100644 crates/asmapi/src/main.rs create mode 100644 crates/asmapi/tests/asmapi_cli.rs create mode 100644 crates/asmflow/Cargo.toml create mode 100644 crates/asmflow/src/cli.rs create mode 100644 crates/asmflow/src/lib.rs create mode 100644 crates/asmflow/src/main.rs create mode 100644 crates/asmflow/tests/asmflow_cli.rs create mode 100644 crates/asmmember/Cargo.toml create mode 100644 crates/asmmember/src/cli.rs create mode 100644 crates/asmmember/src/lib.rs create mode 100644 crates/asmmember/src/main.rs create mode 100644 crates/asmmember/tests/asmmember_cli.rs create mode 100644 crates/asmref/Cargo.toml create mode 100644 crates/asmref/src/lib.rs create mode 100644 crates/asmref/src/main.rs create mode 100644 crates/asmref/tests/asmref_cli.rs create mode 100644 crates/asmtype/Cargo.toml create mode 100644 crates/asmtype/src/lib.rs create mode 100644 crates/asmtype/src/main.rs create mode 100644 crates/asmtype/tests/asmtype_cli.rs create mode 100644 crates/await/Cargo.toml create mode 100644 crates/await/src/lib.rs create mode 100644 crates/await/src/main.rs create mode 100644 crates/await/tests/await_cli.rs create mode 100644 crates/binmeta/Cargo.toml create mode 100644 crates/binmeta/src/lib.rs create mode 100644 crates/binmeta/src/main.rs create mode 100644 crates/binmeta/tests/binmeta_cli.rs create mode 100644 crates/chunkcat/Cargo.toml create mode 100644 crates/chunkcat/src/lib.rs create mode 100644 crates/chunkcat/src/main.rs create mode 100644 crates/chunkcat/tests/chunkcat_cli.rs create mode 100644 crates/cjson/Cargo.toml create mode 100644 crates/cjson/src/lib.rs create mode 100644 crates/cjson/src/main.rs create mode 100644 crates/cjson/tests/cjson_cli.rs create mode 100644 crates/codeindex/Cargo.toml create mode 100644 crates/codeindex/src/lib.rs create mode 100644 crates/codeshape/Cargo.toml create mode 100644 crates/codeshape/src/lib.rs create mode 100644 crates/codeshape/src/main.rs create mode 100644 crates/codeshape/tests/codeshape_cli.rs create mode 100644 crates/common/Cargo.toml create mode 100644 crates/common/src/formats.rs create mode 100644 crates/common/src/formats/ison.rs create mode 100644 crates/common/src/formats/tonl.rs create mode 100644 crates/common/src/formats/toon.rs create mode 100644 crates/common/src/formats/zon.rs create mode 100644 crates/common/src/lib.rs create mode 100644 crates/common/tests/ai_prompt.rs create mode 100644 crates/common/tests/contract.rs create mode 100644 crates/common/tests/jade_workspace.rs create mode 100644 crates/common/tests/miri_json_family.rs create mode 100644 crates/config/Cargo.toml create mode 100644 crates/config/src/lib.rs create mode 100644 crates/config/src/main.rs create mode 100644 crates/config/tests/config_cli.rs create mode 100644 crates/configsupport/Cargo.toml create mode 100644 crates/configsupport/src/lib.rs create mode 100644 crates/context/Cargo.toml create mode 100644 crates/context/src/lib.rs create mode 100644 crates/context/src/main.rs create mode 100644 crates/context/tests/context_cli.rs create mode 100644 crates/csvshape/Cargo.toml create mode 100644 crates/csvshape/src/lib.rs create mode 100644 crates/csvshape/src/main.rs create mode 100644 crates/csvshape/tests/csvshape_cli.rs create mode 100644 crates/defsnip/Cargo.toml create mode 100644 crates/defsnip/src/lib.rs create mode 100644 crates/defsnip/src/main.rs create mode 100644 crates/defsnip/tests/defsnip_cli.rs create mode 100644 crates/diagpick/Cargo.toml create mode 100644 crates/diagpick/src/lib.rs create mode 100644 crates/diagpick/src/main.rs create mode 100644 crates/diagpick/tests/diagpick_cli.rs create mode 100644 crates/dotnetshape/Cargo.toml create mode 100644 crates/dotnetshape/src/lib.rs create mode 100644 crates/dotnetshape/src/main.rs create mode 100644 crates/dotnetshape/tests/dotnetshape_cli.rs create mode 100644 crates/envdiff/Cargo.toml create mode 100644 crates/envdiff/src/lib.rs create mode 100644 crates/envdiff/src/main.rs create mode 100644 crates/envdiff/tests/envdiff_cli.rs create mode 100644 crates/fileprobe/Cargo.toml create mode 100644 crates/fileprobe/src/lib.rs create mode 100644 crates/fileprobe/src/main.rs create mode 100644 crates/fileprobe/tests/fileprobe_cli.rs create mode 100644 crates/gitshape/Cargo.toml create mode 100644 crates/gitshape/src/lib.rs create mode 100644 crates/gitshape/src/main.rs create mode 100644 crates/gitshape/tests/gitshape_cli.rs create mode 100644 crates/hitsnip/Cargo.toml create mode 100644 crates/hitsnip/src/lib.rs create mode 100644 crates/hitsnip/src/main.rs create mode 100644 crates/hitsnip/tests/hitsnip_cli.rs create mode 100644 crates/ison/Cargo.toml create mode 100644 crates/ison/src/lib.rs create mode 100644 crates/ison/src/main.rs create mode 100644 crates/ison/tests/ison_cli.rs create mode 100644 crates/isonl/Cargo.toml create mode 100644 crates/isonl/src/lib.rs create mode 100644 crates/isonl/src/main.rs create mode 100644 crates/isonl/tests/isonl_cli.rs create mode 100644 crates/jsonlgrep/Cargo.toml create mode 100644 crates/jsonlgrep/src/lib.rs create mode 100644 crates/jsonlgrep/src/main.rs create mode 100644 crates/jsonlgrep/tests/query_and_cli.rs create mode 100644 crates/jsonshape/Cargo.toml create mode 100644 crates/jsonshape/src/lib.rs create mode 100644 crates/jsonshape/src/main.rs create mode 100644 crates/jsonshape/tests/jsonshape_cli.rs create mode 100644 crates/llvmtools/Cargo.toml create mode 100644 crates/llvmtools/src/backend.rs create mode 100644 crates/llvmtools/src/cli.rs create mode 100644 crates/llvmtools/src/lib.rs create mode 100644 crates/llvmtools/src/main_nm.rs create mode 100644 crates/llvmtools/src/main_objdump.rs create mode 100644 crates/llvmtools/src/main_readobj.rs create mode 100644 crates/llvmtools/src/parse_nm.rs create mode 100644 crates/llvmtools/src/parse_objdump.rs create mode 100644 crates/llvmtools/src/parse_readobj.rs create mode 100644 crates/llvmtools/src/pe_summary.rs create mode 100644 crates/llvmtools/tests/llvmtools_cli.rs create mode 100644 crates/logshape/Cargo.toml create mode 100644 crates/logshape/src/lib.rs create mode 100644 crates/logshape/src/main.rs create mode 100644 crates/logshape/tests/logshape_cli.rs create mode 100644 crates/managed/Cargo.toml create mode 100644 crates/managed/src/flow.rs create mode 100644 crates/managed/src/flow_opcode_table.rs create mode 100644 crates/managed/src/lib.rs create mode 100644 crates/managed/src/reference_diagnose.rs create mode 100644 crates/mhash/Cargo.toml create mode 100644 crates/mhash/src/lib.rs create mode 100644 crates/mhash/src/main.rs create mode 100644 crates/mhash/tests/mhash_cli.rs create mode 100644 crates/msudo/Cargo.toml create mode 100644 crates/msudo/src/lib.rs create mode 100644 crates/msudo/src/main.rs create mode 100644 crates/msudo/tests/contract.rs create mode 100644 crates/msudo/tests/msudo_cli.rs create mode 100644 crates/outline/Cargo.toml create mode 100644 crates/outline/src/lib.rs create mode 100644 crates/outline/src/main.rs create mode 100644 crates/outline/tests/outline_cli.rs create mode 100644 crates/pathshadow/Cargo.toml create mode 100644 crates/pathshadow/src/lib.rs create mode 100644 crates/pathshadow/src/main.rs create mode 100644 crates/pathshadow/tests/pathshadow_cli.rs create mode 100644 crates/petools/Cargo.toml create mode 100644 crates/petools/src/lib.rs create mode 100644 crates/petools/src/main_drvshape.rs create mode 100644 crates/petools/src/main_ioctlscan.rs create mode 100644 crates/petools/src/main_pecalls.rs create mode 100644 crates/petools/src/main_peexports.rs create mode 100644 crates/petools/src/main_peimports.rs create mode 100644 crates/petools/src/main_pesig.rs create mode 100644 crates/petools/src/main_pestrrefs.rs create mode 100644 crates/petools/tests/petools_cli.rs create mode 100644 crates/petools/tests/petools_core.rs create mode 100644 crates/portping/Cargo.toml create mode 100644 crates/portping/src/lib.rs create mode 100644 crates/portping/src/main.rs create mode 100644 crates/portping/tests/portping_cli.rs create mode 100644 crates/portunlock/Cargo.toml create mode 100644 crates/portunlock/src/lib.rs create mode 100644 crates/portunlock/src/main.rs create mode 100644 crates/portunlock/tests/portunlock_cli.rs create mode 100644 crates/proctree/Cargo.toml create mode 100644 crates/proctree/src/lib.rs create mode 100644 crates/proctree/src/main.rs create mode 100644 crates/proctree/tests/proctree_cli.rs create mode 100644 crates/recent/Cargo.toml create mode 100644 crates/recent/src/cli.rs create mode 100644 crates/recent/src/lib.rs create mode 100644 crates/recent/src/main.rs create mode 100644 crates/recent/tests/recent_cli.rs create mode 100644 crates/refs/Cargo.toml create mode 100644 crates/refs/src/lib.rs create mode 100644 crates/refs/src/main.rs create mode 100644 crates/refs/tests/refs_cli.rs create mode 100644 crates/reposhape/Cargo.toml create mode 100644 crates/reposhape/src/lib.rs create mode 100644 crates/reposhape/src/main.rs create mode 100644 crates/reposhape/tests/reposhape_cli.rs create mode 100644 crates/runprobe/Cargo.toml create mode 100644 crates/runprobe/src/lib.rs create mode 100644 crates/runprobe/src/main.rs create mode 100644 crates/runprobe/tests/runprobe_cli.rs create mode 100644 crates/runtimekit/Cargo.toml create mode 100644 crates/runtimekit/src/lib.rs create mode 100644 crates/runtimekit/tests/loom_capture.rs create mode 100644 crates/snip/Cargo.toml create mode 100644 crates/snip/src/lib.rs create mode 100644 crates/snip/src/main.rs create mode 100644 crates/snip/tests/snip_cli.rs create mode 100644 crates/sqliteshape/Cargo.toml create mode 100644 crates/sqliteshape/src/lib.rs create mode 100644 crates/sqliteshape/src/main.rs create mode 100644 crates/sqliteshape/tests/sqliteshape_cli.rs create mode 100644 crates/sqlshape/Cargo.toml create mode 100644 crates/sqlshape/build.rs create mode 100644 crates/sqlshape/src/adapters.rs create mode 100644 crates/sqlshape/src/cli.rs create mode 100644 crates/sqlshape/src/connection.rs create mode 100644 crates/sqlshape/src/diff.rs create mode 100644 crates/sqlshape/src/lib.rs create mode 100644 crates/sqlshape/src/main.rs create mode 100644 crates/sqlshape/src/model.rs create mode 100644 crates/sqlshape/src/render.rs create mode 100644 crates/sqlshape/tests/sqlshape_cli.rs create mode 100644 crates/stringscan/Cargo.toml create mode 100644 crates/stringscan/src/lib.rs create mode 100644 crates/stringscan/src/main.rs create mode 100644 crates/stringscan/tests/stringscan_cli.rs create mode 100644 crates/sysshape/Cargo.toml create mode 100644 crates/sysshape/src/lib.rs create mode 100644 crates/sysshape/src/main.rs create mode 100644 crates/sysshape/tests/sysshape_cli.rs create mode 100644 crates/tonl/Cargo.toml create mode 100644 crates/tonl/src/lib.rs create mode 100644 crates/tonl/src/main.rs create mode 100644 crates/tonl/tests/tonl_cli.rs create mode 100644 crates/toon/Cargo.toml create mode 100644 crates/toon/src/lib.rs create mode 100644 crates/toon/src/main.rs create mode 100644 crates/toon/tests/shared_toon_core.rs create mode 100644 crates/toon/tests/toon_cli.rs create mode 100644 crates/unityasset/Cargo.toml create mode 100644 crates/unityasset/src/cli.rs create mode 100644 crates/unityasset/src/lib.rs create mode 100644 crates/unityasset/src/main.rs create mode 100644 crates/unityasset/tests/unityasset_cli.rs create mode 100644 crates/unitydiag/Cargo.toml create mode 100644 crates/unitydiag/src/cli.rs create mode 100644 crates/unitydiag/src/lib.rs create mode 100644 crates/unitydiag/src/main.rs create mode 100644 crates/unitydiag/tests/fixtures/LogOutput.log create mode 100644 crates/unitydiag/tests/fixtures/player.log create mode 100644 crates/unitydiag/tests/unitydiag_cli.rs create mode 100644 crates/unityprobe/Cargo.toml create mode 100644 crates/unityprobe/src/cli.rs create mode 100644 crates/unityprobe/src/lib.rs create mode 100644 crates/unityprobe/src/main.rs create mode 100644 crates/unityprobe/tests/unityprobe_cli.rs create mode 100644 crates/unitysupport/Cargo.toml create mode 100644 crates/unitysupport/src/assets.rs create mode 100644 crates/unitysupport/src/lib.rs create mode 100644 crates/unitysupport/src/logs.rs create mode 100644 crates/unitysupport/src/probe.rs create mode 100644 crates/unlock/Cargo.toml create mode 100644 crates/unlock/src/lib.rs create mode 100644 crates/unlock/src/main.rs create mode 100644 crates/unlock/tests/unlock_cli.rs create mode 100644 crates/windowsupport/Cargo.toml create mode 100644 crates/windowsupport/src/lib.rs create mode 100644 crates/windowsupport/src/sudo.rs create mode 100644 crates/zon/Cargo.toml create mode 100644 crates/zon/src/lib.rs create mode 100644 crates/zon/src/main.rs create mode 100644 crates/zon/tests/zon_cli.rs create mode 100644 deny.toml create mode 100644 docs/ai/mercury-toolbox-ai-prompt.md create mode 100644 docs/ai/toolbox-ai-prompt-notes.json create mode 100644 docs/benchmarks/README.md create mode 100644 docs/benchmarks/mhash-benchmark-2026-04-25.md create mode 100644 docs/jade-discipline.md create mode 100644 docs/maintainer-notes.md create mode 100644 docs/releases/v0.1.0.md create mode 100644 docs/superpowers/plans/2026-04-21-mercury-toolbox-v2-plan.md create mode 100644 docs/superpowers/plans/2026-04-21-toolbox-reading-v2-plan.md create mode 100644 docs/superpowers/plans/2026-04-21-toolbox-v1-5-plan.md create mode 100644 docs/superpowers/specs/2026-04-21-mercury-toolbox-v2-design.md create mode 100644 docs/superpowers/specs/2026-04-21-toolbox-reading-v2-design.md create mode 100644 docs/superpowers/specs/2026-04-21-toolbox-v1-5-design.md create mode 100644 docs/templates/daily-maintenance-template.md create mode 100644 docs/templates/jade-feature-template.md create mode 100644 docs/templates/release-notes-template.md create mode 100644 fixtures/binaries/stringscan-sample.bin create mode 100644 fixtures/binmeta/plain.txt create mode 100644 fixtures/cjson/records.jsonl create mode 100644 fixtures/cjson/sample.json create mode 100644 fixtures/csvshape/sample.csv create mode 100644 fixtures/csvshape/sample.tsv create mode 100644 fixtures/diag/rust-errors.txt create mode 100644 fixtures/diag/unity-errors.txt create mode 100644 fixtures/hits/rg-output.txt create mode 100644 fixtures/json-family/ison/README.md create mode 100644 fixtures/json-family/ison/users.ison create mode 100644 fixtures/json-family/ison/users.isonl create mode 100644 fixtures/json-family/ison/users.json create mode 100644 fixtures/json-family/tonl/README.md create mode 100644 fixtures/json-family/tonl/invalid.tonl create mode 100644 fixtures/json-family/tonl/records.jsonl create mode 100644 fixtures/json-family/tonl/records.tonl create mode 100644 fixtures/json-family/tonl/sample.json create mode 100644 fixtures/json-family/tonl/sample.tonl create mode 100644 fixtures/json-family/zon/invalid-nested.zon create mode 100644 fixtures/json-family/zon/person.json create mode 100644 fixtures/json-family/zon/person.zon create mode 100644 fixtures/json-family/zon/records.jsonl create mode 100644 fixtures/json-family/zon/records.zon create mode 100644 fixtures/jsonl/events.jsonl create mode 100644 fixtures/jsonshape/events.jsonl create mode 100644 fixtures/logs/repetitive.log create mode 100644 fixtures/managed/FixtureSupport/FixtureSupport.csproj create mode 100644 fixtures/managed/FixtureSupport/SupportTypes.cs create mode 100644 fixtures/managed/GameAssembly/GameAssembly.csproj create mode 100644 fixtures/managed/GameAssembly/GameTypes.cs create mode 100644 fixtures/managed/bin/FixtureSupport.dll create mode 100644 fixtures/managed/bin/GameAssembly.dll create mode 100644 fixtures/managed/diagnose-bin/compile/0Harmony.dll create mode 100644 fixtures/managed/diagnose-bin/compile/MissingOnly.dll create mode 100644 fixtures/managed/diagnose-bin/compile/RuntimeDependency.dll create mode 100644 fixtures/managed/diagnose-bin/compile/TestOnlySupport.dll create mode 100644 fixtures/managed/diagnose-bin/root/RootPlugin.dll create mode 100644 fixtures/managed/diagnose-bin/server-a/0Harmony.dll create mode 100644 fixtures/managed/diagnose-bin/server-a/NotManaged.dll create mode 100644 fixtures/managed/diagnose-bin/server-a/RuntimeDependency.dll create mode 100644 fixtures/managed/diagnose-bin/server-a/TestOnlySupport.dll create mode 100644 fixtures/managed/diagnose-bin/server-b/RuntimeDependency.dll create mode 100644 fixtures/managed/diagnose-src/MissingOnly/MissingMarker.cs create mode 100644 fixtures/managed/diagnose-src/MissingOnly/MissingOnly.csproj create mode 100644 fixtures/managed/diagnose-src/RootPlugin/PluginEntry.cs create mode 100644 fixtures/managed/diagnose-src/RootPlugin/RootPlugin.csproj create mode 100644 fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeDependencyCompile.csproj create mode 100644 fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeFeature.cs create mode 100644 fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeDependencyServer.csproj create mode 100644 fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeFeature.cs create mode 100644 fixtures/managed/diagnose-src/TestOnlySupport/TestMarker.cs create mode 100644 fixtures/managed/diagnose-src/TestOnlySupport/TestOnlySupport.csproj create mode 100644 fixtures/managed/diagnose-src/ZeroHarmony/Harmony.cs create mode 100644 fixtures/managed/diagnose-src/ZeroHarmony/ZeroHarmony.csproj create mode 100644 fixtures/pathshadow/commands.txt create mode 100644 fixtures/polyglot/repo/nested/worker.py create mode 100644 fixtures/polyglot/repo/scripts/module.psm1 create mode 100644 fixtures/polyglot/repo/scripts/tools.ps1 create mode 100644 fixtures/polyglot/repo/src/Game.cs create mode 100644 fixtures/polyglot/repo/src/Service.java create mode 100644 fixtures/polyglot/repo/src/lib.rs create mode 100644 fixtures/polyglot/repo/src/service.go create mode 100644 fixtures/polyglot/repo/web/app.js create mode 100644 fixtures/polyglot/repo/web/app.ts create mode 100644 fixtures/polyglot/repo/web/view.tsx create mode 100644 fixtures/portping/targets.txt create mode 100644 fixtures/reading/binary.bin create mode 100644 fixtures/reading/config.json create mode 100644 fixtures/reading/config.toml create mode 100644 fixtures/reading/config.yaml create mode 100644 fixtures/reading/generated.lock create mode 100644 fixtures/reading/minified.js create mode 100644 fixtures/reading/sample.cs create mode 100644 fixtures/reading/sample.go create mode 100644 fixtures/reading/sample.java create mode 100644 fixtures/reading/sample.js create mode 100644 fixtures/reading/sample.ps1 create mode 100644 fixtures/reading/sample.py create mode 100644 fixtures/reading/sample.rs create mode 100644 fixtures/reading/sample.ts create mode 100644 fixtures/sqliteshape/sample.db create mode 100644 fixtures/sqliteshape/sample.db-shm create mode 100644 fixtures/sqliteshape/sample.db-wal create mode 100644 fixtures/sqliteshape/sample.sql create mode 100644 fixtures/text/phrases.txt create mode 100644 fixtures/toon-spec/decode/arrays-nested.json create mode 100644 fixtures/toon-spec/decode/arrays-primitive.json create mode 100644 fixtures/toon-spec/decode/arrays-tabular.json create mode 100644 fixtures/toon-spec/decode/blank-lines.json create mode 100644 fixtures/toon-spec/decode/delimiters.json create mode 100644 fixtures/toon-spec/decode/indentation-errors.json create mode 100644 fixtures/toon-spec/decode/numbers.json create mode 100644 fixtures/toon-spec/decode/objects.json create mode 100644 fixtures/toon-spec/decode/path-expansion.json create mode 100644 fixtures/toon-spec/decode/primitives.json create mode 100644 fixtures/toon-spec/decode/root-form.json create mode 100644 fixtures/toon-spec/decode/validation-errors.json create mode 100644 fixtures/toon-spec/decode/whitespace.json create mode 100644 fixtures/toon-spec/encode/arrays-nested.json create mode 100644 fixtures/toon-spec/encode/arrays-objects.json create mode 100644 fixtures/toon-spec/encode/arrays-primitive.json create mode 100644 fixtures/toon-spec/encode/arrays-tabular.json create mode 100644 fixtures/toon-spec/encode/delimiters.json create mode 100644 fixtures/toon-spec/encode/key-folding.json create mode 100644 fixtures/toon-spec/encode/objects.json create mode 100644 fixtures/toon-spec/encode/primitives.json create mode 100644 fixtures/toon-spec/encode/whitespace.json create mode 100644 fixtures/toon/config.json create mode 100644 fixtures/toon/config.toon create mode 100644 fixtures/toon/records.jsonl create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/json_family_decode.rs create mode 100644 justfile create mode 100644 opcode-table.txt create mode 100644 renovate.json create mode 100644 scripts/benchmark-mhash-smoke.ps1 create mode 100644 scripts/benchmark-mhash.ps1 create mode 100644 scripts/cargo-flamegraph-windows.ps1 create mode 100644 scripts/check-ai-prompt.ps1 create mode 100644 scripts/check-ai-skill.ps1 create mode 100644 scripts/check-conventional-commits.ps1 create mode 100644 scripts/check-ecosystem.ps1 create mode 100644 scripts/check-gitea-ci.ps1 create mode 100644 scripts/check-jade-hardening.ps1 create mode 100644 scripts/check-jade.ps1 create mode 100644 scripts/check-no-panic.ps1 create mode 100644 scripts/check-powershell.ps1 create mode 100644 scripts/check-release-version.ps1 create mode 100644 scripts/generate-ai-prompt.ps1 create mode 100644 scripts/generate-ai-skill.ps1 create mode 100644 scripts/install-jade-tooling.ps1 create mode 100644 scripts/install-package-toolbox.ps1 create mode 100644 scripts/install-toolbox.ps1 create mode 100644 scripts/invoke-gitea-git.ps1 create mode 100644 scripts/package-toolbox.ps1 create mode 100644 scripts/probe-toolbox.ps1 create mode 100644 scripts/publish-gitea-release.ps1 create mode 100644 scripts/setup-gitea-runner.ps1 create mode 100644 scripts/sync-json-family-specs.ps1 create mode 100644 scripts/toolbox-commands.ps1 create mode 100644 scripts/uninstall-package-toolbox.ps1 create mode 100644 scripts/uninstall-toolbox.ps1 create mode 100644 skills/mercury-toolbox/SKILL.md create mode 100644 skills/mercury-toolbox/agents/openai.yaml create mode 100644 skills/mercury-toolbox/assets/logo.png create mode 100644 skills/mercury-toolbox/references/command-catalog.md create mode 100644 support/unityprobe-bridge/MercuryUnityProbePlugin.cs create mode 100644 support/unityprobe-bridge/MercuryUnityProbeProtocol.cs create mode 100644 vendor/tree-sitter/.cargo-ok create mode 100644 vendor/tree-sitter/.cargo_vcs_info.json create mode 100644 vendor/tree-sitter/Cargo.lock create mode 100644 vendor/tree-sitter/Cargo.toml create mode 100644 vendor/tree-sitter/Cargo.toml.orig create mode 100644 vendor/tree-sitter/README.md create mode 100644 vendor/tree-sitter/binding_rust/README.md create mode 100644 vendor/tree-sitter/binding_rust/bindings.rs create mode 100644 vendor/tree-sitter/binding_rust/build.rs create mode 100644 vendor/tree-sitter/binding_rust/ffi.rs create mode 100644 vendor/tree-sitter/binding_rust/lib.rs create mode 100644 vendor/tree-sitter/binding_rust/util.rs create mode 100644 vendor/tree-sitter/binding_rust/wasm_language.rs create mode 100644 vendor/tree-sitter/include/tree_sitter/api.h create mode 100644 vendor/tree-sitter/src/alloc.c create mode 100644 vendor/tree-sitter/src/alloc.h create mode 100644 vendor/tree-sitter/src/array.h create mode 100644 vendor/tree-sitter/src/atomic.h create mode 100644 vendor/tree-sitter/src/clock.h create mode 100644 vendor/tree-sitter/src/error_costs.h create mode 100644 vendor/tree-sitter/src/get_changed_ranges.c create mode 100644 vendor/tree-sitter/src/get_changed_ranges.h create mode 100644 vendor/tree-sitter/src/host.h create mode 100644 vendor/tree-sitter/src/language.c create mode 100644 vendor/tree-sitter/src/language.h create mode 100644 vendor/tree-sitter/src/length.h create mode 100644 vendor/tree-sitter/src/lexer.c create mode 100644 vendor/tree-sitter/src/lexer.h create mode 100644 vendor/tree-sitter/src/lib.c create mode 100644 vendor/tree-sitter/src/node.c create mode 100644 vendor/tree-sitter/src/parser.c create mode 100644 vendor/tree-sitter/src/parser.h create mode 100644 vendor/tree-sitter/src/point.h create mode 100644 vendor/tree-sitter/src/portable/endian.h create mode 100644 vendor/tree-sitter/src/query.c create mode 100644 vendor/tree-sitter/src/reduce_action.h create mode 100644 vendor/tree-sitter/src/reusable_node.h create mode 100644 vendor/tree-sitter/src/stack.c create mode 100644 vendor/tree-sitter/src/stack.h create mode 100644 vendor/tree-sitter/src/subtree.c create mode 100644 vendor/tree-sitter/src/subtree.h create mode 100644 vendor/tree-sitter/src/tree.c create mode 100644 vendor/tree-sitter/src/tree.h create mode 100644 vendor/tree-sitter/src/tree_cursor.c create mode 100644 vendor/tree-sitter/src/tree_cursor.h create mode 100644 vendor/tree-sitter/src/ts_assert.h create mode 100644 vendor/tree-sitter/src/unicode.h create mode 100644 vendor/tree-sitter/src/unicode/ICU_SHA create mode 100644 vendor/tree-sitter/src/unicode/LICENSE create mode 100644 vendor/tree-sitter/src/unicode/README.md create mode 100644 vendor/tree-sitter/src/unicode/ptypes.h create mode 100644 vendor/tree-sitter/src/unicode/umachine.h create mode 100644 vendor/tree-sitter/src/unicode/urename.h create mode 100644 vendor/tree-sitter/src/unicode/utf.h create mode 100644 vendor/tree-sitter/src/unicode/utf16.h create mode 100644 vendor/tree-sitter/src/unicode/utf8.h create mode 100644 vendor/tree-sitter/src/wasm/stdlib-symbols.txt create mode 100644 vendor/tree-sitter/src/wasm/stdlib.c create mode 100644 vendor/tree-sitter/src/wasm/wasm-stdlib.h create mode 100644 vendor/tree-sitter/src/wasm_store.c create mode 100644 vendor/tree-sitter/src/wasm_store.h diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..632e5cd --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,26 @@ +[build] +target-dir = "target" +rustc-wrapper = "sccache" + +[env] +DUCKDB_DOWNLOAD_LIB = "1" + +[future-incompat-report] +frequency = "always" + +[term] +verbose = false + +[net] +git-fetch-with-cli = true + +# Keep checked-in rustflags portable. For local-only CPU-specific builds, set +# RUSTFLAGS='-Ctarget-cpu=native' in the shell instead of committing it here. +[target.x86_64-pc-windows-msvc] +rustflags = ["-Dwarnings"] + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-Dwarnings"] + +[target.aarch64-apple-darwin] +rustflags = ["-Dwarnings"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..28a572d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf + +*.bin binary +*.db binary +*.dll binary +*.exe binary +*.gif binary +*.ico binary +*.jpg binary +*.jpeg binary +*.pdb binary +*.png binary +*.sqlite binary +*.zip binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e455cb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.worktrees/ +.serena/ +target/ +.cargo-workspace/ +.bacon-locations + +# Generated build, packaging, profiling, and smoke-test artifacts. +dist/ +tmp/ +coverage/ +flamegraph.svg +perf.data* +fixtures/managed/*/obj/ +fixtures/managed/diagnose-src/**/bin/ +fixtures/managed/diagnose-src/**/obj/ + +# Local reference clones used during privileged-launch research. +# Keep vendor/tree-sitter tracked: Cargo.toml patches tree-sitter to that path. +vendor/windows-privilege-refs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b5da31 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + + +### Maintenance + +- **license:** Reserve all rights + +- **governance:** Add release standards + +## [v0.1.0] - 2026-06-04 + +- First hosted release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f193f9e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +Mercury Toolbox is currently proprietary and distributed under the terms in +[LICENSE](LICENSE). Do not submit third-party code or assets unless their +licensing terms permit this repository's intended use. + +## Commits + +Use Conventional Commits for every non-merge commit: + +```text +type(scope): imperative summary +``` + +Scopes are optional. Supported types are `build`, `chore`, `ci`, `docs`, +`feat`, `fix`, `perf`, `refactor`, `revert`, `style`, and `test`. Use `!` +before the colon for a breaking change, for example: + +```text +feat(sqlshape)!: replace legacy output fields +fix(ci): preserve DuckDB runtime in portable packages +``` + +Check the current commit locally with: + +```powershell +just commit-check +``` + +## Versions and Releases + +Versions follow [Semantic Versioning 2.0.0](https://semver.org/): + +- `MAJOR` for incompatible command-line or output-contract changes. +- `MINOR` for backward-compatible commands, options, and capabilities. +- `PATCH` for backward-compatible fixes and maintenance. + +Set `[workspace.package].version` in `Cargo.toml`, create an annotated `vX.Y.Z` +tag with the exact same version, and add release notes under +`docs/releases/vX.Y.Z.md`. Validate a release candidate with: + +```powershell +just release-version-check vX.Y.Z +just changelog +``` + +`git-cliff` generates `CHANGELOG.md` from Conventional Commits. The existing +`v0.1.0` release is retained as the baseline; new entries are generated from +the commit history after that release. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ab8e74b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5763 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argv" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "runtimekit", + "serde", + "serde_json", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arrow" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607e64bb911ee4f90483e044fe78f175989148c2892e659a2cd25429e782ec54" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e754319ed8a85d817fe7adf183227e0b5308b82790a737b426c1124626b48118" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841321891f247aa86c6112c80d83d89cb36e0addd020fa2425085b8eb6c3f579" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f955dfb73fae000425f49c8226d2044dab60fb7ad4af1e24f961756354d996c9" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5e686972523798f76bef355145bc1ae25a84c731e650268d31ab763c701663" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3b5846209775b6dc8056d77ff9a032b27043383dd5488abd0b663e265b9373" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa70d9d6b1356f1fb9f1f651b84a725b7e0abb93f188cf7d31f14abfa2f2e6f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faec88a945338192beffbbd4be0def70135422930caa244ac3cec0cd213b26b4" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18aa020f6bc8e5201dcd2d4b7f98c68f8a410ef37128263243e6ff2a47a67d4f" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "arrow-select" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a657ab5132e9c8ca3b24eb15a823d0ced38017fe3930ff50167466b02e2d592c" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6de2efbbd1a9f9780ceb8d1ff5d20421b35863b361e3386b4f571f1fc69fcb8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "asmapi" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "managed", + "predicates", + "serde_json", +] + +[[package]] +name = "asmflow" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "managed", + "predicates", + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "asmmember" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "managed", + "predicates", + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "asmref" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "managed", + "predicates", + "serde", + "serde_json", +] + +[[package]] +name = "asmtype" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "managed", + "predicates", + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-native-tls" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57d4cec3c647232e1094dc013546c0b33ce785d8aeb251e1f20dfaf8a9a13fe" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "await" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "native-tls", + "predicates", + "runtimekit", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "binmeta" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "goblin", + "humantime", + "lexopt", + "predicates", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "binrw" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53195f985e88ab94d1cc87e80049dd2929fd39e4a772c5ae96a7e5c4aad3642" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5910da05ee556b789032c8ff5a61fb99239580aa3fd0bfaa8f4d094b2aee00ad" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2s_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee29928bad1e3f94c9d1528da29e07a1d3d04817ae8332de1e8b846c8439f4b3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", + "rayon-core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "btoi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bufstream" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link 0.2.1", +] + +[[package]] +name = "chunkcat" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "cjson" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clrmeta" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b79baced645960d480f3ee57ebc38dc7d5100d061359e384b672edca2de5cf" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "codeindex" +version = "0.1.0" +dependencies = [ + "common", + "serde", + "tree-sitter", + "tree-sitter-c-sharp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-powershell", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "codeshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde", +] + +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "walkdir", +] + +[[package]] +name = "config" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "configsupport", + "lexopt", + "predicates", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "toml", +] + +[[package]] +name = "configsupport" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "context" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "csvshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "csv", + "lexopt", + "predicates", + "serde", + "tempfile", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defsnip" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde_json", + "tempfile", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_utils" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diagpick" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotnetshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "ignore", + "lexopt", + "predicates", + "quick-xml", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "duckdb" +version = "1.10504.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9b997383221efd999a362448a0866c54b9a2cb7d4da8cd4903ead4df9f8eaa" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "rust_decimal", + "strum", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "envdiff" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "predicates", + "serde", + "serde_json", + "windowsupport", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fileprobe" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "humantime", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "gitshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "configsupport", + "lexopt", + "predicates", + "serde", + "tempfile", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "goblin" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "983a6aafb3b12d4c41ea78d39e189af4298ce747353945ff5105b54a056e5cd9" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hitsnip" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "io-enum" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de9008599afe8527a8c9d70423437363b321649161e98473f433de802d76107" +dependencies = [ + "derive_utils", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ison" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde_json", + "tempfile", +] + +[[package]] +name = "isonl" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "ison", + "lexopt", + "predicates", + "serde_json", + "tempfile", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonlgrep" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "regex-lite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "jsonshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexopt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libduckdb-sys" +version = "1.10504.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e67523a40ee3da30411e00e243990b3760d91877202ab2f39c03ab34f8dbfc" +dependencies = [ + "flate2", + "pkg-config", + "reqwest", + "serde", + "serde_json", + "tar", + "vcpkg", + "zip", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "llvmtools" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "goblin", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "logshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4_flex" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "managed" +version = "0.1.0" +dependencies = [ + "clrmeta", + "goblin", + "regex-lite", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mercury-mhash" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "blake2s_simd", + "blake3", + "common", + "crc", + "crc32fast", + "digest 0.10.7", + "lexopt", + "md-5 0.10.6", + "md4", + "memmap2", + "predicates", + "rayon", + "ripemd", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "sha3", + "streebog", + "tempfile", + "tiny-keccak", + "walkdir", + "xxhash-rust", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "msudo" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "windowsupport", +] + +[[package]] +name = "mysql" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a732193888328fc060ab901c0ed1355521267a51ffbfd9a0b3786434c6b8e7f" +dependencies = [ + "bufstream", + "bytes", + "crossbeam-queue", + "crossbeam-utils", + "flate2", + "io-enum", + "libc", + "lru", + "mysql_common", + "named_pipe", + "pem", + "percent-encoding", + "socket2", + "twox-hash", + "url", +] + +[[package]] +name = "mysql-common-derive" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" +dependencies = [ + "darling", + "heck", + "num-bigint", + "proc-macro-crate", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", + "thiserror 2.0.18", +] + +[[package]] +name = "mysql_common" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffc2127d4035fa5a614935c663a15a4468e64e798473e0cc21c8df40a607588" +dependencies = [ + "base64", + "bitflags 2.11.1", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom 0.3.4", + "mysql-common-derive", + "num-bigint", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "named_pipe" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-sock-diag" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a495cb1de50560a7cd12fdcf023db70eec00e340df81be31cedbbfd4aadd6b76" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core", + "netlink-packet-utils", + "smallvec", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "libc", + "log", +] + +[[package]] +name = "netstat2" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "496f264d3ead4870d6b366deb9d20597592d64aac2a907f3e7d07c2325ba4663" +dependencies = [ + "bindgen", + "bitflags 2.11.1", + "byteorder", + "netlink-packet-core", + "netlink-packet-sock-diag", + "netlink-packet-utils", + "netlink-sys", + "num-derive", + "num-traits", + "thiserror 2.0.18", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "outline" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathshadow" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petools" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "goblin", + "lexopt", + "llvmtools", + "predicates", + "regex-lite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portping" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "native-tls", + "predicates", + "serde", + "serde_json", +] + +[[package]] +name = "portunlock" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "humantime", + "lexopt", + "predicates", + "serde", + "serde_json", + "windowsupport", +] + +[[package]] +name = "postgres" +version = "0.19.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proctree" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "regex-lite", + "serde", + "serde_json", + "windowsupport", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recent" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "filetime", + "humantime", + "ignore", + "lexopt", + "predicates", + "regex-lite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "refs" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "codeindex", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reposhape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "configsupport", + "ignore", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "runprobe" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "runtimekit", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "runtimekit" +version = "0.1.0" +dependencies = [ + "common", + "humantime", + "lexopt", + "loom", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snip" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "regex-lite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqliteshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "sqlshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "duckdb", + "lexopt", + "mysql", + "native-tls", + "postgres", + "postgres-native-tls", + "predicates", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "tiberius", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "streebog" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7fe6ed8a42cec360e070309427bb7959e102849b0dbaa7de19d5b9860bd536" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "stringscan" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "sysshape" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "sysinfo", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-native-tls", + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonl" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "toon" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +dependencies = [ + "cc", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-powershell" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unity-asset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6705b95fda449527055ff77fe5cff49f774dde7335ed92bc3d048fc74066ebaf" +dependencies = [ + "unity-asset-binary", + "unity-asset-core", + "unity-asset-yaml", +] + +[[package]] +name = "unity-asset-binary" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d739d1414c1732be1035417f83b1e60f234c7d5eb8abda0ee70d178a58137cb7" +dependencies = [ + "binrw", + "brotli", + "byteorder", + "flate2", + "indexmap", + "lz4_flex", + "lzma-rs", + "memmap2", + "num_cpus", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "unity-asset-core", +] + +[[package]] +name = "unity-asset-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12903fd47e122b5e7e335fc9cb5540bb35ab0ffe5bb57a7741a8c7be8d361361" +dependencies = [ + "indexmap", + "lazy_static", + "serde", + "serde_bytes", + "thiserror 2.0.18", +] + +[[package]] +name = "unity-asset-yaml" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b991e340c27b1578e657defee9f57de867d853b8f40448fb079d0ee3a5ebc77e" +dependencies = [ + "indexmap", + "serde", + "serde_yaml", + "thiserror 2.0.18", + "unity-asset-core", +] + +[[package]] +name = "unityasset" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "regex-lite", + "serde", + "serde_json", + "unitysupport", +] + +[[package]] +name = "unitydiag" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "filetime", + "lexopt", + "predicates", + "serde", + "serde_json", + "unitysupport", +] + +[[package]] +name = "unityprobe" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "unitysupport", +] + +[[package]] +name = "unitysupport" +version = "0.1.0" +dependencies = [ + "common", + "filetime", + "regex-lite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "unity-asset", + "unity-asset-binary", + "windowsupport", +] + +[[package]] +name = "unlock" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "humantime", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", + "windowsupport", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "windowsupport" +version = "0.1.0" +dependencies = [ + "common", + "netstat2", + "serde", + "serde_json", + "sysinfo", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zon" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "common", + "lexopt", + "predicates", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9706537 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,248 @@ +[workspace] +members = [ + "crates/common", + "crates/codeindex", + "crates/configsupport", + "crates/managed", + "crates/runtimekit", + "crates/unitysupport", + "crates/windowsupport", + "crates/msudo", + "crates/cjson", + "crates/ison", + "crates/isonl", + "crates/jsonlgrep", + "crates/jsonshape", + "crates/mhash", + "crates/recent", + "crates/pathshadow", + "crates/portping", + "crates/binmeta", + "crates/llvmtools", + "crates/petools", + "crates/snip", + "crates/outline", + "crates/fileprobe", + "crates/chunkcat", + "crates/hitsnip", + "crates/diagpick", + "crates/logshape", + "crates/stringscan", + "crates/toon", + "crates/tonl", + "crates/zon", + "crates/asmtype", + "crates/asmmember", + "crates/asmref", + "crates/asmapi", + "crates/csvshape", + "crates/sqliteshape", + "crates/sqlshape", + "crates/dotnetshape", + "crates/envdiff", + "crates/proctree", + "crates/unlock", + "crates/defsnip", + "crates/codeshape", + "crates/refs", + "crates/context", + "crates/config", + "crates/gitshape", + "crates/reposhape", + "crates/runprobe", + "crates/await", + "crates/argv", + "crates/portunlock", + "crates/sysshape", + "crates/asmflow", + "crates/unityasset", + "crates/unityprobe", + "crates/unitydiag", +] +exclude = ["fuzz"] +resolver = "2" + +[workspace.package] +authors = ["MercuryToolbox contributors"] +edition = "2024" +version = "0.1.0" +rust-version = "1.86" +license = "LicenseRef-ARR" +license-file = "LICENSE" +readme = "README.md" +publish = false +description = "Mercury Toolbox crates for PowerShell-first, AI-friendly terminal workflows." +keywords = ["cli", "powershell", "toolbox", "mercury"] +categories = ["command-line-utilities"] + +[workspace.dependencies] +anyhow = "1.0.100" +assert_cmd = "2.1.1" +criterion = "0.7.0" +clrmeta = "0.1.0" +filetime = "0.2.26" +goblin = { version = "0.10.5", default-features = false, features = ["std", "pe32", "pe64"] } +humantime = "2.1.0" +ignore = "0.4.23" +insta = "1.44.1" +lexopt = "0.3.2" +loom = "0.7.2" +miette = "7.6.0" +native-tls = "0.2.14" +duckdb = { version = "1.10504.0", default-features = false } +mysql = "28.0.0" +postgres = "0.19.13" +postgres-native-tls = "0.5.3" +predicates = "3.1.3" +proptest = "1.9.0" +quick-xml = "0.38.4" +regex-lite = "0.1.9" +rstest = "0.26.1" +rusqlite = { version = "0.37.0", features = ["bundled"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha2 = { version = "0.10.9", default-features = false } +blake2s_simd = "1.0.3" +blake3 = { version = "1.8.2", features = ["rayon"] } +crc = "3.4.0" +crc32fast = "1.5.0" +digest-traits = { package = "digest", version = "0.10.7" } +md-5 = "0.10.6" +md4 = "0.10.2" +memmap2 = "0.9.9" +rayon = "1.11.0" +ripemd = "0.1.3" +sha1 = "0.10.6" +sha3 = "0.10.8" +streebog = "0.10.2" +tiny-keccak = { version = "2.0.2", features = ["k12", "parallel_hash"] } +xxhash-rust = { version = "0.8.15", default-features = false, features = ["std", "xxh32", "xxh64", "xxh3"] } +sysinfo = { version = "0.37.2", default-features = false, features = ["system"] } +tempfile = "3.20.0" +thiserror = "2.0.17" +tokio = { version = "1.52.3", features = ["net", "rt-multi-thread", "time"] } +tokio-util = { version = "0.7.18", features = ["compat"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } +csv = "1.4.0" +netstat2 = "0.11.2" +walkdir = "2.5.0" +tree-sitter = { version = "0.25.10", default-features = false } +tree-sitter-c-sharp = "0.23.5" +tree-sitter-go = "0.25.0" +tree-sitter-javascript = "0.25.0" +tree-sitter-java = "0.23.5" +tree-sitter-powershell = "0.26.3" +tree-sitter-python = "0.25.0" +tree-sitter-rust = "0.24.2" +tree-sitter-typescript = "0.23.2" +unity-asset = "0.2.0" +unity-asset-binary = "0.2.0" +url = "2.5.8" +windows-sys = { version = "0.61.2", default-features = false } +tiberius = { version = "0.12.3", default-features = false, features = ["native-tls", "tds73", "tokio", "tokio-util"] } + +[patch.crates-io] +tree-sitter = { path = "vendor/tree-sitter" } + +[workspace.lints.rust] +unsafe_code = "deny" +dead_code = "deny" +unreachable_pub = "deny" +unused_imports = "deny" +unused_variables = "deny" +unused_mut = "deny" +unused_must_use = "deny" +missing_docs = "deny" +missing_debug_implementations = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -4 } +pedantic = { level = "deny", priority = -3 } +nursery = { level = "deny", priority = -2 } +cargo = { level = "deny", priority = -1 } +cargo_common_metadata = "deny" +dbg_macro = "deny" +missing_errors_doc = "deny" +missing_panics_doc = "deny" +todo = "deny" +unwrap_used = "deny" + +[profile.dev] +debug = 2 +incremental = true +opt-level = 1 +codegen-units = 256 + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" +panic = "abort" +debug = 1 + +[profile.release-fast] +inherits = "release" +lto = "fat" +opt-level = 3 +codegen-units = 1 +panic = "abort" +strip = "symbols" +debug = 0 + +[profile.release-size] +inherits = "release" +lto = "fat" +opt-level = "z" +codegen-units = 1 +panic = "abort" +strip = "symbols" +debug = 0 + +[profile.release-fast.package.aho-corasick] +opt-level = "s" + +[profile.release-fast.package.codeindex] +opt-level = "s" + +[profile.release-fast.package.globset] +opt-level = "s" + +[profile.release-fast.package.ignore] +opt-level = "s" + +[profile.release-fast.package.regex-automata] +opt-level = "s" + +[profile.release-fast.package.regex-syntax] +opt-level = "s" + +[profile.release-fast.package.tree-sitter] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-c-sharp] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-go] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-javascript] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-java] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-powershell] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-python] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-rust] +opt-level = "s" + +[profile.release-fast.package.tree-sitter-typescript] +opt-level = "s" + +[profile.release-fast.package.walkdir] +opt-level = "s" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e2ee882 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +Copyright (c) 2026 Crockan + +All rights reserved. + +This repository, including its source code, documentation, build scripts, +configuration, and generated assets, is proprietary. No permission is granted +to use, copy, modify, merge, publish, distribute, sublicense, or sell any part +of this repository without prior written permission from the copyright holder. diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..3aa2168 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,6 @@ +@{ + IncludeRules = @( + 'PSAvoidAssignmentToAutomaticVariable', + 'PSAvoidUsingInvokeExpression' + ) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad36838 --- /dev/null +++ b/README.md @@ -0,0 +1,1028 @@ +# Mercury Toolbox / 水星工具箱 + +Mercury Toolbox is proprietary software. See [LICENSE](LICENSE). + +Mercury Toolbox is a PowerShell-first Rust toolbox of small, focused CLI binaries for local inspection, shaping, and triage. + +It is built for the kind of terminal work where you need one command to answer one concrete question quickly: what changed, what owns this port, what does this JSON look like, what symbols exist in this codebase, what imports does this PE pull in, what is inside this Unity asset, or what hash set should I emit for this release artifact. + +Instead of hiding everything behind one giant control binary, Mercury keeps each capability as its own small command with a stable CLI contract, compact text output by default, and structured `--json` or `--toon` output when the next consumer is a script, another tool, or an AI model. + +Quick reasons to use it: + +- PowerShell-friendly by default, including stdin and pipeline behavior that stays predictable on Windows. +- AI-readable structured output through native `--json` and `--toon` modes. +- Narrow commands that are easier to remember, script, package, test, and reason about than one monolithic Swiss-army binary. +- Coverage across code reading, logs, Git/repo shape, config editing, ports/processes, .NET/Unity inspection, PE/LLVM triage, CSV/JSON/SQL shaping, and release hashing through `mhash`. + +Fast starter commands: + +```powershell +fileprobe +reposhape . --json | ConvertFrom-Json +diagpick .\build.log +mhash --algorithm sha256,blake3-256 .\dist\MercuryToolbox.zip --json | ConvertFrom-Json +peimports .\target\release-fast\portping.exe --json | ConvertFrom-Json +``` + +Recommended first read: + +- [Install](#install) if you want the toolbox on your machine now. +- [Which Tool First](#which-tool-first) if you want the shortest route to the right command. +- [Commands](#commands) if you want the full tool map. +- [docs/jade-discipline.md](docs/jade-discipline.md) if you are contributing code or changing shared contracts. +- [docs/maintainer-notes.md](docs/maintainer-notes.md) before changing shared output, JSON-family formats, codeindex-family tools, or Jade gates. + +## Architecture + +- `crates/common` carries the shared CLI contract, exit codes, JSON/text/TOON rendering rules, compact JSON-family format helpers, and stdin policy. +- Set `$env:MERCURY_OUTPUT='json'` or `$env:MERCURY_OUTPUT='toon'` when you want the whole toolbox to default to one structured format for an AI-heavy shell session. +- JSON-capable tools should use native `--toon` or `--format toon`; reserve the standalone `toon` binary for external JSON producers or explicit format conversion. +- `codeshape` and `defsnip` both ride on the shared `codeindex` AST engine, so their JSON now exposes `engine=codeindex`. +- Managed inspection stays split on purpose: `asmtype` finds candidate types, `asmmember` drills into members, `asmflow` handles IL and xrefs, and JSONL piping lets them hand off without copy-paste. +- Unity-focused inspection builds on shared layers instead of one-off scripts: `unityasset` covers offline assets, `unitydiag` covers logs, and `unityprobe` covers read-only runtime inspection through an explicit BepInEx bridge install. + +## Modern CLI Pairing + +- Mercury is meant to work with modern terminal companions, not replace them: `rg`, `fd`, `bat`, `jq`, `yq`, `sd`, `xh`, `tokei`, `eza`, `procs`, `dust`, `hyperfine`, `hexyl`, `zoxide`, `lazygit`, `delta`, `tealdeer`, and `ouch`. +- Prefer `rg` over recursive `grep` or `Select-String`, `fd` over `Get-ChildItem -Recurse`, `bat` or Mercury readers over raw `Get-Content`, `jq` or `yq` over manual JSON or YAML parsing, and `xh` over `curl` for quick HTTP checks. +- Prefer Mercury when the question is “what is the shape of this local thing?” and prefer the companion tool when the question is “which local thing should I open next?” +- `just` is the fast command hub for this repo, and `bacon` is the watch loop that keeps either workspace-wide or subsystem-scoped checks running while you keep editing. + +## Which Tool First + +Start from the job, not from the command name. The table below is the quick routing layer for a fresh shell or an AI agent that has not seen the artifact yet. + +| Job | First command | Follow-up lane | +| --- | --- | --- | +| I have an unknown file | `fileprobe ` | `binmeta`, `stringscan`, `snip`, or `chunkcat` depending on the detected shape | +| I have a repo and need the map | `reposhape . --json` | `codeshape`, `gitshape`, `dotnetshape`, `sqlshape`, or `ctxpack` | +| I have source and need one symbol | `defsnip .` | `refs .`, then `hitsnip` or `ctxpack` for handoff | +| I have a log or build failure | `diagpick ` | `logshape`, `snip --match`, or `runprobe` for the repro command | +| I have a .NET or Unity DLL | `asmref diagnose --resolve-dir ` | `asmtype`, `asmmember`, `asmflow`, and `asmapi diff` | +| I have a Windows EXE or DLL | `peimports ` | `peexports`, `pecalls`, `pesig`, `pestrrefs`, `llvmreadobj`, or `llvmobjdump` | +| I have a Windows driver | `drvshape ` | `ioctlscan`, `peimports --category device_io`, `pecalls --category device_io`, and `pestrrefs` | +| I need an AI-readable handoff | Add `--json` for tooling or `--toon` for model input | Pipe external JSON through `toon` only when the producer is not already a Mercury command | + +Safe starter commands are intentionally read-only: + +```powershell +fileprobe +reposhape . --json | ConvertFrom-Json +asmref diagnose --resolve-dir --format toon +peimports --json | ConvertFrom-Json +drvshape --json | ConvertFrom-Json +``` + +Every command has guided triage notes in the generated AI prompt and Codex skill catalog: an answer shape, a trust basis, and likely next actions. PE deep-analysis commands additionally emit that guidance at runtime through an answer-first text header plus `report_quality` and `next_actions` in structured output. Use those fields as the first trust check before inventing an ad-hoc pipeline. + +## Install + +Recommended on Windows + PowerShell: + +```powershell +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 +``` + +Default install behavior: + +- builds all toolbox binaries in `ReleaseFast` +- stages versioned binaries under `$env:LOCALAPPDATA\MercuryToolbox\versions\...` +- updates the stable command directory at `$env:LOCALAPPDATA\MercuryToolbox\current\bin` +- refreshes the generated AI prompt and Codex skill assets +- copies docs and AI assets into `$env:LOCALAPPDATA\MercuryToolbox\share\mercury-toolbox` +- installs the generated `mercury-toolbox` Codex skill into `$env:CODEX_HOME\skills` when `CODEX_HOME` is set, otherwise into `$HOME\.codex\skills` +- adds that stable command directory to the user `PATH` when missing +- updates the installer process `PATH` and prints a reload snippet for shells launched via `pwsh -File` +- installs every toolbox command: + `cjson`, `ison`, `isonl`, `zon`, `tonl`, `jsonlgrep`, `jsonshape`, `mhash`, `recent`, `pathshadow`, `portping`, `portunlock`, `msudo`, `asmtype`, `asmmember`, `asmref`, `asmapi`, `asmflow`, `llvmobjdump`, `llvmreadobj`, `llvmnm`, `peexports`, `peimports`, `pecalls`, `pesig`, `pestrrefs`, `drvshape`, `ioctlscan`, `binmeta`, `fileprobe`, `outline`, `codeshape`, `refs`, `snip`, `defsnip`, `ctxpack`, `chunkcat`, `hitsnip`, `diagpick`, `logshape`, `stringscan`, `toon`, `csvshape`, `sqliteshape`, `sqlshape`, `unityasset`, `unityprobe`, `unitydiag`, `envdiff`, `proctree`, `sysshape`, `runprobe`, `await`, `argv`, `config`, `gitshape`, `reposhape`, `dotnetshape`, `unlock` + +If you launch the installer as a child PowerShell process, reload the current shell afterward: + +```powershell +$env:Path = [Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') +``` + +Useful install variants: + +```powershell +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -Configuration Debug +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -Configuration ReleaseSize +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -SkipSlimBinaryRebuild +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -InstallRoot C:\Tools\MercuryToolbox +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -CodexHome C:\Tools\CodexHome +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -NoCodexSkillInstall +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 -NoPathUpdate +``` + +Remove the toolbox again with: + +```powershell +pwsh -NoProfile -File .\scripts\uninstall-toolbox.ps1 +``` + +If you installed into a custom root, pass the same root to uninstall: + +```powershell +pwsh -NoProfile -File .\scripts\uninstall-toolbox.ps1 -InstallRoot C:\Tools\MercuryToolbox +``` + +Manual Rust-native fallback: + +```powershell +cargo install --path .\crates\snip +``` + +## Portable Package + +Build a portable release bundle with binaries, package install scripts, manifest, checksums, the combined AI prompt, and the generated Codex skill: + +```powershell +pwsh -NoProfile -File .\scripts\package-toolbox.ps1 +``` + +The first hosted package is documented in +[the v0.1.0 release notes](docs/releases/v0.1.0.md), and hosted archives live +on the [Gitea releases page](../../releases). +For an offline install, download +`MercuryToolbox-x86_64-pc-windows-msvc-ReleaseFast.zip`, extract it, then run +the package installer shown below. + +Default package behavior: + +- builds the full workspace in `ReleaseFast` +- refreshes `docs\ai\mercury-toolbox-ai-prompt.md` +- refreshes `skills\mercury-toolbox\` +- stages a portable directory under `.\dist\MercuryToolbox--ReleaseFast` +- writes `SHA256SUMS.txt` and `mercury-toolbox-package.json` +- creates a matching `.zip` archive you can copy to another machine + +Useful package variants: + +```powershell +pwsh -NoProfile -File .\scripts\package-toolbox.ps1 -Configuration ReleaseSize +pwsh -NoProfile -File .\scripts\package-toolbox.ps1 -SkipSlimBinaryRebuild +pwsh -NoProfile -File .\scripts\package-toolbox.ps1 -OutputRoot C:\Artifacts +pwsh -NoProfile -File .\scripts\package-toolbox.ps1 -PackageName MercuryToolbox-custom +``` + +Install directly from an extracted package without the Rust toolchain: + +```powershell +pwsh -NoProfile -File .\dist\MercuryToolbox--ReleaseFast\scripts\install-package-toolbox.ps1 +``` + +That package installer: + +- stages packaged binaries under `$env:LOCALAPPDATA\MercuryToolbox\versions\...` +- updates the stable command directory at `$env:LOCALAPPDATA\MercuryToolbox\current\bin` +- copies the AI prompt, generated skill, manifest, and checksums into `$env:LOCALAPPDATA\MercuryToolbox\share\mercury-toolbox` +- installs the generated `mercury-toolbox` Codex skill into `$env:CODEX_HOME\skills` when `CODEX_HOME` is set, otherwise into `$HOME\.codex\skills` +- updates the user `PATH`, and prints the same reload snippet for shells launched via `pwsh -File`, unless `-NoPathUpdate` is used + +Remove a package install again with: + +```powershell +pwsh -NoProfile -File .\dist\MercuryToolbox--ReleaseFast\scripts\uninstall-package-toolbox.ps1 +``` + +## Build Profiles And Size Tuning + +`ReleaseFast` is the default installer and packager configuration. It uses `opt-level=3`, fat LTO, one codegen unit, `panic=abort`, stripped symbols, and no debug info for fast runtime in portable binaries. This is intentionally slower to compile than debug or plain release builds. + +`ReleaseSize` uses the same fat LTO, single-codegen-unit, stripped, aborting baseline with `opt-level="z"` for the smallest binaries and packages. Use it when archive size matters more than throughput, then run the probe gate to compare actual sizes: + +```powershell +pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseSize -SkipBenchmarks +``` + +The install and package scripts perform a second "slim rebuild" pass for selected codeindex-heavy tools after the workspace build. Keep the default for release artifacts. Use `-SkipSlimBinaryRebuild` only for faster local iteration when you can accept potentially larger selected binaries. + +Portable packages deliberately do not set `target-cpu=native`. For a local-only build on the current machine, opt in from the shell: + +```powershell +$env:RUSTFLAGS = '-Ctarget-cpu=native' +cargo build --profile release-fast -p mercury-mhash --bin mhash +Remove-Item Env:\RUSTFLAGS +``` + +## Commands + +`HIGH RISK:` `msudo` is the top-level high-risk toolbox command because it can cross the Windows privilege boundary. Start with safe discovery first: `msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated`, then inspect `msudo --help` or `msudo run --help` before any explicit `run` request. + +### Tool Map + +Every command supports `--help`, compact text by default, and structured output through `--json`, `--toon`, or `--format text|json|toon` unless a command-specific format flag is explicitly documented. + +| Area | Tool | Primary use | Fast start | +| --- | --- | --- | --- | +| Data and config | `cjson` | Compact JSON or JSONL. | `cjson ` | +| Data and config | `ison` | Convert JSON and ISON. | `ison ` | +| Data and config | `isonl` | Convert JSONL and ISONL. | `isonl --from jsonl --to isonl ` | +| Data and config | `zon` | Convert JSON/JSONL and ZON. | `zon ` | +| Data and config | `tonl` | Query, validate, index, stream, and ETL TONL. | `tonl query --where active=true ` | +| Data and config | `mhash` | Hash, manifest, verify, and benchmark files. | `mhash --algorithm sha256 ` | +| Data and config | `jsonlgrep` | Filter and project JSONL or JSON arrays. | `jsonlgrep level=error ` | +| Data and config | `jsonshape` | Summarize or diff JSON shape. | `jsonshape ` | +| Data and config | `csvshape` | Summarize or diff CSV/TSV shape. | `csvshape ` | +| Data and config | `sqliteshape` | Inspect or diff SQLite schema/table shape. | `sqliteshape ` | +| Data and config | `sqlshape` | Inspect or diff normalized SQL schema across engines. | `sqlshape --engine sqlite --url ` | +| Data and config | `config` | Read or edit JSON/YAML/TOML/env/INI config. | `config get /version` | +| Data and config | `toon` | Convert external/raw JSON and TOON. | `'{"ok":true}' \| toon` | +| Code and context | `recent` | List fresh files or directories. | `recent --root . --since 2h` | +| Code and context | `pathshadow` | Explain PATH winners and shadowed commands. | `pathshadow rg --summary` | +| Code and context | `fileprobe` | Classify files before opening them. | `fileprobe ` | +| Code and context | `outline` | List top-level source or config structure. | `outline ` | +| Code and context | `codeshape` | Map repo files and AST declarations. | `codeshape .` | +| Code and context | `refs` | Find symbol references or callers. | `refs helper .` | +| Code and context | `snip` | Extract exact lines, matches, or symbols. | `snip --lines 16:27 ` | +| Code and context | `defsnip` | Extract full AST-backed definitions. | `defsnip helper .` | +| Code and context | `ctxpack` | Pack files/snippets/hits/diagnostics for prompts. | `ctxpack ` | +| Code and context | `chunkcat` | Read large files in bounded chunks. | `chunkcat --max-lines 40` | +| Code and context | `hitsnip` | Turn search hits into merged snippets. | `rg -nH "helper" . \| hitsnip --def` | +| Code and context | `diagpick` | Extract actionable diagnostics from logs. | `diagpick --def` | +| Code and context | `gitshape` | Summarize Git status and diffs. | `gitshape status` | +| Code and context | `reposhape` | Detect repo ecosystems and entrypoints. | `reposhape .` | +| Code and context | `dotnetshape` | Inspect .NET project graphs and MSBuild config shape. | `dotnetshape .` | +| Logs and runtime | `logshape` | Group repetitive log lines. | `logshape ` | +| Logs and runtime | `stringscan` | Extract and classify strings from blobs. | `stringscan ` | +| Logs and runtime | `envdiff` | Snapshot, diff, or wrap environment changes. | `envdiff run --shell pwsh -- ` | +| Logs and runtime | `proctree` | Inspect system, rooted, or command process trees. | `proctree system` | +| Logs and runtime | `sysshape` | Capture machine, shell, env, and tool shape. | `sysshape --group shell` | +| Logs and runtime | `runprobe` | Run one command and capture a stable result object. | `runprobe --shell raw -- ` | +| Logs and runtime | `await` | Poll paths, ports, HTTP, or commands. | `await path --state exists` | +| Logs and runtime | `argv` | Quote argv or inspect shell argument passing. | `argv quote --shell pwsh -- git status` | +| Network and locks | `portping` | Probe TCP/HTTP/HTTPS with timings. | `portping tcp://127.0.0.1:80` | +| Network and locks | `portunlock` | Show or free local port owners. | `portunlock who 3000` | +| Network and locks | `unlock` | Inspect file lockers and unlock-aware file actions. | `unlock who ` | +| Network and locks | `msudo` | Inspect elevation state before privileged launches. | `msudo status --json` | +| Managed, Unity, binary | `asmtype` | Find managed assembly types. | `asmtype --match SpaceCraft` | +| Managed, Unity, binary | `asmmember` | Inspect managed type members. | `asmmember --assembly ` | +| Managed, Unity, binary | `asmref` | Inspect managed assembly references and dependency closure risks. | `asmref diagnose --resolve-dir ` | +| Managed, Unity, binary | `asmapi` | Diff managed public API surfaces. | `asmapi diff ` | +| Managed, Unity, binary | `asmflow` | Inspect managed IL bodies and xrefs. | `asmflow find --match Start` | +| Managed, Unity, binary | `llvmobjdump` | Summarize LLVM objdump sections and disassembly. | `llvmobjdump --json` | +| Managed, Unity, binary | `llvmreadobj` | Inspect COFF/PE headers, sections, imports, exports, and debug hints through LLVM. | `llvmreadobj --json` | +| Managed, Unity, binary | `llvmnm` | List and classify object symbols through LLVM nm output. | `llvmnm --defined-only --json` | +| Managed, Unity, binary | `peexports` | Deeply inspect PE export tables. | `peexports --json` | +| Managed, Unity, binary | `peimports` | Categorize PE import tables by API family. | `peimports --category device_io --json` | +| Managed, Unity, binary | `pecalls` | Extract imported API callsites from LLVM disassembly. | `pecalls --api WSAStartup --json` | +| Managed, Unity, binary | `pesig` | Infer PE function boundaries and calling convention hints. | `pesig --min-confidence medium --json` | +| Managed, Unity, binary | `pestrrefs` | Find strings and direct cross-references in PE code. | `pestrrefs --contains DeviceIoControl --json` | +| Managed, Unity, binary | `drvshape` | Summarize likely Windows driver traits and hints. | `drvshape --json` | +| Managed, Unity, binary | `ioctlscan` | Scan PE bytes for probable Windows IOCTL constants. | `ioctlscan --json` | +| Managed, Unity, binary | `binmeta` | Inspect file metadata, PE basics, version resources, and Windows signature identity. | `binmeta ` | +| Managed, Unity, binary | `unityasset` | Inspect offline Unity assets and bundles. | `unityasset index ` | +| Managed, Unity, binary | `unityprobe` | Query an explicit read-only BepInEx bridge. | `unityprobe status --game-root ` | +| Managed, Unity, binary | `unitydiag` | Summarize Unity and BepInEx logs. | `unitydiag --game-root --latest` | + +### `cjson` + +Compact JSON or JSONL into single-line output, with an optional stable recursive key sort for diffing, caching, and AI-friendly ingestion. + +```powershell +cjson .\fixtures\cjson\sample.json +bat --style=plain --paging=never .\fixtures\cjson\records.jsonl | cjson --input-format jsonl --sort-keys +cjson --sort-keys --json .\fixtures\cjson\sample.json | ConvertFrom-Json +``` + +### `ison` / `isonl` / `zon` / `tonl` + +Work with the compressed JSON family without adding Node or TypeScript runtime dependencies. `ison`, `isonl`, and `zon` are format converters; `tonl` also includes query, validate, index, streaming query, and ETL commands. Use `--toon` or `--format toon` when the wrapper output itself is going to an AI/model. + +```powershell +ison .\fixtures\json-family\ison\users.json +isonl --from isonl --to jsonl .\fixtures\json-family\ison\users.isonl +zon .\fixtures\json-family\zon\person.json --toon +tonl query --where active=true .\fixtures\json-family\tonl\records.tonl --format toon +``` + +### `jsonlgrep` + +Filter JSONL, single-line JSON arrays, or line-oriented logs with field selectors, negation, nested paths, projections, and JSON output. + +```powershell +jsonlgrep level=error .\fixtures\jsonl\events.jsonl --pick ts,msg +jsonlgrep event.user.name!=alice .\nested.jsonl --pick event.user.name,status +bat --style=plain --paging=never .\fixtures\jsonl\events.jsonl | jsonlgrep 'msg~=login' --count --json | ConvertFrom-Json +recent --root . --limit 20 --json | jsonlgrep 'path~=LogOutput' --pick modified_rfc3339,path --json | ConvertFrom-Json +``` + +### `jsonshape` + +Summarize JSON or JSONL structure into compact path statistics, or diff two shape reports when you want additions, removals, and changed kinds without opening the raw payloads. + +```powershell +jsonshape .\fixtures\reading\config.json +jsonshape .\fixtures\jsonshape\events.jsonl --input-format jsonl --json | ConvertFrom-Json +jsonshape .\fixtures\toon\config.json --limit 8 +jsonshape diff .\fixtures\reading\config.json .\fixtures\toon\config.json --json | ConvertFrom-Json +``` + +### `mhash` + +Compute high-performance OpenHashTab-class file mhashs, write JSONL manifests, verify manifests, list the supported algorithm matrix, and benchmark hash throughput without leaving PowerShell-friendly CLI flows. + +`mhash` defaults to SHA-256 text output for simple local checks. Use `--algorithm` for explicit multi-algorithm hashing, `--all` when you want the full OpenHashTab-class matrix, `--json` or `--format jsonl` for machine handoff, and `--manifest` when the hash run should produce a reusable verification file. `mhash verify` reads Mercury JSONL manifests and common checksum-style lines; pass exactly one `--algorithm` when verifying checksum-style text that does not name the algorithm. + +```powershell +mhash .\README.md +mhash --algorithm sha256,blake3-256,xxh3-128 .\dist\MercuryToolbox.zip +mhash list-algorithms +mhash list-algorithms --json | ConvertFrom-Json | Sort-Object family,name +fd -t f . .\dist | mhash --input-format lines --algorithm sha256,blake3-256 --format jsonl | jq -r '.algorithm + " " + .path' +mhash --recursive --ignore target --all --manifest .\checksums.jsonl .\dist --jsonl +mhash verify .\checksums.jsonl --json | ConvertFrom-Json +mhash bench --algorithm sha256,blake3-256,xxh3-128 .\dist\MercuryToolbox.zip +hyperfine 'mhash --algorithm sha256 .\dist\MercuryToolbox.zip' 'mhash --algorithm blake3-256 .\dist\MercuryToolbox.zip' +``` + +### `recent` + +List recently modified files or directories, respecting `.gitignore` by default and filtering by extension, kind, or basename regex. + +```powershell +recent --root . --since 2h --ext rs --name '^(lib|main)$' +'.' | recent --kind dir --limit 10 +'.' | recent --limit 5 --json | ConvertFrom-Json +``` + +### `pathshadow` + +Inspect command resolution order across `PATH`, explain who wins, and find every duplicate command available to the shell. Use `--shell powershell` when AI work needs the actual PowerShell winner, including aliases and cmdlets, and `--high-signal --summary` when full duplicate scans are too noisy. + +```powershell +pathshadow rg +pathshadow python npm --shell powershell +'cargo' | pathshadow --json | ConvertFrom-Json +pathshadow --all-duplicates --high-signal --summary --json | ConvertFrom-Json +``` + +### `portping` + +Probe TCP, HTTP, and HTTPS targets and emit compact timing summaries or JSON records with method and status expectation support. + +```powershell +portping tcp://127.0.0.1:80 +portping https://example.com/health --method HEAD --expect-status 200 +'https://example.com' | portping --json | ConvertFrom-Json +``` + +### `portunlock` + +Inspect local TCP or UDP port owners on Windows and, when needed, free the port by terminating only the exact remaining owning processes. + +```powershell +portunlock who 3000 +portunlock free --force 5000 +'5000' | portunlock who --json | ConvertFrom-Json +``` + +### `msudo` + +Inspect the current Windows elevation relay status and, when you explicitly choose to cross that boundary, launch a one-shot elevated child process through the configured host. + +Reliable today: one-shot `current-process`, `current-user`, `admin`, `system`, and `trustedinstaller` commands, foreground `--same-console` launches in the current terminal, active-session targeting for duplicated high-privilege tokens, and interactive `SYSTEM` / `TrustedInstaller` shells either in a new window or in the current console when `--same-console` is explicit. The CLI is intentionally native to Mercury rather than syntax-compatible with gsudo or NSudo; use explicit options such as `--user trustedinstaller --dangerous --privileges enable-all`. For high-risk flows, start with `msudo status --json` and keep the requested command explicit. + +`HIGH RISK:` The global `msudo` rule above applies here too: inspect with `msudo status --json`, check `--help` before using new flags, and relay only the exact command you intend to run. + +For `--user trustedinstaller`, verify the token with `whoami /groups`, not just `whoami /user`: the launch runs from the `TrustedInstaller` service token, so the user SID still reports `NT AUTHORITY\SYSTEM` while the token carries the `NT SERVICE\TrustedInstaller` service SID. + +```powershell +msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated +msudo --help +msudo run --help +msudo --user trustedinstaller --dangerous --privileges enable-all --integrity system --wait -- cmd /d /c whoami /groups +msudo --same-console --user system --dangerous --shell powershell +``` + +### `asmtype` + +List managed assembly types with compact filters for Unity and general .NET reverse-engineering flows. `--with-member-match` lets you keep only the types whose members look relevant, `--show-matched-members` explains why each type survived that pass while preferring user-facing hits over auto-property backing fields when both are present, `--pick` trims the output for pipelines, `--base` is the fast “derived from this base type” filter, and `--intent unity-spacecraft-workflow` bakes in a good first-pass keyword bundle. + +```powershell +asmtype .\fixtures\managed\bin\GameAssembly.dll --match SpaceCraft +asmtype .\fixtures\managed\bin\GameAssembly.dll --with-member-match 'StartProject|QueueVehicle' +asmtype .\fixtures\managed\bin\GameAssembly.dll --with-member-match 'Build|Launch' --show-matched-members --json | ConvertFrom-Json +asmtype .\fixtures\managed\bin\GameAssembly.dll --base '^Game\.ObjectInfoDataScripts\.ProductionItem$' --pick full_name,base_type +asmtype .\fixtures\managed\bin\GameAssembly.dll --with-member-match 'k__BackingField' --user-code-only +'C:\game\Managed\Assembly-CSharp.dll' | asmtype --input-format lines --intent unity-spacecraft-workflow --json | ConvertFrom-Json +'C:\game\Managed\Assembly-CSharp.dll' | asmtype --input-format lines --match 'Rocket|Launch' +asmtype .\fixtures\managed\bin\GameAssembly.dll --namespace 'Game\.UI' --json | ConvertFrom-Json +``` + +### `asmmember` + +List methods, fields, and properties from managed types without hand-written reflection scripts. It now accepts JSONL rows from `asmtype --json`, so AI flows can go from “find likely types” to “inspect their members” without copying names around. `--pick` trims multi-type pipelines down to the fields you actually want, `--user-code-only` hides compiler-generated backing fields and closure artifacts, and the Unity intent preset turns that on automatically. + +```powershell +asmmember --assembly .\fixtures\managed\bin\GameAssembly.dll Game.UI.Windows.Windows.SpaceCraftConstructionWindow +asmtype .\fixtures\managed\bin\GameAssembly.dll --intent unity-spacecraft-workflow --json | asmmember --assembly .\fixtures\managed\bin\GameAssembly.dll --input-format jsonl --intent unity-spacecraft-workflow --json | ConvertFrom-Json +'Game.UI.Windows.Windows.SpaceCraftConstructionWindow' | asmmember --assembly C:\game\Managed\Assembly-CSharp.dll --input-format lines --match 'Build|Launch' +asmmember --assembly .\fixtures\managed\bin\GameAssembly.dll Data.SpacecraftConstructData --pick kind,name,visibility,signature +asmmember --assembly .\fixtures\managed\bin\GameAssembly.dll Data.SpacecraftConstructData --user-code-only +asmmember --assembly .\fixtures\managed\bin\GameAssembly.dll Data.SpacecraftConstructData --binding public,nonpublic,instance,static --json | ConvertFrom-Json +``` + +### `asmref` + +Inspect managed assembly references and see which dependencies resolve from nearby directories. Use `diagnose` for Unity/.NET/Rocket plugin dependency closure checks, including missing references, same-name candidate conflicts, winning DLLs, test-only leaks, and obvious MissingMethod/TypeLoad risks. + +```powershell +asmref .\fixtures\managed\bin\GameAssembly.dll --resolve-dir .\fixtures\managed\bin +asmref diagnose .\fixtures\managed\bin\GameAssembly.dll --resolve-dir .\fixtures\managed\bin --format toon +asmref diagnose .\RootPlugin.dll --test-only-pattern Project.Tests --no-default-test-patterns --json | ConvertFrom-Json +'C:\game\Managed\Assembly-CSharp.dll' | asmref --input-format lines --resolve-dir C:\game\Managed --json | ConvertFrom-Json +asmref .\fixtures\managed\bin\GameAssembly.dll --json | ConvertFrom-Json +``` + +### `asmapi` + +Diff two managed assemblies by normalized type and method API. Public API is the default; use `--visibility internal` or `--visibility all` when reverse-engineering non-public compatibility, and use `--include-special` when property/event accessor methods should be visible. + +```powershell +asmapi diff old\0Harmony.dll new\0Harmony.dll +asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json | ConvertFrom-Json +asmapi diff old.dll new.dll --visibility all +``` + +### `asmflow` + +Inspect managed method bodies, decoded IL, callers, callees, field access, and string literals when type/member lists are not enough. Use `find` to locate candidate methods, `body` to inspect the IL window, and `xref` to understand call flow. + +```powershell +asmflow find .\fixtures\managed\bin\GameAssembly.dll --match 'StartProject|QueueVehicle' +asmflow body --assembly .\fixtures\managed\bin\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' +asmflow xref --assembly .\fixtures\managed\bin\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartLaunch' --direction both +asmflow xref --assembly .\fixtures\managed\bin\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartLaunch' --direction callers --virtual --json | ConvertFrom-Json +``` + +### `llvmobjdump` + +Run `llvm-objdump` through Mercury's LLVM backend discovery and turn section tables, symbol labels, and disassembly rows into compact text, JSON, or TOON. + +```powershell +llvmobjdump .\target\release-fast\binmeta.exe --json | ConvertFrom-Json +llvmobjdump .\target\release-fast\binmeta.exe --section .text --toon +``` + +### `llvmreadobj` + +Run `llvm-readobj` through Mercury's LLVM backend discovery and summarize COFF/PE file headers, sections, imports, exports, debug directories, and CodeView/PDB hints. + +```powershell +llvmreadobj .\target\release-fast\binmeta.exe --json | ConvertFrom-Json +llvmreadobj .\target\release-fast\binmeta.exe --debug-directory --toon +``` + +### `llvmnm` + +Run `llvm-nm` through Mercury's LLVM backend discovery and parse POSIX-style symbol rows, including object prefixes and source-line hints when LLVM emits them. + +```powershell +llvmnm .\target\release-fast\binmeta.exe --defined-only --json | ConvertFrom-Json +llvmnm .\target\release-fast\binmeta.exe --undefined-only --toon +``` + +### `peexports` + +Inspect PE export tables with ordinals, RVAs, forwarders, and entrypoint hints. + +```powershell +peexports C:\Windows\System32\kernel32.dll --json | ConvertFrom-Json +peexports C:\Windows\System32\kernel32.dll --forwarders-only --toon +``` + +### `peimports` + +Inspect and categorize PE imports by API family so you can spot filesystem, registry, device I/O, NT, and network usage quickly. + +```powershell +peimports .\target\release-fast\portping.exe --category device_io --json | ConvertFrom-Json +peimports .\target\release-fast\portping.exe --library kernel32.dll --toon +``` + +### `pecalls` + +Extract imported API callsites from LLVM disassembly and keep unresolved rows separate from confirmed import hits. + +```powershell +pecalls .\target\release-fast\portping.exe --api WSAStartup --json | ConvertFrom-Json +pecalls .\target\release-fast\binmeta.exe --category device_io --include-jumps --toon +``` + +### `pesig` + +Infer function boundaries and calling-convention hints from PE code shape and symbol labels. + +```powershell +pesig .\target\release-fast\portping.exe --min-confidence medium --json | ConvertFrom-Json +pesig .\fixtures\managed\bin\GameAssembly.dll --min-confidence medium --toon +``` + +### `pestrrefs` + +Scan ASCII and UTF-16LE strings, then surface direct cross-references when LLVM disassembly can prove them. + +```powershell +pestrrefs .\target\release-fast\portping.exe --contains DeviceIoControl --json | ConvertFrom-Json +pestrrefs .\fixtures\managed\bin\GameAssembly.dll --encoding both --min-len 6 --toon +``` + +### `drvshape` + +Summarize likely driver shape, including WDM, KMDF, NDIS, minifilter, and native driver hints. + +```powershell +drvshape C:\Windows\System32\drivers\ndis.sys --json | ConvertFrom-Json +drvshape C:\Windows\System32\drivers\ndis.sys --toon +``` + +### `ioctlscan` + +Scan PE bytes for probable Windows IOCTL constants and decode the `CTL_CODE` fields for quick triage. + +```powershell +ioctlscan C:\Windows\System32\drivers\ndis.sys --json | ConvertFrom-Json +ioctlscan C:\Windows\System32\drivers\ndis.sys --toon +``` + +### `binmeta` + +Inspect file metadata, Windows PE structure, version resources, and Authenticode/catalog signature identity with compact text output for humans and stable JSON for automation. + +```powershell +binmeta .\fixtures\binmeta\plain.txt +binmeta .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json +binmeta C:\Windows\System32\notepad.exe --json | ConvertFrom-Json | Select-Object path,version,signature +fd -a '^jsonlgrep\.exe$' .\target | binmeta --input-format lines --json | ConvertFrom-Json +``` + +For PE files, `binmeta` adds nullable `version` and `signature` objects. Version output includes common resource strings such as company, product, file version, original filename, and fixed four-part versions; signature output includes embedded certificate table presence, WinTrust status, embedded-vs-catalog signature type, catalog path/member tag when available, signer subject, chain summaries, timestamp signer, and certificate expiry fields. Signature or trust failures are reported as data and do not make `binmeta` fail. + +### `fileprobe` + +Probe whether a file is source, config, text, or binary before opening it, and surface AI-friendly hints such as language, minified/generated status, encoding, BOM, and newline style. + +```powershell +fileprobe .\fixtures\reading\sample.rs +fileprobe .\fixtures\reading\minified.js --json | ConvertFrom-Json +fd -a '^sample\.rs$' .\fixtures\reading | fileprobe --input-format lines --json | ConvertFrom-Json +``` + +### `outline` + +Emit heuristic structure summaries for Rust, C#, JSON, TOML, and YAML so you can jump straight to the interesting part of a file. + +```powershell +outline .\fixtures\reading\sample.rs +outline --depth 1 .\fixtures\reading\config.toml +'.\fixtures\reading\config.yaml' | outline --json | ConvertFrom-Json +``` + +### `codeshape` + +Walk a code repository with tree-sitter-backed indexing and emit a compact project map of files, declarations, and signatures. The JSON report includes `engine=codeindex` so downstream consumers can see the shared AST source explicitly. + +```powershell +codeshape .\fixtures\polyglot\repo +codeshape --max-depth 1 --limit-per-file 8 .\fixtures\polyglot\repo --json | ConvertFrom-Json +'.\fixtures\polyglot\repo' | codeshape --json | ConvertFrom-Json +``` + +### `refs` + +Find exact symbol use-sites or caller summaries through the shared `codeindex` engine, with text fallback when AST lookup is unavailable. + +```powershell +refs helper .\fixtures\polyglot\repo +refs --callers helper .\fixtures\polyglot\repo --json | ConvertFrom-Json +refs --at .\fixtures\polyglot\repo\src\lib.rs:25 .\fixtures\polyglot\repo +``` + +### `snip` + +Extract precise ranges, regex neighborhoods, or heuristic symbol blocks from files or piped text with numbered output. + +```powershell +snip --lines 16:27 .\fixtures\reading\sample.rs +snip --symbol run .\fixtures\reading\sample.rs --json | ConvertFrom-Json +snip --around helper --context 0 .\fixtures\reading\sample.rs +``` + +### `defsnip` + +Extract full AST-backed definition blocks by exact symbol name so nearby line churn does not break your code slices. The JSON rows include `engine=codeindex` for the same reason as `codeshape`, and the text mode now warns when one symbol name resolves to multiple definitions. + +```powershell +defsnip helper .\fixtures\polyglot\repo +defsnip build_report . --json | ConvertFrom-Json +defsnip --kind method --parents Build .\fixtures\polyglot\repo +'.\fixtures\polyglot\repo\web\app.ts' | defsnip helper --json | ConvertFrom-Json +``` + +### `ctxpack` + +Pack files, line hits, diagnostics, snippets, and definition JSON into compact context blocks that are ready to hand to an AI agent or paste into a review note. + +```powershell +ctxpack .\fixtures\polyglot\repo\src\lib.rs:26 +refs helper .\fixtures\polyglot\repo --json | ctxpack --input-format auto +diagpick .\fixtures\diag\rust-errors.txt --def --json | ctxpack --input-format auto --json | ConvertFrom-Json +``` + +### `chunkcat` + +Plan stable line chunks for large text files, then print only the chunk you need with optional JSON metadata for scripts and AI flows. `--tail` is the quick “show me the last window” mode for logs. + +```powershell +chunkcat .\fixtures\reading\sample.rs --max-lines 8 +chunkcat .\fixtures\reading\sample.rs --max-lines 8 --chunk 2 --json | ConvertFrom-Json +chunkcat .\BepInEx\LogOutput.log --max-lines 20 --tail +fd -a '^sample\.rs$' .\fixtures\reading | chunkcat --input-format lines --chunk 0 +``` + +### `hitsnip` + +Turn `rg -n` output or `path:line[:column]` hit streams into merged, numbered snippets. `--def` promotes those hits to the enclosing definition when `codeindex` supports the file, and `--allow-empty` keeps exploratory pipelines quiet when there were no hits. + +```powershell +rg -nH "Mode::" .\fixtures\reading\sample.rs | hitsnip --context 1 +rg -nH "helper" .\fixtures\polyglot\repo\src\lib.rs | hitsnip --def +rg -n "error|warning" .\fixtures\diag\rust-errors.txt | hitsnip --path .\fixtures\diag\rust-errors.txt --context 1 +rg -nH "DefinitelyMissing" .\fixtures\reading\sample.rs | hitsnip --allow-empty --json | ConvertFrom-Json +bat --style=plain --paging=never .\fixtures\hits\rg-output.txt | hitsnip --json | ConvertFrom-Json +``` + +### `diagpick` + +Extract actionable diagnostics from compiler and runtime logs, with optional snippets and enclosing definitions. Clean logs now say `0 actionable diagnostics` in text mode instead of failing silently. + +```powershell +diagpick .\fixtures\diag\rust-errors.txt +diagpick .\fixtures\diag\unity-errors.txt --snip --json | ConvertFrom-Json +diagpick .\fixtures\diag\rust-errors.txt --def +diagpick .\BepInEx\LogOutput.log --severity warning +``` + +### `logshape` + +Collapse repetitive logs into template groups before reading the raw stream. + +```powershell +logshape .\fixtures\logs\repetitive.log +logshape .\fixtures\logs\repetitive.log --json | ConvertFrom-Json | Select-Object -ExpandProperty groups +``` + +### `stringscan` + +Extract printable strings from binaries or opaque artifacts and classify the high-signal matches. + +```powershell +stringscan .\fixtures\binaries\stringscan-sample.bin +stringscan .\fixtures\binaries\stringscan-sample.bin --details --kind bepinex +fd -a '^stringscan-sample\.bin$' .\fixtures\binaries | stringscan --input-format lines --json | ConvertFrom-Json +``` + +### `toon` + +Convert between JSON and TOON text, including tabular arrays, safe key folding, safe dotted-path expansion, and JSONL-to-array ingestion. + +```powershell +toon .\fixtures\toon\config.json +toon .\fixtures\toon\config.toon --json | ConvertFrom-Json +toon .\fixtures\toon\records.jsonl +``` + +### `csvshape` + +Summarize huge CSV or TSV files into a bounded, AI-friendly schema view with detected delimiter, header presence, inferred column kinds, blank counts, short examples, and diff support. + +```powershell +csvshape .\fixtures\csvshape\sample.csv +csvshape .\fixtures\csvshape\sample.tsv --delimiter tab --json | ConvertFrom-Json +csvshape .\fixtures\csvshape\sample.csv --limit-columns 8 --sample-rows 16 +csvshape diff .\fixtures\csvshape\sample.csv .\fixtures\csvshape\sample.tsv --json | ConvertFrom-Json +``` + +### `sqliteshape` + +Inspect SQLite databases read-only without dropping into an interactive shell, keeping the default view cheap and compact, and diff two database shapes when schemas move. Use `sqliteshape` for SQLite-only local inspection; use `sqlshape` when you need a normalized cross-database report or diff. + +```powershell +sqliteshape .\fixtures\sqliteshape\sample.db +'C:\data\events.db' | sqliteshape --input-format lines --json | ConvertFrom-Json +sqliteshape .\fixtures\sqliteshape\sample.db --table users --include-indexes +sqliteshape diff .\before.db .\after.db --json | ConvertFrom-Json +``` + +### `sqlshape` + +Inspect SQL schema shape read-only across PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, and DuckDB. It normalizes tables, views, columns, primary keys, foreign keys, indexes, and cheap row estimates into one model, and it redacts connection strings in reports and errors. + +```powershell +sqlshape --engine sqlite --url .\fixtures\sqliteshape\sample.db --json | ConvertFrom-Json +sqlshape --engine duckdb --url .\data\warehouse.duckdb --toon +sqlshape --engine postgres --url "postgres://user:pass@host:5432/app" --schema public +sqlshape diff --before-engine sqlite --before-url .\before.db --after-engine duckdb --after-url .\after.duckdb --json | ConvertFrom-Json +``` + +### `unityasset` + +Inspect offline Unity assets, AssetBundles, YAML scenes, and prefabs without opening the Unity editor. Start with `index`, then use object ids with `refs`, `dump`, or `extract`. + +```powershell +unityasset index 'C:\game\Game_Data' +unityasset index 'C:\game\Game_Data' --class MonoBehaviour --script 'MarketOffer|LaunchVehicle' +unityasset index 'C:\game\Game_Data' --class '^VideoClip$' --summary +unityasset refs 'sharedassets0.assets#1234' 'C:\game\Game_Data' --json | ConvertFrom-Json +unityasset extract 'resources.assets#5678' 'C:\game\Game_Data' --output-dir .\out +``` + +### `unityprobe` + +Query a running Mono Unity game through an explicit read-only BepInEx bridge. It does not inject implicitly: install the bridge first, check status, then query scenes, objects, instances, or static state. + +```powershell +unityprobe install 'C:\game' +unityprobe status --game-root 'C:\game' +unityprobe --json scenes | ConvertFrom-Json +unityprobe --json find 'Game\.UI\.Windows' | ConvertFrom-Json +unityprobe inspect 10432 +unityprobe static Manager.GameManager +``` + +### `unitydiag` + +Extract high-signal incidents from Unity `Player.log` and BepInEx logs, with grouping, stack controls, severity filters, and game-root discovery. + +```powershell +unitydiag 'C:\Users\example\AppData\LocalLow\Studio\Game\Player.log' +unitydiag --game-root 'C:\game' --latest +unitydiag 'C:\game\BepInEx\LogOutput.log' --include-info --top 10 +unitydiag --game-root 'C:\game' --json | ConvertFrom-Json +``` + +### `envdiff` + +Capture environment snapshots, compare them, or wrap a command so PATH pollution and variable mutations become obvious immediately. + +```powershell +envdiff snapshot --output .\before.json +envdiff diff .\before.json .\after.json --json | ConvertFrom-Json +envdiff run --shell cmd -- .\scripts\set-env.cmd +envdiff run --shell pwsh -- .\scripts\set-env.ps1 +``` + +### `proctree` + +Show the live Windows process tree, a subtree rooted at a PID, or the descendants of a command you just ran. + +```powershell +proctree system +proctree root 1234 --json | ConvertFrom-Json +proctree run -- pwsh -NoProfile -Command "npm test" +``` + +### `sysshape` + +Capture a compact machine and tool inventory for AI-friendly triage without manually checking the shell, environment, and language toolchains one by one. + +`--env safe` keeps curated non-secret variables while leaving raw path-like values out of `environment`; the segmented `path_like` view carries PATH-heavy details more compactly. JSON output now includes `system.default_shell`, `system.session_shell`, `path_like[].duplicate_segments`, and per-tool `probe_status` / `error`. + +```powershell +sysshape --group shell +sysshape --json --env safe --group shell | ConvertFrom-Json +sysshape --json --env safe --group all | ConvertFrom-Json +sysshape --env none --group dev +``` + +### `runprobe` + +Run one command and capture the stable result object you actually want in scripts or AI flows: exit code, duration, timeout status, and bounded stdout/stderr tails. + +```powershell +runprobe --shell raw -- cmd /d /s /c "exit 0" +runprobe --shell pwsh --timeout 2s -- Write-Output done +runprobe --json --shell cmd --timeout 1s -- cmd /d /s /c "echo hello & echo boom 1>&2 & exit 5" | ConvertFrom-Json +``` + +### `await` + +Wait for a path, TCP target, HTTP endpoint, or repeated command condition without hand-writing polling loops. + +```powershell +await path .\target\ready.flag --state exists +await port tcp://127.0.0.1:5000 --interval 200ms +await http http://127.0.0.1:8080/health --status 204 +await run --shell pwsh -- .\scripts\health.ps1 +``` + +### `argv` + +Quote argument vectors for `pwsh`/`cmd`/raw launches and inspect what a shell really passed to a native process. + +```powershell +argv quote --shell pwsh -- git commit -m "two words" +'["tool.exe","two words"]' | argv quote --shell cmd +argv inspect --json --shell pwsh -- alpha "two words" | ConvertFrom-Json +``` + +### `config` + +Read and edit common config formats with slash-pointer paths and explicit value typing. + +```powershell +config get .\package.json /version +config set .\config.yaml /retries 3 --value-type number +config ls --format toml .\settings.toml --json | ConvertFrom-Json +``` + +### `gitshape` + +Summarize Git status and diffs into compact AI-friendly counters, changed files, line deltas, and touched-symbol hints. + +```powershell +gitshape status +gitshape status --repo C:\src\repo --json | ConvertFrom-Json +gitshape diff --staged --json | ConvertFrom-Json +``` + +### `reposhape` + +Detect repository ecosystems, manifests, command entrypoints, and CI hints before you start opening files. + +```powershell +reposhape . +reposhape . --json | ConvertFrom-Json +reposhape C:\src\repo --max-depth 4 +``` + +### `dotnetshape` + +Inspect .NET project graphs, inherited MSBuild properties, package references, and build-shape warnings without invoking MSBuild. + +```powershell +dotnetshape . +dotnetshape . --json | ConvertFrom-Json +dotnetshape C:\src\repo --max-depth 4 +``` + +### `unlock` + +Inspect file lockers and perform unlock-aware delete, move, rename, copy, or free operations with staged escalation. `unlock who` now defaults to a fast Restart Manager pass for interactive triage, while `--deep` adds the slower global handle scan when you need exhaustive coverage. + +```powershell +unlock who .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json | Select-Object -ExpandProperty results +unlock who --deep .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json | Select-Object -ExpandProperty results +unlock free --force .\locked.txt +unlock delete --force .\locked.txt +``` + +## AI Assets + +Mercury ships both a compact English AI prompt and a real Codex skill. The prompt is the terse “tool card”; the skill is the route-oriented entrypoint plus the generated command catalog. + +```powershell +pwsh -NoProfile -File .\scripts\generate-ai-prompt.ps1 +pwsh -NoProfile -File .\scripts\check-ai-prompt.ps1 +pwsh -NoProfile -File .\scripts\generate-ai-skill.ps1 +pwsh -NoProfile -File .\scripts\check-ai-skill.ps1 +``` + +The generated skill lives in `.\skills\mercury-toolbox\`. Installing into Codex happens when you run `install-toolbox.ps1` or install a packaged build, unless `-NoCodexSkillInstall` is used. + +For the authoritative full command reference, prefer the generated assets over this README: `docs\ai\mercury-toolbox-ai-prompt.md` is the compact prompt view, and `skills\mercury-toolbox\references\command-catalog.md` is the full generated catalog that packaged installs carry forward too. + +## Development + +For the day-to-day loop, use the repo-level [justfile](./justfile) and [bacon.toml](./bacon.toml): + +`just test` and the scoped `*-test` recipes use `cargo nextest`; treat nextest as the default test runner for this repository. Plain `cargo test` is only for tool-specific cases that explicitly require it. + +Start new user-facing features from the [Jade feature template](docs/templates/jade-feature-template.md) so CLI contracts, tests, generated AI assets, and handoff notes stay synchronized. For daily low-risk cleanup, use the [daily maintenance template](docs/templates/daily-maintenance-template.md) with the durable backlog in `.codex-progress\quality-security-backlog.md`. + +```powershell +just +just --summary +just periphery-check +just workspace-check +just check +just code-check +just data-test +just runtime-check +just windows-test +just msudo-verify +just managed-clippy +just mhash-check +just mhash-test +just mhash-clippy +just mhash-bench +just mhash-benchmark +just ai-check +just test +just install +just uninstall +just package +just install-package +just uninstall-package +just package-roundtrip +just jade-skip-coverage +bacon +bacon code +bacon code-test +bacon code-clippy +bacon data +bacon data-test +bacon data-clippy +bacon mhash +bacon mhash-test +bacon mhash-clippy +bacon runtime +bacon runtime-test +bacon runtime-clippy +bacon windows +bacon msudo-verify +bacon managed +bacon managed-test +bacon managed-clippy +bacon unity-test +bacon unity-clippy +bacon ai +bacon clippy +bacon test +``` + +`just periphery-check` is the lightweight lane for docs, PowerShell scripts, justfile recipes, templates, and generated AI asset hygiene: it prints the recipe summary, runs the PowerShell gate, and rechecks generated AI assets without a Rust rebuild. + +`just workspace-check` is the fast lane for everyday repo-wide confidence: formatting, cargo check, a `release-fast` workspace build, AI asset checks, and the PowerShell gate without the heavier install/package roundtrips. + +For documentation-only maintenance, start from `just --summary` and the [maintainer notes](docs/maintainer-notes.md). Add `just ai-check` when a change mentions generated prompt or skill assets, then use `just periphery-check` before handoff when the patch changes workflow instructions, templates, scripts, or just recipes. Do not claim generated AI assets were refreshed unless the generator or check command was actually run. + +Use the fuller gates when the change reaches beyond that quick lane: + +- `just ecosystem` exercises install, package, and portable install flows. +- `just jade` is the full mandatory Jade gate: fmt, check, coverage-backed tests, strict Clippy, root and fuzz dependency audits, Miri, fuzzing, sanitizer, no-panic, Loom, PowerShell, generated AI assets, ecosystem checks, and the selected verification build profile. +- `just jade-skip-coverage` is still a broad local gate, but skipping coverage means it is not the release-quality Jade proof. +- `just miri`, `just fuzz`, `just sanitizer`, `just no-panic`, and `just loom` are focused repair loops. They are not exemptions and not a separate optional safety tier. + +Run the broader Jade verification gate: + +```powershell +pwsh -NoProfile -File .\scripts\check-jade.ps1 +``` + +If a hardening gate truly cannot run on a machine, pass the matching `-ExemptMiri`, `-ExemptFuzz`, `-ExemptSanitizers`, `-ExemptNoPanic`, or `-ExemptLoom` flag with a non-empty reviewed reason. Do not turn those exemptions into default workflow recipes. + +Run the install/package ecosystem gate: + +```powershell +pwsh -NoProfile -File .\scripts\check-ecosystem.ps1 +``` + +The hosted Gitea repository now uses native `.gitea/workflows` automation: + +- `.gitea/workflows/ci.yml` runs the everyday verification lane on pushes, pull requests, and manual dispatches. +- `.gitea/workflows/release.yml` is tag-driven. It requires a tag like `v0.1.0` that matches `[workspace.package].version`, runs `just jade`, builds the portable package, and publishes the release through `scripts\publish-gitea-release.ps1`. +- `scripts\setup-gitea-runner.ps1` provisions a Windows host runner for the current repo, registers it with the repo-level runner token API, writes a host-backend config, and installs a startup entry so `.gitea/workflows` can execute on this machine again after logon. + +For runner or release maintenance, keep the toolchain snapshot and secret surfaces in the handoff: confirm `cargo nextest --version`, `cargo llvm-cov --version`, `just --version`, and `pwsh --version` are visible in CI logs, and treat `.runner`, `GITEA_TOKEN`, and `GITEA_API_TOKEN` as host-local secret material. The maintainer checklist for those flows lives in [docs/maintainer-notes.md](docs/maintainer-notes.md). + +If Git for Windows crashes in `git-remote-https.exe` while credential-manager or schannel credentials are being resolved, do not embed tokens in `origin` or Git config. Use the repo helper, which disables credential-manager lookup for that one command and passes a transient Basic auth header: + +```powershell +$env:GITEA_API_TOKEN = '' +pwsh -NoProfile -File .\scripts\invoke-gitea-git.ps1 ls-remote origin refs/heads/main +pwsh -NoProfile -File .\scripts\invoke-gitea-git.ps1 push origin main +``` + +The helper uses Git's OpenSSL backend by default and keeps TLS verification enabled. If the local Gitea test host is temporarily presenting a certificate chain that Git cannot validate, add `-InsecureSkipTlsVerify` only for that one command. See `docs\maintainer-notes.md` before changing machine-wide Git credential or TLS settings. + +For a local one-shot release from a checked-out repo, the `just` recipes now line up with the hosted workflow: + +```powershell +just package +just publish-release v0.1.0 .\docs\releases\v0.1.0.md +``` + +Measure binary size and representative latency with the probe gate: + +```powershell +pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 +pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseSize -SkipBenchmarks +pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseFast -IncludeBloat +``` + +On Windows, `cargo flamegraph` will use DTrace when it is configured; otherwise it falls back to `blondie`, which requires an elevated shell. `probe-toolbox.ps1 -IncludeFlamegraph` writes a capability report first and skips impossible runs instead of repeatedly failing blindly. + +`samply` is installed by the Jade tooling script, but Windows recording still requires `xperf.exe` from Windows Performance Toolkit. + +`sccache` is also wired into `.cargo/config.toml` as the default `rustc-wrapper`. Check it with `sccache --show-stats`, and reset counters with `sccache --zero-stats` before a fresh probe run when you want clean measurements. + +For a one-shot UAC relay on Windows, use the dedicated wrapper: + +```powershell +pwsh -NoProfile -File .\scripts\cargo-flamegraph-windows.ps1 -Package refs -TargetArgumentJson '["helper",".\\fixtures\\polyglot\\repo","--limit","12"]' +``` + +If you add `-LogPath .\dist\probe\release-fast\flamegraphs\refs.log`, the wrapper now writes a compact relay log to `refs.log` and the full elevated-session transcript to `refs.log.transcript.txt`. diff --git a/bacon.toml b/bacon.toml new file mode 100644 index 0000000..ee938dc --- /dev/null +++ b/bacon.toml @@ -0,0 +1,210 @@ +default_job = "check" + +# Bacon already watches the Rust source tree and Cargo manifest; explicit +# watches here only cover lockfiles, fixtures, docs, scripts, skills, and +# workspace config that are outside the default scan. + +[jobs.check] +command = ["just", "check"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.clippy] +command = ["just", "clippy"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.core] +command = ["just", "core-check"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.core-clippy] +command = ["just", "core-clippy"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.core-test] +command = ["just", "core-test"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.code] +command = ["just", "code-check"] +need_stdout = false +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.code-clippy] +command = ["just", "code-clippy"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.code-test] +command = ["just", "code-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.data] +command = ["just", "data-check"] +need_stdout = false +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.data-clippy] +command = ["just", "data-clippy"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.data-test] +command = ["just", "data-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.mhash] +command = ["just", "mhash-check"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.mhash-clippy] +command = ["just", "mhash-clippy"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.mhash-test] +command = ["just", "mhash-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.runtime] +command = ["just", "runtime-check"] +need_stdout = false +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.runtime-clippy] +command = ["just", "runtime-clippy"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.runtime-test] +command = ["just", "runtime-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.windows] +command = ["just", "windows-check"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.windows-clippy] +command = ["just", "windows-clippy"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.windows-test] +command = ["just", "windows-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.managed] +command = ["just", "managed-check"] +need_stdout = false +watch = ["fixtures/managed", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.managed-clippy] +command = ["just", "managed-clippy"] +need_stdout = true +watch = ["fixtures/managed", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.managed-test] +command = ["just", "managed-test"] +need_stdout = true +watch = ["fixtures/managed", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.unity] +command = ["just", "unity-check"] +need_stdout = false +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.unity-clippy] +command = ["just", "unity-clippy"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.unity-test] +command = ["just", "unity-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.msudo-check] +command = ["just", "msudo-check"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.msudo-clippy] +command = ["just", "msudo-clippy"] +need_stdout = false +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.msudo-test] +command = ["just", "msudo-test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.msudo-status] +command = ["just", "msudo-status"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.msudo-verify] +command = ["just", "msudo-verify"] +need_stdout = true +watch = ["fixtures", "docs/ai", "skills/mercury-toolbox", "scripts", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.test] +command = ["just", "test"] +need_stdout = true +watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml"] + +[jobs.ai] +command = ["just", "ai-check"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "docs/ai", "skills/mercury-toolbox", "scripts/toolbox-commands.ps1", "scripts/generate-ai-prompt.ps1", "scripts/check-ai-prompt.ps1", "scripts/generate-ai-skill.ps1", "scripts/check-ai-skill.ps1", "justfile", "bacon.toml", "README.md"] + +[jobs.prompt] +command = ["just", "prompt-check"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "docs/ai", "scripts/toolbox-commands.ps1", "scripts/generate-ai-prompt.ps1", "scripts/check-ai-prompt.ps1", "justfile", "bacon.toml", "README.md"] + +[jobs.skill] +command = ["just", "skill-check"] +need_stdout = true +watch = ["Cargo.lock", ".cargo", "skills/mercury-toolbox", "docs/ai", "scripts/toolbox-commands.ps1", "scripts/generate-ai-skill.ps1", "scripts/check-ai-skill.ps1", "justfile", "bacon.toml", "README.md"] + +[jobs.powershell] +command = ["just", "powershell"] +need_stdout = true +watch = ["scripts", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml"] + +[jobs.workspace] +command = ["just", "workspace-check"] +need_stdout = true +watch = ["scripts", "docs", "skills", "Cargo.lock", ".cargo", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml", "README.md"] + +[jobs.ecosystem] +command = ["just", "ecosystem"] +need_stdout = true +watch = ["fixtures", "scripts", "docs", "skills", "Cargo.lock", ".cargo", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml", "README.md"] + +[jobs.ecosystem-fast] +command = ["just", "ecosystem-fast"] +need_stdout = true +watch = ["fixtures", "scripts", "docs", "skills", "Cargo.lock", ".cargo", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml", "README.md"] + +[jobs.jade] +command = ["just", "jade"] +need_stdout = true +watch = ["scripts", "docs", "skills", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] + +[jobs.jade-skip-coverage] +command = ["just", "jade-skip-coverage"] +need_stdout = true +watch = ["scripts", "docs", "skills", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"] diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..3fa0bf0 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,44 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project are documented in this file. +""" +body = """ +{% if version %} +## [{{ version }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %} +## [Unreleased] +{% endif %} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group }} +{% for commit in commits %} +- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }} +{% endfor %} +{% endfor %} +""" +trim = true +footer = """ +## [v0.1.0] - 2026-06-04 + +- First hosted release. +""" + +[git] +conventional_commits = true +filter_unconventional = true +tag_pattern = "v[0-9]*" +skip_tags = "v0\\.1\\.0" +sort_commits = "oldest" +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Tests" }, + { message = "^build", group = "Build" }, + { message = "^ci", group = "Continuous Integration" }, + { message = "^chore", group = "Maintenance" } +] diff --git a/crates/argv/Cargo.toml b/crates/argv/Cargo.toml new file mode 100644 index 0000000..ad96e76 --- /dev/null +++ b/crates/argv/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "argv" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect argv and optional stdin line payloads for shell quoting/debug workflows." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +runtimekit = { path = "../runtimekit" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/argv/src/lib.rs b/crates/argv/src/lib.rs new file mode 100644 index 0000000..a6f547d --- /dev/null +++ b/crates/argv/src/lib.rs @@ -0,0 +1,922 @@ +//! The `argv` command renders shell-safe argument text and verifies real argv round-trips. + +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, print_text, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use runtimekit::{ShellMode, collect_command_values, parse_shell_mode, parser_value_string}; +use serde::{Deserialize, Serialize}; + +const HELP: &str = "\ +Quote argument vectors for Windows shells and inspect what a shell really passed to a native process. + +Usage: + argv quote --shell [VALUE...] + argv inspect [--json] --shell [VALUE...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Notes: + Windows-first: quote and inspect focus on native invocation behavior for pwsh, cmd, and raw process launches. + cmd inspect is Windows-only. + quote accepts positional args or a JSON array of strings from stdin when no positional args are provided. + raw bypasses any shell and shows the direct native-process argv path. + +Examples: + argv quote --shell pwsh -- git commit -m \"two words\" + '[\"tool.exe\",\"two words\"]' | argv quote --shell cmd + argv inspect --json --shell pwsh -- alpha \"two words\" +"; + +const QUOTE_HELP: &str = "\ +Render a shell-safe invocation string for pwsh, cmd, or raw native argv. + +Usage: + argv quote --shell [VALUE...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --shell Target shell: pwsh, cmd, raw + -h, --help Show this help text + +Examples: + argv quote --shell pwsh -- git commit -m \"two words\" + '[\"tool.exe\",\"two words\"]' | argv quote --shell cmd +"; + +const INSPECT_HELP: &str = "\ +Launch a native helper and show what argv actually arrived after shell parsing. + +Usage: + argv inspect [--json] --shell [VALUE...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --shell Target shell: pwsh, cmd, raw + -h, --help Show this help text + +Examples: + argv inspect --shell pwsh -- alpha \"two words\" + argv inspect --json --shell cmd -- alpha \"--filter=x|y\" +"; + +const HELPER_SUBCOMMAND: &str = "__native_helper"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: CommandMode, +} + +#[derive(Debug, Clone)] +enum CommandMode { + Help(&'static str), + Quote { + shell: ShellMode, + values: Vec, + }, + Inspect { + shell: ShellMode, + values: Vec, + }, + NativeHelper { + values: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct QuoteReport { + shell: String, + text: String, + argv: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct InspectReport { + shell: String, + invocation: String, + probe_invocation: Option, + count: usize, + argv: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct HelperReport { + count: usize, + argv: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("argv {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + command: CommandMode::Quote { + shell: ShellMode::Raw, + values: Vec::new(), + }, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => cli.common.quiet = true, + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + ArgValue(value) => { + let token = runtimekit::os_to_utf8(value, "subcommand")?; + cli.command = match token.as_str() { + "quote" => parse_quote_command(&mut parser, &mut cli.common)?, + "inspect" => parse_inspect_command(&mut parser, &mut cli.common)?, + HELPER_SUBCOMMAND => parse_native_helper_command(&mut parser)?, + _ => { + return Err(CliError::usage( + "unknown subcommand; expected quote or inspect", + )); + } + }; + return Ok((ParseOutcome::Run, cli)); + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Err(CliError::usage("provide a subcommand: quote or inspect")) +} + +fn parse_quote_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut shell = None::; + let mut values = Vec::new(); + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok(CommandMode::Help(QUOTE_HELP)), + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("shell") => { + shell = Some(parse_shell_mode( + "--shell", + &parser_value_string(parser, "--shell")?, + )?); + } + ArgValue(value) => { + values = collect_command_values(parser, value)?; + break; + } + _ => { + return Err(CliError::usage( + "unsupported quote argument; use --help to see available options", + )); + } + } + } + Ok(CommandMode::Quote { + shell: shell.ok_or_else(|| CliError::usage("quote requires --shell "))?, + values, + }) +} + +fn parse_inspect_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut shell = None::; + let mut values = Vec::new(); + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok(CommandMode::Help(INSPECT_HELP)), + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("shell") => { + shell = Some(parse_shell_mode( + "--shell", + &parser_value_string(parser, "--shell")?, + )?); + } + ArgValue(value) => { + values = collect_command_values(parser, value)?; + break; + } + _ => { + return Err(CliError::usage( + "unsupported inspect argument; use --help to see available options", + )); + } + } + } + Ok(CommandMode::Inspect { + shell: shell.ok_or_else(|| CliError::usage("inspect requires --shell "))?, + values, + }) +} + +fn parse_native_helper_command(parser: &mut lexopt::Parser) -> Result { + let mut values = Vec::new(); + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + ArgValue(value) => values.push(runtimekit::os_to_utf8(value, "helper arg")?), + _ => { + return Err(CliError::usage( + "internal helper expects only positional values", + )); + } + } + } + Ok(CommandMode::NativeHelper { values }) +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + CommandMode::Help(text) => { + print_text(text)?; + Ok(ExitCode::Success) + } + CommandMode::Quote { shell, values } => run_quote(cli, *shell, values), + CommandMode::Inspect { shell, values } => run_inspect(cli, *shell, values), + CommandMode::NativeHelper { values } => run_native_helper(values), + } +} + +fn run_quote(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result { + let argv = resolve_values(cli, values)?; + let report = QuoteReport { + shell: shell_label(shell).to_string(), + text: render_shell_invocation(shell, &argv), + argv, + }; + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print_text(&report.text)?, + } + Ok(ExitCode::Success) +} + +fn run_inspect(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result { + let argv = resolve_values(cli, values)?; + let inspection = inspect_arguments(shell, &argv)?; + let report = InspectReport { + shell: shell_label(shell).to_string(), + invocation: inspection.invocation, + probe_invocation: Some(inspection.probe_invocation), + count: inspection.helper.argv.len(), + argv: inspection.helper.argv, + }; + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print_text(render_inspect_text(&report))?, + } + Ok(ExitCode::Success) +} + +fn run_native_helper(values: &[String]) -> Result { + let report = HelperReport { + count: values.len(), + argv: values.to_vec(), + }; + print_json(&report)?; + Ok(ExitCode::Success) +} + +fn resolve_values(cli: &Cli, values: &[OsString]) -> Result, CliError> { + if !values.is_empty() { + return values + .iter() + .cloned() + .map(|value| runtimekit::os_to_utf8(value, "argv value")) + .collect(); + } + if cli.common.stdin_is_terminal() { + return Err(CliError::usage( + "provide positional values or pipe a JSON array of strings into stdin", + )); + } + + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let trimmed = buffer.trim(); + if trimmed.is_empty() { + return Err(CliError::usage( + "stdin was empty; expected a JSON array of strings", + )); + } + serde_json::from_str::>(trimmed).map_err(|error| { + CliError::usage(format!( + "expected a JSON array of strings on stdin, got parse error: {error}" + )) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InspectionResult { + invocation: String, + probe_invocation: String, + helper: HelperReport, +} + +fn inspect_arguments(shell: ShellMode, values: &[String]) -> Result { + let current_exe = std::env::current_exe().map_err(|error| { + CliError::runtime(format!("failed to resolve current executable: {error}")) + })?; + let helper_exe = current_exe.display().to_string(); + let mut helper_argv = vec![helper_exe, HELPER_SUBCOMMAND.to_string(), "--".to_string()]; + helper_argv.extend(values.iter().cloned()); + let invocation = render_shell_invocation(shell, values); + let probe_invocation = render_shell_invocation(shell, &helper_argv); + + let output = match shell { + ShellMode::Raw => Command::new(¤t_exe) + .arg(HELPER_SUBCOMMAND) + .arg("--") + .args(values) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch raw helper: {error}")))?, + ShellMode::Pwsh => Command::new("pwsh") + .arg("-NoProfile") + .arg("-Command") + .arg(&probe_invocation) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch pwsh helper: {error}")))?, + ShellMode::Cmd => { + if !cfg!(windows) { + return Err(CliError::usage("cmd inspect is Windows-only")); + } + let wrapper = unique_cmd_wrapper_path(); + let wrapper_body = format!( + "@echo off\r\nsetlocal DisableDelayedExpansion\r\n{}\r\n", + probe_invocation.replace('%', "%%") + ); + write_cmd_wrapper_file(&wrapper, &wrapper_body)?; + let output = Command::new("cmd") + .arg("/d") + .arg("/v:off") + .arg("/c") + .arg(&wrapper) + .output() + .map_err(|error| { + CliError::runtime(format!("failed to launch cmd helper: {error}")) + }); + let _ = fs::remove_file(&wrapper); + output? + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CliError::runtime(format!( + "native helper failed with status {}: {}", + output.status, + stderr.trim() + ))); + } + let helper = serde_json::from_slice::(&output.stdout) + .map_err(|error| CliError::runtime(format!("failed to parse helper output: {error}")))?; + Ok(InspectionResult { + invocation, + probe_invocation, + helper, + }) +} + +fn render_inspect_text(report: &InspectReport) -> String { + let mut lines = vec![format!( + "shell={} count={} invocation={}", + report.shell, report.count, report.invocation + )]; + lines.extend( + report + .argv + .iter() + .enumerate() + .map(|(index, value)| format!("argv[{index}]={}", quote_text(value))), + ); + lines.join("\n") +} + +fn render_shell_invocation(shell: ShellMode, values: &[String]) -> String { + match shell { + ShellMode::Pwsh => { + let mut parts = vec!["&".to_string()]; + parts.extend(values.iter().map(|value| quote_pwsh(value))); + parts.join(" ") + } + ShellMode::Cmd => values + .iter() + .map(|value| quote_cmd(value)) + .collect::>() + .join(" "), + ShellMode::Raw => values + .iter() + .map(|value| quote_raw(value)) + .collect::>() + .join(" "), + } +} + +fn quote_pwsh(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn quote_cmd(value: &str) -> String { + if value.is_empty() + || value + .chars() + .any(|ch| ch.is_whitespace() || "\"^&|<>()%!".contains(ch)) + { + quote_windows_arg_inner(value, true) + } else { + value.to_string() + } +} + +fn quote_raw(value: &str) -> String { + quote_windows_arg(value) +} + +fn quote_windows_arg(value: &str) -> String { + quote_windows_arg_inner(value, false) +} + +fn quote_windows_arg_inner(value: &str, force_quote: bool) -> String { + if force_quote || value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') { + let mut rendered = String::from("\""); + let mut backslashes = 0_usize; + for ch in value.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + rendered.push_str(&"\\".repeat(backslashes * 2 + 1)); + rendered.push('"'); + backslashes = 0; + } + _ => { + rendered.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + rendered.push(ch); + } + } + } + rendered.push_str(&"\\".repeat(backslashes * 2)); + rendered.push('"'); + rendered + } else { + value.to_string() + } +} + +fn quote_text(value: &str) -> String { + if value.is_empty() || value.chars().any(char::is_whitespace) { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value.to_string() + } +} + +const fn shell_label(shell: ShellMode) -> &'static str { + match shell { + ShellMode::Raw => "raw", + ShellMode::Pwsh => "pwsh", + ShellMode::Cmd => "cmd", + } +} + +fn unique_cmd_wrapper_path() -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |value| value.as_nanos()); + std::env::temp_dir().join(format!( + "mercury-argv-helper-{}-{unique}.cmd", + std::process::id() + )) +} + +fn write_cmd_wrapper_file(path: &Path, body: &str) -> Result<(), CliError> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| { + CliError::runtime(format!( + "refusing to replace existing cmd inspect wrapper {}: {error}", + path.display() + )) + })?; + file.write_all(body.as_bytes()).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + file.flush() + .map_err(|error| CliError::runtime(format!("failed to flush {}: {error}", path.display()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_accepts_quote_and_inspect_commands() { + let (_, quote_cli) = parse_cli_from([ + "argv", + "quote", + "--shell", + "pwsh", + "--", + "tool", + "two words", + ]) + .expect("quote cli"); + assert!(matches!( + quote_cli.command, + CommandMode::Quote { + shell: ShellMode::Pwsh, + .. + } + )); + + let (_, inspect_cli) = + parse_cli_from(["argv", "--json", "inspect", "--shell", "raw", "--", "alpha"]) + .expect("inspect cli"); + assert!(inspect_cli.common.json); + assert!(matches!( + inspect_cli.command, + CommandMode::Inspect { + shell: ShellMode::Raw, + .. + } + )); + + let (_, inspect_help) = + parse_cli_from(["argv", "inspect", "--help"]).expect("inspect help"); + assert!(matches!( + inspect_help.command, + CommandMode::Help(text) if text.contains("argv inspect") + )); + } + + #[test] + fn parser_covers_global_and_subcommand_common_flags() { + let (_, quote_cli) = parse_cli_from([ + "argv", "--quiet", "--color", "never", "quote", "--json", "--quiet", "--color", + "never", "--shell", "cmd", "--", "tool.exe", + ]) + .expect("quote cli"); + assert!(quote_cli.common.json); + assert!(quote_cli.common.quiet); + assert_eq!(quote_cli.common.color, common::ColorChoice::Never); + assert!(matches!( + quote_cli.command, + CommandMode::Quote { + shell: ShellMode::Cmd, + .. + } + )); + + let (_, inspect_cli) = parse_cli_from([ + "argv", "inspect", "--quiet", "--color", "never", "--shell", "pwsh", "--", "alpha", + ]) + .expect("inspect cli"); + assert!(inspect_cli.common.quiet); + assert_eq!(inspect_cli.common.color, common::ColorChoice::Never); + assert!(matches!( + inspect_cli.command, + CommandMode::Inspect { + shell: ShellMode::Pwsh, + .. + } + )); + } + + #[test] + fn quote_rendering_is_shell_aware() { + assert_eq!( + render_shell_invocation( + ShellMode::Pwsh, + &["tool".to_string(), "two words".to_string()] + ), + "& 'tool' 'two words'" + ); + assert_eq!( + render_shell_invocation( + ShellMode::Cmd, + &["tool.exe".to_string(), "two words".to_string()] + ), + "tool.exe \"two words\"" + ); + assert_eq!(quote_pwsh("a'b"), "'a''b'"); + } + + #[test] + fn helper_text_output_is_compact() { + let report = InspectReport { + shell: "raw".to_string(), + invocation: "alpha".to_string(), + probe_invocation: Some("tool.exe __native_helper -- alpha".to_string()), + count: 2, + argv: vec!["alpha".to_string(), "two words".to_string()], + }; + let text = render_inspect_text(&report); + assert!(text.contains("shell=raw count=2")); + assert!(text.contains("argv[1]=\"two words\"")); + } + + #[test] + fn native_helper_parser_rejects_flags_and_quote_rendering_covers_raw() { + assert!(matches!( + parse_cli_from(["argv", HELPER_SUBCOMMAND, "--json"]), + Err(CliError::Usage(message)) if message.contains("only positional values") + )); + + assert_eq!( + render_shell_invocation( + ShellMode::Raw, + &["tool.exe".to_string(), "two words".to_string()] + ), + "tool.exe \"two words\"" + ); + assert_eq!(quote_raw(""), "\"\""); + assert_eq!(quote_text("needs quotes"), "\"needs quotes\""); + } + + #[test] + fn parser_and_helper_paths_reject_invalid_arguments() { + assert!(matches!( + parse_cli_from(["argv"]), + Err(CliError::Usage(message)) if message.contains("provide a subcommand") + )); + assert!(matches!( + parse_cli_from(["argv", "--bogus"]), + Err(CliError::Usage(message)) if message.contains("unsupported argument") + )); + assert!(matches!( + parse_cli_from(["argv", "quote", "--shell", "pwsh", "--bogus"]), + Err(CliError::Usage(message)) if message.contains("unsupported quote argument") + )); + assert!(matches!( + parse_cli_from(["argv", "inspect", "--shell", "raw", "--bogus"]), + Err(CliError::Usage(message)) if message.contains("unsupported inspect argument") + )); + + let (_, helper_cli) = + parse_cli_from(["argv", HELPER_SUBCOMMAND, "alpha", "two words"]).expect("helper cli"); + assert!(matches!( + helper_cli.command, + CommandMode::NativeHelper { values } if values == vec!["alpha".to_string(), "two words".to_string()] + )); + } + + #[test] + fn parser_rejects_unknown_subcommands_and_missing_shell_flags() { + assert!(matches!( + parse_cli_from(["argv", "wat"]), + Err(CliError::Usage(message)) if message.contains("expected quote or inspect") + )); + assert!(matches!( + parse_cli_from(["argv", "quote", "--", "tool.exe"]), + Err(CliError::Usage(message)) if message.contains("quote requires --shell") + )); + assert!(matches!( + parse_cli_from(["argv", "inspect", "--", "tool.exe"]), + Err(CliError::Usage(message)) if message.contains("inspect requires --shell") + )); + } + + #[test] + fn quote_helpers_cover_metacharacters_backslashes_and_empty_values() { + assert_eq!(quote_cmd("a&b"), "\"a&b\""); + assert_eq!(quote_cmd("two words"), "\"two words\""); + assert_eq!(quote_windows_arg("two words\\"), "\"two words\\\\\""); + assert_eq!(quote_windows_arg("say \"hi\""), "\"say \\\"hi\\\"\""); + assert_eq!(quote_windows_arg("plain"), "plain"); + assert_eq!(quote_text(""), "\"\""); + assert_eq!(shell_label(ShellMode::Cmd), "cmd"); + } + + #[test] + fn run_and_resolve_values_cover_quote_and_helper_dispatch() { + let quote_cli = Cli { + common: CommonArgs::default(), + command: CommandMode::Quote { + shell: ShellMode::Raw, + values: vec![OsString::from("tool.exe"), OsString::from("two words")], + }, + }; + assert_eq!( + resolve_values("e_cli, "e_cli_values("e_cli)).expect("values"), + vec!["tool.exe".to_string(), "two words".to_string()] + ); + assert_eq!(run("e_cli).expect("quote"), ExitCode::Success); + + let helper_cli = Cli { + common: CommonArgs { + json: true, + format: None, + ..CommonArgs::default() + }, + command: CommandMode::NativeHelper { + values: vec!["alpha".to_string()], + }, + }; + assert_eq!(run(&helper_cli).expect("helper"), ExitCode::Success); + } + + #[test] + fn inspect_arguments_attempt_pwsh_and_cmd_launch_paths() { + let pwsh_error = + inspect_arguments(ShellMode::Pwsh, &["alpha".to_string()]).expect_err("pwsh helper"); + assert!(matches!( + pwsh_error, + CliError::Runtime(message) + if message.contains("failed to parse helper output") + || message.contains("native helper failed with status") + || message.contains("failed to launch pwsh helper") + )); + + #[cfg(windows)] + { + let cmd_error = + inspect_arguments(ShellMode::Cmd, &["alpha".to_string()]).expect_err("cmd helper"); + assert!(matches!( + cmd_error, + CliError::Runtime(message) + if message.contains("failed to parse helper output") + || message.contains("native helper failed with status") + || message.contains("failed to write") + || message.contains("failed to launch cmd helper") + )); + let wrapper_one = unique_cmd_wrapper_path(); + let wrapper_two = unique_cmd_wrapper_path(); + assert_ne!(wrapper_one, wrapper_two); + assert_eq!( + wrapper_one.extension().and_then(|value| value.to_str()), + Some("cmd") + ); + } + } + + #[test] + fn resolve_values_rejects_missing_terminal_input() { + let cli = Cli { + common: CommonArgs::default(), + command: CommandMode::Quote { + shell: ShellMode::Raw, + values: Vec::new(), + }, + }; + let error = resolve_values(&cli, &[]).expect_err("missing values"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("provide positional values or pipe a JSON array") + || message.contains("stdin was empty; expected a JSON array of strings") + )); + } + + #[test] + fn inspect_arguments_in_unit_tests_surface_helper_failures() { + let error = inspect_arguments(ShellMode::Raw, &["alpha".to_string()]) + .expect_err("unit test binary is not the helper entrypoint"); + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("failed to parse helper output") + || message.contains("native helper failed with status") + )); + } + + #[test] + fn cmd_wrapper_writer_refuses_preexisting_paths() { + let path = unique_cmd_wrapper_path(); + fs::write(&path, "original").expect("preexisting wrapper"); + + let error = + write_cmd_wrapper_file(&path, "replacement").expect_err("preexisting path refused"); + + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("refusing to replace existing cmd inspect wrapper") + )); + assert_eq!( + fs::read_to_string(&path).expect("preserved content"), + "original" + ); + let _ = fs::remove_file(path); + } + + #[test] + fn run_native_helper_returns_success_exit_code() { + let code = + run_native_helper(&["alpha".to_string(), "two words".to_string()]).expect("helper"); + assert_eq!(code, ExitCode::Success); + } + + fn quote_cli_values(cli: &Cli) -> Vec { + match &cli.command { + CommandMode::Quote { values, .. } => values.clone(), + _ => Vec::new(), + } + } +} diff --git a/crates/argv/src/main.rs b/crates/argv/src/main.rs new file mode 100644 index 0000000..bf78338 --- /dev/null +++ b/crates/argv/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `argv`. + +fn main() { + std::process::exit(argv::main_entry()); +} diff --git a/crates/argv/tests/argv_cli.rs b/crates/argv/tests/argv_cli.rs new file mode 100644 index 0000000..7a97e26 --- /dev/null +++ b/crates/argv/tests/argv_cli.rs @@ -0,0 +1,233 @@ +//! Integration tests for the `argv` command. + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("argv").expect("binary") +} + +#[test] +fn no_args_prints_quick_help_card() { + let mut command = cargo_command(); + command + .assert() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "error: provide a subcommand: quote or inspect", + )) + .stderr(predicate::str::contains("argv - Mercury Toolbox")) + .stderr(predicate::str::contains("Usage:")) + .stderr(predicate::str::contains("argv quote")) + .stderr(predicate::str::contains("argv inspect")) + .stderr(predicate::str::contains( + "Type 'argv --help' for the full command reference.", + )); +} + +#[test] +fn help_mentions_quote_inspect_and_platform_boundaries() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("argv quote")) + .stdout(predicate::str::contains("argv inspect")) + .stdout(predicate::str::contains("Windows-first")) + .stdout(predicate::str::contains("cmd inspect is Windows-only")); +} + +#[test] +fn subcommand_help_mentions_common_output_flags() { + for subcommand in ["quote", "inspect"] { + let mut command = cargo_command(); + command + .args([subcommand, "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--format ")) + .stdout(predicate::str::contains("--json")) + .stdout(predicate::str::contains("--toon")) + .stdout(predicate::str::contains("--color ")) + .stdout(predicate::str::contains("--quiet")) + .stdout(predicate::str::contains("--shell ")); + } +} + +#[test] +fn quote_accepts_json_array_from_stdin() { + let mut command = cargo_command(); + command + .arg("quote") + .arg("--shell") + .arg("pwsh") + .write_stdin("[\"tool\",\"two words\"]") + .assert() + .success() + .stdout(predicate::str::contains("& 'tool' 'two words'")); +} + +#[cfg(windows)] +#[test] +fn quote_renders_cmd_safe_text_for_positional_args() { + let mut command = cargo_command(); + command + .arg("quote") + .arg("--shell") + .arg("cmd") + .arg("--") + .arg("tool.exe") + .arg("two words") + .assert() + .success() + .stdout(predicate::str::contains("tool.exe")) + .stdout(predicate::str::contains("\"two words\"")); +} + +#[cfg(windows)] +#[test] +fn quote_cmd_wraps_metacharacters_for_cmd_shell() { + let mut command = cargo_command(); + command + .arg("quote") + .arg("--shell") + .arg("cmd") + .arg("--") + .arg("tool.exe") + .arg("a&b") + .assert() + .success() + .stdout(predicate::str::contains("tool.exe")) + .stdout(predicate::str::contains("\"a&b\"")); +} + +#[cfg(windows)] +#[test] +fn inspect_round_trips_through_powershell_helper() { + let mut command = cargo_command(); + command + .arg("inspect") + .arg("--json") + .arg("--shell") + .arg("pwsh") + .arg("--") + .arg("alpha") + .arg("two words") + .assert() + .success() + .stdout(predicate::str::contains("\"shell\":\"pwsh\"")) + .stdout(predicate::str::contains( + "\"argv\":[\"alpha\",\"two words\"]", + )) + .stdout(predicate::str::contains("\"count\":2")); +} + +#[cfg(windows)] +#[test] +fn inspect_round_trips_positional_values_that_look_like_options() { + let mut command = cargo_command(); + command + .arg("inspect") + .arg("--json") + .arg("--shell") + .arg("pwsh") + .arg("--") + .arg("alpha") + .arg("--filter=x|y") + .assert() + .success() + .stdout(predicate::str::contains( + "\"argv\":[\"alpha\",\"--filter=x|y\"]", + )) + .stdout(predicate::str::contains("\"count\":2")); +} + +#[cfg(windows)] +#[test] +fn inspect_round_trips_through_cmd_helper() { + let mut command = cargo_command(); + command + .arg("inspect") + .arg("--json") + .arg("--shell") + .arg("cmd") + .arg("--") + .arg("alpha") + .arg("two words") + .assert() + .success() + .stdout(predicate::str::contains("\"shell\":\"cmd\"")) + .stdout(predicate::str::contains( + "\"argv\":[\"alpha\",\"two words\"]", + )) + .stdout(predicate::str::contains("\"count\":2")); +} + +#[cfg(windows)] +#[test] +fn inspect_round_trips_cmd_caret_literals() { + let mut command = cargo_command(); + command + .arg("inspect") + .arg("--json") + .arg("--shell") + .arg("cmd") + .arg("--") + .arg("--flag") + .arg("literal^caret") + .assert() + .success() + .stdout(predicate::str::contains( + "\"argv\":[\"--flag\",\"literal^caret\"]", + )); +} + +#[test] +fn inspect_raw_uses_helper_without_shell() { + let mut command = cargo_command(); + command + .arg("inspect") + .arg("--json") + .arg("--shell") + .arg("raw") + .arg("--") + .arg("alpha") + .arg("two words") + .assert() + .success() + .stdout(predicate::str::contains("\"shell\":\"raw\"")) + .stdout(predicate::str::contains( + "\"argv\":[\"alpha\",\"two words\"]", + )); +} + +#[test] +fn inspect_text_accepts_json_array_from_stdin() { + let mut command = cargo_command(); + command + .arg("inspect") + .arg("--shell") + .arg("raw") + .write_stdin("[\"alpha\",\"two words\"]") + .assert() + .success() + .stdout(predicate::str::contains("shell=raw count=2")) + .stdout(predicate::str::contains("argv[1]=\"two words\"")); +} + +#[test] +fn quote_rejects_invalid_stdin_json() { + let mut command = cargo_command(); + command + .arg("quote") + .arg("--shell") + .arg("raw") + .write_stdin("{\"argv\":[\"alpha\"]}") + .assert() + .code(2) + .stderr(predicate::str::contains( + "expected a JSON array of strings on stdin", + )); +} diff --git a/crates/asmapi/Cargo.toml b/crates/asmapi/Cargo.toml new file mode 100644 index 0000000..35eb14b --- /dev/null +++ b/crates/asmapi/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "asmapi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Diff managed assembly API surfaces with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +managed = { path = "../managed" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true diff --git a/crates/asmapi/src/lib.rs b/crates/asmapi/src/lib.rs new file mode 100644 index 0000000..fcb872a --- /dev/null +++ b/crates/asmapi/src/lib.rs @@ -0,0 +1,511 @@ +//! The `asmapi` command diffs managed assembly API surfaces. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_quick_help_error, print_structured, print_text, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use managed::{ApiDiffQuery, ApiDiffReport, ApiVisibilityScope, diff_assembly_api}; + +const HELP: &str = "\ +Diff managed assembly API surfaces. + +Usage: + asmapi [OPTIONS] [ARGS...] + +Subcommands: + diff Compare old and new managed assembly APIs + +Shared Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + asmapi diff old\\0Harmony.dll new\\0Harmony.dll + asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json + asmapi diff old.dll new.dll --visibility all +"; + +const DIFF_HELP: &str = "\ +Compare old and new managed assembly APIs. + +Usage: + asmapi diff [OPTIONS] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --visibility API scope: public, internal, all (default: public) + --include-special Include special-name methods such as property accessors + --no-missing-method-risks Suppress MissingMethodException risk rows + -h, --help Show this help text + -V, --version Show the command version + +Examples: + asmapi diff old\\0Harmony.dll new\\0Harmony.dll + asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json + asmapi diff old.dll new.dll --visibility all +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: Command, +} + +#[derive(Debug, Clone)] +enum Command { + Diff(DiffArgs), +} + +#[derive(Debug, Clone)] +struct DiffArgs { + old_assembly: PathBuf, + new_assembly: PathBuf, + query: ApiDiffQuery, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help(&'static str), + Version, + Run, +} + +#[derive(Debug, Clone)] +enum ParsedCommand { + Outcome(ParseOutcome), + Command(Command), +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help(help), _)) => match print_text(help) { + Ok(()) => ExitCode::Success.as_i32(), + Err(error) => { + eprintln!("error: {error}"); + error.exit_code().as_i32() + } + }, + Ok((ParseOutcome::Version, _)) => { + println!("asmapi {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let subcommand = loop { + let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + else { + return Err(CliError::usage("missing subcommand; expected diff")); + }; + match argument { + Long("help") | Short('h') => { + return Ok((ParseOutcome::Help(HELP), placeholder(common))); + } + Long("version") | Short('V') => { + return Ok((ParseOutcome::Version, placeholder(common))); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + ArgValue(value) => { + break value.into_string().map_err(|invalid| { + CliError::usage(format!( + "subcommand expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + })?; + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + }; + + let parsed = match subcommand.as_str() { + "diff" => parse_diff_command(&mut parser, &mut common)?, + _ => return Err(CliError::usage("unsupported subcommand; expected diff")), + }; + let command = match parsed { + ParsedCommand::Outcome(outcome) => return Ok((outcome, placeholder(common))), + ParsedCommand::Command(command) => command, + }; + + Ok((ParseOutcome::Run, Cli { common, command })) +} + +fn placeholder(common: CommonArgs) -> Cli { + Cli { + common, + command: Command::Diff(DiffArgs { + old_assembly: PathBuf::new(), + new_assembly: PathBuf::new(), + query: ApiDiffQuery::default(), + }), + } +} + +fn parse_diff_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut query = ApiDiffQuery::default(); + let mut paths = Vec::new(); + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(ParsedCommand::Outcome(ParseOutcome::Help(DIFF_HELP))); + } + Long("version") | Short('V') => { + return Ok(ParsedCommand::Outcome(ParseOutcome::Version)); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("visibility") => { + query.visibility = parse_visibility(&parser_value_string(parser, "--visibility")?)?; + } + Long("include-special") => query.include_special = true, + Long("no-missing-method-risks") => query.include_missing_method_risks = false, + ArgValue(value) => { + if paths.len() == 2 { + return Err(CliError::usage( + "diff requires exactly two assembly paths: ", + )); + } + paths.push(PathBuf::from(value)); + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + if paths.len() != 2 { + return Err(CliError::usage( + "diff requires exactly two assembly paths: ", + )); + } + Ok(ParsedCommand::Command(Command::Diff(DiffArgs { + old_assembly: paths.remove(0), + new_assembly: paths.remove(0), + query, + }))) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_visibility(value: &str) -> Result { + match value { + "public" => Ok(ApiVisibilityScope::Public), + "internal" => Ok(ApiVisibilityScope::Internal), + "all" => Ok(ApiVisibilityScope::All), + other => Err(CliError::usage(format!( + "invalid --visibility value '{other}'; expected public, internal, or all" + ))), + } +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + Command::Diff(args) => { + let report = diff_assembly_api(&args.old_assembly, &args.new_assembly, &args.query) + .map_err(|error| CliError::runtime(error.to_string()))?; + render_report(&report, cli.common.render_mode())?; + Ok(ExitCode::Success) + } + } +} + +fn render_report(report: &ApiDiffReport, mode: RenderMode) -> Result<(), CliError> { + match mode { + RenderMode::Json | RenderMode::Toon => print_structured(report, mode), + RenderMode::Text => print_text(render_text_report(report)), + } +} + +fn render_text_report(report: &ApiDiffReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "asmapi diff old={} new={} visibility={:?} removed_types={} added_types={} removed_methods={} added_methods={} signature_changed={} missing_method_risks={}", + report.old_assembly.assembly_name, + report.new_assembly.assembly_name, + report.visibility, + report.summary.removed_types, + report.summary.added_types, + report.summary.removed_methods, + report.summary.added_methods, + report.summary.signature_changed_methods, + report.summary.missing_method_risks + ); + render_type_section(&mut output, "removed_types", &report.removed_types); + render_type_section(&mut output, "added_types", &report.added_types); + render_method_section(&mut output, "removed_methods", &report.removed_methods); + render_method_section(&mut output, "added_methods", &report.added_methods); + render_method_section( + &mut output, + "signature_changed", + &report.signature_changed_methods, + ); + if !report.missing_method_risks.is_empty() { + let _ = writeln!(output, "missing_method_risks:"); + for row in &report.missing_method_risks { + let _ = writeln!( + output, + " - {}::{} old_signature=\"{}\" old_assembly={} reason={}", + row.type_name, row.method_name, row.old_signature, row.old_assembly, row.reason + ); + } + } + output +} + +fn render_type_section(output: &mut String, title: &str, rows: &[managed::ApiTypeChange]) { + if rows.is_empty() { + return; + } + let _ = writeln!(output, "{title}:"); + for row in rows { + let _ = writeln!( + output, + " - {} kind={} visibility={}", + row.type_name, row.kind, row.visibility + ); + } +} + +fn render_method_section(output: &mut String, title: &str, rows: &[managed::ApiMethodChange]) { + if rows.is_empty() { + return; + } + let _ = writeln!(output, "{title}:"); + for row in rows { + let old = if row.old_signatures.is_empty() { + "-" + } else { + &row.old_signatures[0] + }; + let new = if row.new_signatures.is_empty() { + "-" + } else { + &row.new_signatures[0] + }; + let _ = writeln!( + output, + " - {}::{} old=\"{}\" new=\"{}\"", + row.type_name, row.method_name, old, new + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use managed::{ + ApiDiffSummary, ApiMethodChange, ApiTypeChange, AssemblyDescriptor, MissingMethodRisk, + }; + + fn assembly(name: &str) -> AssemblyDescriptor { + AssemblyDescriptor { + path: PathBuf::from(format!("{name}.dll")), + assembly_name: name.to_string(), + assembly_version: Some("1.2.3.4".to_string()), + runtime_version: "v4.0.30319".to_string(), + is_il_only: true, + is_library: true, + is_strong_name_signed: false, + public_key_token: None, + } + } + + #[test] + fn parse_cli_covers_top_level_and_diff_outcomes() { + let (outcome, _) = parse_cli_from(["asmapi", "--help"]).expect("top help"); + assert_eq!(outcome, ParseOutcome::Help(HELP)); + let (outcome, _) = parse_cli_from(["asmapi", "--version"]).expect("top version"); + assert_eq!(outcome, ParseOutcome::Version); + let (outcome, _) = parse_cli_from(["asmapi", "diff", "--help"]).expect("diff help"); + assert_eq!(outcome, ParseOutcome::Help(DIFF_HELP)); + let error = parse_cli_from(["asmapi"]).expect_err("missing subcommand"); + assert!(error.to_string().contains("missing subcommand")); + + let (outcome, cli) = parse_cli_from([ + "asmapi", + "--json", + "--color", + "never", + "diff", + "--visibility", + "all", + "--include-special", + "--no-missing-method-risks", + "old.dll", + "new.dll", + ]) + .expect("diff args"); + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.common.render_mode(), RenderMode::Json); + let Command::Diff(args) = cli.command; + assert_eq!(args.old_assembly, PathBuf::from("old.dll")); + assert_eq!(args.new_assembly, PathBuf::from("new.dll")); + assert_eq!(args.query.visibility, ApiVisibilityScope::All); + assert!(args.query.include_special); + assert!(!args.query.include_missing_method_risks); + } + + #[test] + fn parse_diff_rejects_invalid_visibility_and_wrong_path_count() { + let error = parse_cli_from(["asmapi", "diff", "--visibility", "private", "old", "new"]) + .expect_err("invalid visibility"); + assert!(error.to_string().contains("invalid --visibility")); + let error = parse_cli_from(["asmapi", "diff", "only-one"]).expect_err("path count"); + assert!(error.to_string().contains("exactly two assembly paths")); + let error = + parse_cli_from(["asmapi", "diff", "old", "new", "extra"]).expect_err("path count"); + assert!(error.to_string().contains("exactly two assembly paths")); + let error = parse_cli_from(["asmapi", "--format", "xml", "diff", "old", "new"]) + .expect_err("format"); + assert!(error.to_string().contains("invalid --format value 'xml'")); + + assert_eq!( + parse_visibility("public").expect("public"), + ApiVisibilityScope::Public + ); + assert_eq!( + parse_visibility("internal").expect("internal"), + ApiVisibilityScope::Internal + ); + assert_eq!( + parse_visibility("all").expect("all"), + ApiVisibilityScope::All + ); + } + + #[test] + fn render_text_report_includes_all_change_sections_and_risk_rows() { + let report = ApiDiffReport { + old_assembly: assembly("OldGame"), + new_assembly: assembly("NewGame"), + visibility: ApiVisibilityScope::Public, + summary: ApiDiffSummary { + removed_types: 1, + added_types: 1, + removed_methods: 1, + added_methods: 1, + signature_changed_methods: 1, + missing_method_risks: 1, + }, + removed_types: vec![ApiTypeChange { + type_name: "Game.Legacy".to_string(), + kind: "class".to_string(), + visibility: "public".to_string(), + }], + added_types: vec![ApiTypeChange { + type_name: "Game.Modern".to_string(), + kind: "class".to_string(), + visibility: "public".to_string(), + }], + removed_methods: vec![ApiMethodChange { + type_name: "Game.Legacy".to_string(), + method_name: "Run".to_string(), + old_signatures: vec!["void Run()".to_string()], + new_signatures: Vec::new(), + }], + added_methods: vec![ApiMethodChange { + type_name: "Game.Modern".to_string(), + method_name: "Run".to_string(), + old_signatures: Vec::new(), + new_signatures: vec!["void Run(int count)".to_string()], + }], + signature_changed_methods: vec![ApiMethodChange { + type_name: "Game.Player".to_string(), + method_name: "Move".to_string(), + old_signatures: vec!["void Move(float x)".to_string()], + new_signatures: vec!["void Move(float x, float y)".to_string()], + }], + missing_method_risks: vec![MissingMethodRisk { + old_assembly: "OldGame".to_string(), + type_name: "Game.Legacy".to_string(), + method_name: "Run".to_string(), + old_signature: "void Run()".to_string(), + reason: "removed public method".to_string(), + }], + }; + + let text = render_text_report(&report); + assert!(text.contains("asmapi diff old=OldGame new=NewGame")); + assert!(text.contains("removed_types:")); + assert!(text.contains("added_types:")); + assert!(text.contains("removed_methods:")); + assert!(text.contains("added_methods:")); + assert!(text.contains("signature_changed:")); + assert!(text.contains("missing_method_risks:")); + assert!(text.contains("Game.Legacy::Run old_signature=\"void Run()\"")); + } +} diff --git a/crates/asmapi/src/main.rs b/crates/asmapi/src/main.rs new file mode 100644 index 0000000..bc6d80b --- /dev/null +++ b/crates/asmapi/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for the `asmapi` command. + +fn main() { + std::process::exit(asmapi::main_entry()); +} diff --git a/crates/asmapi/tests/asmapi_cli.rs b/crates/asmapi/tests/asmapi_cli.rs new file mode 100644 index 0000000..f5fa379 --- /dev/null +++ b/crates/asmapi/tests/asmapi_cli.rs @@ -0,0 +1,148 @@ +//! Integration tests for the `asmapi` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; + +fn cargo_command() -> Command { + Command::cargo_bin("asmapi").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn managed_fixture_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") +} + +fn game_assembly() -> PathBuf { + managed_fixture_dir().join("GameAssembly.dll") +} + +fn fixture_support() -> PathBuf { + managed_fixture_dir().join("FixtureSupport.dll") +} + +fn diff_json(extra_args: &[&str]) -> Value { + let mut command = cargo_command(); + let output = command + .arg("diff") + .arg(game_assembly()) + .arg(fixture_support()) + .args(extra_args) + .assert() + .success() + .get_output() + .stdout + .clone(); + serde_json::from_slice::(&output).expect("json payload") +} + +#[test] +fn no_args_prints_quick_help_card() { + let mut command = cargo_command(); + command + .assert() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "missing subcommand; expected diff", + )) + .stderr(predicate::str::contains("asmapi")) + .stderr(predicate::str::contains("Type 'asmapi --help'")); +} + +#[test] +fn help_mentions_diff_and_shared_output_flags() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("asmapi diff")) + .stdout(predicate::str::contains("--format ")) + .stdout(predicate::str::contains("--json")) + .stdout(predicate::str::contains("--toon")); + + let mut command = cargo_command(); + command + .args(["diff", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--visibility ")) + .stdout(predicate::str::contains("--include-special")) + .stdout(predicate::str::contains("--no-missing-method-risks")); +} + +#[test] +fn diff_outputs_compact_text_sections() { + let mut command = cargo_command(); + command + .arg(game_assembly()) + .arg(fixture_support()) + .arg("diff") + .assert() + .code(2); + + let mut command = cargo_command(); + command + .arg("diff") + .arg(game_assembly()) + .arg(fixture_support()) + .assert() + .success() + .stdout(predicate::str::contains("removed_types=")) + .stdout(predicate::str::contains("added_types:")) + .stdout(predicate::str::contains("removed_methods:")) + .stdout(predicate::str::contains("missing_method_risks:")); +} + +#[test] +fn diff_json_exposes_stable_top_level_fields() { + let payload = diff_json(&["--json"]); + assert_eq!(payload["visibility"], "public"); + assert!(payload["old_assembly"].is_object()); + assert!(payload["new_assembly"].is_object()); + assert!(payload["summary"]["removed_types"].as_u64().is_some()); + assert!(payload["missing_method_risks"].as_array().is_some()); +} + +#[test] +fn visibility_all_includes_more_or_equal_removed_methods() { + let public_payload = diff_json(&["--json"]); + let all_payload = diff_json(&["--visibility", "all", "--json"]); + + assert_eq!(all_payload["visibility"], "all"); + assert!( + all_payload["summary"]["removed_methods"] + .as_u64() + .expect("all visibility removed method count") + >= public_payload["summary"]["removed_methods"] + .as_u64() + .expect("public visibility removed method count") + ); +} + +#[test] +fn invalid_visibility_is_usage_error() { + let mut command = cargo_command(); + command + .arg("diff") + .arg(game_assembly()) + .arg(fixture_support()) + .arg("--visibility") + .arg("private") + .assert() + .code(2) + .stderr(predicate::str::contains("invalid --visibility value")); +} diff --git a/crates/asmflow/Cargo.toml b/crates/asmflow/Cargo.toml new file mode 100644 index 0000000..d535296 --- /dev/null +++ b/crates/asmflow/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "asmflow" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect managed IL, calls, fields, and string literals with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +managed = { path = "../managed" } +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/asmflow/src/cli.rs b/crates/asmflow/src/cli.rs new file mode 100644 index 0000000..eecabcd --- /dev/null +++ b/crates/asmflow/src/cli.rs @@ -0,0 +1,1063 @@ +//! The `asmflow` command inspects managed IL flow data. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_error, print_json, print_quick_help_error, + print_structured, print_text, read_existing_stdin_path_records, should_read_stdin, + write_stdout, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use managed::{ + ManagedError, ManagedMethodBodyReport, ManagedMethodDescriptor, ManagedXrefReport, MethodQuery, + MethodTargetQuery, XrefDirection, find_methods, inspect_method_body, inspect_method_xref, +}; +use regex_lite::{Regex, RegexBuilder}; + +const HELP: &str = "\ +Inspect managed IL, method calls, field access, and string literals. + +Usage: + asmflow [OPTIONS] [ARGS...] + +Subcommands: + find Find methods by type name, method name, or signature + body Show a method body summary and IL instructions + xref Show callers or callees for a method + +Shared Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Find Options: + --assembly Assembly path; can be repeated + --match Match method name, type name, qualified name, or signature + --type Match only the declaring type full name + --signature Match only the rendered signature + --limit Maximum number of methods to emit + +Body Options: + --assembly Assembly path; can be repeated + --signature Disambiguate overloads by signature regex + --instructions-start Skip the first N decoded instructions in text output + --instructions-count Limit rendered instructions; default text mode shows 40 + --offset-range Render only instructions whose IL offsets fall in the inclusive range + +Xref Options: + --assembly Assembly path; can be repeated + --signature Disambiguate overloads by signature regex + --direction callers, callees, or both + --virtual Match base and override family members when finding callers + +Examples: + asmflow find .\\fixtures\\managed\\bin\\GameAssembly.dll --match 'StartProject|QueueVehicle' + asmflow body --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' + asmflow body --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' --offset-range 0x0:0x30 + asmflow xref --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartLaunch' --direction both + asmflow xref --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartLaunch' --direction callers --virtual + 'C:\\game\\Solar Expanse_Data\\Managed\\Assembly-CSharp.dll' | asmflow find --input-format lines --match 'Research|Construct' + asmflow body --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' --json | ConvertFrom-Json + asmflow xref --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartLaunch' --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: Command, +} + +#[derive(Debug, Clone)] +enum Command { + Find(FindArgs), + Body(BodyArgs), + Xref(XrefArgs), +} + +#[derive(Debug, Clone)] +struct FindArgs { + assemblies: Vec, + query: MethodQuery, +} + +#[derive(Debug, Clone)] +struct BodyArgs { + assemblies: Vec, + target: MethodTargetQuery, + instructions_start: usize, + instructions_count: Option, + offset_range: Option<(usize, usize)>, +} + +#[derive(Debug, Clone)] +struct XrefArgs { + assemblies: Vec, + target: MethodTargetQuery, + direction: XrefDirection, + virtual_dispatch: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => match print_text(HELP) { + Ok(()) => ExitCode::Success.as_i32(), + Err(error) => { + print_error(&error); + error.exit_code().as_i32() + } + }, + Ok((ParseOutcome::Version, _)) => { + println!("asmflow {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + + let subcommand = loop { + let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + else { + return Err(CliError::usage( + "missing subcommand; expected find, body, or xref", + )); + }; + + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + command: Command::Find(FindArgs { + assemblies: Vec::new(), + query: MethodQuery::default(), + }), + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + command: Command::Find(FindArgs { + assemblies: Vec::new(), + query: MethodQuery::default(), + }), + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + ArgValue(value) => { + break value.into_string().map_err(|invalid| { + CliError::usage(format!( + "subcommand expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + })?; + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + }; + + let command = match subcommand.as_str() { + "find" => Command::Find(parse_find_args(&mut parser, &mut common)?), + "body" => Command::Body(parse_body_args(&mut parser, &mut common)?), + "xref" => Command::Xref(parse_xref_args(&mut parser, &mut common)?), + other => { + return Err(CliError::usage(format!( + "unknown subcommand '{other}'; expected find, body, or xref" + ))); + } + }; + + Ok((ParseOutcome::Run, Cli { common, command })) +} + +fn parse_find_args( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut assemblies = Vec::new(); + let mut query = MethodQuery::default(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("match") => { + query.match_pattern = Some(parse_regex( + "--match", + &parser_value_string(parser, "--match")?, + )?); + } + Long("assembly") => { + assemblies.push(PathBuf::from( + parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?, + )); + } + Long("type") => { + query.type_pattern = Some(parse_regex( + "--type", + &parser_value_string(parser, "--type")?, + )?); + } + Long("signature") => { + query.signature_pattern = Some(parse_regex( + "--signature", + &parser_value_string(parser, "--signature")?, + )?); + } + Long("limit") => { + query.limit = Some(parse_positive_usize_flag( + "--limit", + &parser_value_string(parser, "--limit")?, + )?); + } + ArgValue(value) => assemblies.push(PathBuf::from(value)), + _ => { + return Err(CliError::usage( + "unsupported find argument; use --help to see available options", + )); + } + } + } + + Ok(FindArgs { assemblies, query }) +} + +fn parse_body_args( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut assemblies = Vec::new(); + let mut target_text = None; + let mut signature_pattern = None; + let mut instructions_start = 0_usize; + let mut instructions_count = None; + let mut offset_range = None; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("assembly") => { + assemblies.push(PathBuf::from( + parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?, + )); + } + Long("signature") => { + signature_pattern = Some(parse_regex( + "--signature", + &parser_value_string(parser, "--signature")?, + )?); + } + Long("instructions-start") => { + instructions_start = parse_usize_flag( + "--instructions-start", + &parser_value_string(parser, "--instructions-start")?, + )?; + } + Long("instructions-count") => { + instructions_count = Some(parse_positive_usize_flag( + "--instructions-count", + &parser_value_string(parser, "--instructions-count")?, + )?); + } + Long("offset-range") => { + offset_range = Some(parse_offset_range(&parser_value_string( + parser, + "--offset-range", + )?)?); + } + ArgValue(value) => { + let text = value.into_string().map_err(|invalid| { + CliError::usage(format!( + "body target expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + })?; + if target_text.is_none() && looks_like_assembly_path(&text) { + assemblies.push(PathBuf::from(text)); + } else if target_text.is_none() { + target_text = Some(text); + } else { + assemblies.push(PathBuf::from(text)); + } + } + _ => { + return Err(CliError::usage( + "unsupported body argument; use --help to see available options", + )); + } + } + } + + let Some(target_text) = target_text else { + return Err(CliError::usage("body requires a Type::Method target")); + }; + + Ok(BodyArgs { + assemblies, + target: map_managed_error(MethodTargetQuery::parse(&target_text, signature_pattern))?, + instructions_start, + instructions_count, + offset_range, + }) +} + +fn parse_xref_args( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut assemblies = Vec::new(); + let mut target_text = None; + let mut signature_pattern = None; + let mut direction = XrefDirection::Callers; + let mut virtual_dispatch = false; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("assembly") => { + assemblies.push(PathBuf::from( + parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?, + )); + } + Long("signature") => { + signature_pattern = Some(parse_regex( + "--signature", + &parser_value_string(parser, "--signature")?, + )?); + } + Long("direction") => { + direction = parse_direction(&parser_value_string(parser, "--direction")?)?; + } + Long("virtual") => virtual_dispatch = true, + ArgValue(value) => { + let text = value.into_string().map_err(|invalid| { + CliError::usage(format!( + "xref target expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + })?; + if target_text.is_none() && looks_like_assembly_path(&text) { + assemblies.push(PathBuf::from(text)); + } else if target_text.is_none() { + target_text = Some(text); + } else { + assemblies.push(PathBuf::from(text)); + } + } + _ => { + return Err(CliError::usage( + "unsupported xref argument; use --help to see available options", + )); + } + } + } + + let Some(target_text) = target_text else { + return Err(CliError::usage("xref requires a Type::Method target")); + }; + + Ok(XrefArgs { + assemblies, + target: map_managed_error(MethodTargetQuery::parse(&target_text, signature_pattern))?, + direction, + virtual_dispatch, + }) +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + Command::Find(args) => run_find(cli, args), + Command::Body(args) => run_body(cli, args), + Command::Xref(args) => run_xref(cli, args), + } +} + +fn run_find(cli: &Cli, args: &FindArgs) -> Result { + let assemblies = collect_paths(cli.common, &args.assemblies)?; + if assemblies.is_empty() { + return Err(CliError::usage( + "provide at least one assembly path or pipe assembly paths into stdin", + )); + } + + let rows = map_managed_error(find_methods(&assemblies, &args.query))?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&rows)?, + RenderMode::Toon => print_structured(&rows, RenderMode::Toon)?, + RenderMode::Text => { + if rows.is_empty() { + if !cli.common.quiet { + print_text("0 methods")?; + } + } else { + let mut rendered = String::new(); + for row in &rows { + let _ = writeln!(rendered, "{}", render_method_row(row)); + } + write_stdout(&rendered)?; + } + } + } + Ok(map_result_count(rows.len())) +} + +fn run_body(cli: &Cli, args: &BodyArgs) -> Result { + let assemblies = collect_paths(cli.common, &args.assemblies)?; + if assemblies.is_empty() { + return Err(CliError::usage( + "provide at least one assembly path with --assembly, as a positional argument, or via stdin", + )); + } + + let report = map_managed_error(inspect_method_body(&assemblies, &args.target))?; + match cli.common.render_mode() { + RenderMode::Json | RenderMode::Toon => { + let filtered = filtered_body_report(report, args); + print_structured(&filtered, cli.common.render_mode())?; + } + RenderMode::Text => write_stdout(&render_body_report(&report, args))?, + } + Ok(ExitCode::Success) +} + +fn run_xref(cli: &Cli, args: &XrefArgs) -> Result { + let assemblies = collect_paths(cli.common, &args.assemblies)?; + if assemblies.is_empty() { + return Err(CliError::usage( + "provide at least one assembly path with --assembly, as a positional argument, or via stdin", + )); + } + + let report = map_managed_error(inspect_method_xref( + &assemblies, + &args.target, + args.direction, + args.virtual_dispatch, + ))?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => write_stdout(&render_xref_report(&report, args.direction))?, + } + Ok(ExitCode::Success) +} + +fn collect_paths(common: CommonArgs, explicit: &[PathBuf]) -> Result, CliError> { + if should_read_stdin(!explicit.is_empty(), common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if let Some(parsed) = + read_existing_stdin_path_records(&buffer, common.input_format, "asmflow")? + { + if !parsed.is_empty() { + return Ok(parsed); + } + } + } + + common::expand_input_patterns(explicit, "asmflow") +} + +#[allow(dead_code)] +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + read_existing_stdin_path_records(buffer, input_format, "asmflow")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn render_method_row(row: &ManagedMethodDescriptor) -> String { + format!( + "{} signature=\"{}\" visibility={} rva=0x{:08x} instructions={} calls={} strings={} assembly={}", + row.qualified_name, + row.signature, + row.visibility, + row.rva, + row.instruction_count, + row.call_count, + row.string_literal_count, + row.assembly_name + ) +} + +fn render_body_report(report: &ManagedMethodBodyReport, args: &BodyArgs) -> String { + let mut rendered = String::new(); + let _ = writeln!(rendered, "{}", render_method_row(&report.method)); + + if !report.calls.is_empty() { + rendered.push_str("calls:\n"); + for call in &report.calls { + let _ = writeln!( + rendered, + " - {} signature=\"{}\"", + call.qualified_name, call.signature + ); + } + } + if !report.delegate_targets.is_empty() { + rendered.push_str("delegate_targets:\n"); + for reference in &report.delegate_targets { + let _ = writeln!( + rendered, + " - {} signature=\"{}\"", + reference.qualified_name, reference.signature + ); + } + } + if !report.field_reads.is_empty() { + rendered.push_str("field_reads:\n"); + for field in &report.field_reads { + let _ = writeln!( + rendered, + " - {} signature=\"{}\"", + field.qualified_name, field.signature + ); + } + } + if !report.field_writes.is_empty() { + rendered.push_str("field_writes:\n"); + for field in &report.field_writes { + let _ = writeln!( + rendered, + " - {} signature=\"{}\"", + field.qualified_name, field.signature + ); + } + } + if !report.string_literals.is_empty() { + rendered.push_str("string_literals:\n"); + for value in &report.string_literals { + let _ = writeln!(rendered, " - {value:?}"); + } + } + + let instructions = rendered_instructions(report, args); + let _ = writeln!( + rendered, + "instructions shown={}/{}:", + instructions.len(), + report.instructions.len() + ); + if instructions.is_empty() { + rendered.push_str(" - none\n"); + } else { + for instruction in instructions { + let _ = writeln!( + rendered, + " {:04x}: {}{}", + instruction.offset, + instruction.opcode, + instruction + .operand + .as_ref() + .map_or_else(String::new, |operand| format!(" {operand}")) + ); + } + } + + rendered +} + +fn render_xref_report(report: &ManagedXrefReport, direction: XrefDirection) -> String { + let mut rendered = String::new(); + let _ = writeln!(rendered, "target {}", render_method_row(&report.target)); + if report.virtual_targets.len() > 1 { + rendered.push_str("virtual_targets:\n"); + for candidate in &report.virtual_targets { + let _ = writeln!(rendered, " - {}", render_method_row(candidate)); + } + } + + if matches!(direction, XrefDirection::Callers | XrefDirection::Both) { + let _ = writeln!(rendered, "callers count={}:", report.callers.len()); + if report.callers.is_empty() { + rendered.push_str(" - none\n"); + } else { + for caller in &report.callers { + let _ = writeln!(rendered, " - {}", render_method_row(caller)); + } + } + } + + if matches!(direction, XrefDirection::Callees | XrefDirection::Both) { + let _ = writeln!(rendered, "callees count={}:", report.callees.len()); + if report.callees.is_empty() { + rendered.push_str(" - none\n"); + } else { + for callee in &report.callees { + let _ = writeln!( + rendered, + " - {} signature=\"{}\"", + callee.qualified_name, callee.signature + ); + } + } + let _ = writeln!( + rendered, + "delegate_targets count={}:", + report.delegate_targets.len() + ); + if report.delegate_targets.is_empty() { + rendered.push_str(" - none\n"); + } else { + for reference in &report.delegate_targets { + let _ = writeln!( + rendered, + " - {} signature=\"{}\"", + reference.qualified_name, reference.signature + ); + } + } + } + + rendered +} + +fn filtered_body_report( + mut report: ManagedMethodBodyReport, + args: &BodyArgs, +) -> ManagedMethodBodyReport { + if body_filters_active(args) { + report.instructions = rendered_instructions(&report, args) + .into_iter() + .cloned() + .collect(); + } + report +} + +fn rendered_instructions<'a>( + report: &'a ManagedMethodBodyReport, + args: &BodyArgs, +) -> Vec<&'a managed::ManagedInstruction> { + let default_limit = args.instructions_count.unwrap_or(40); + let use_full_range = body_filters_active(args); + let rows = report + .instructions + .iter() + .filter(|instruction| { + args.offset_range + .is_none_or(|(start, end)| instruction.offset >= start && instruction.offset <= end) + }) + .skip(args.instructions_start); + if use_full_range { + rows.take(args.instructions_count.unwrap_or(usize::MAX)) + .collect() + } else { + rows.take(default_limit).collect() + } +} + +const fn body_filters_active(args: &BodyArgs) -> bool { + args.instructions_start != 0 || args.instructions_count.is_some() || args.offset_range.is_some() +} + +fn map_managed_error(result: Result) -> Result { + result.map_err(|error| match error { + ManagedError::Query { message } => CliError::usage(message), + other => CliError::runtime(other.to_string()), + }) +} + +fn parse_regex(flag: &str, value: &str) -> Result { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(|error| CliError::usage(format!("invalid {flag} regex '{value}': {error}"))) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn parse_offset_range(value: &str) -> Result<(usize, usize), CliError> { + let Some((start, end)) = value.split_once(':') else { + return Err(CliError::usage(format!( + "invalid --offset-range value '{value}'; expected START:END" + ))); + }; + let start = parse_offset_value(start)?; + let end = parse_offset_value(end)?; + if start > end { + return Err(CliError::usage(format!( + "invalid --offset-range value '{value}'; start must be <= end" + ))); + } + Ok((start, end)) +} + +fn parse_offset_value(value: &str) -> Result { + let trimmed = value.trim(); + let parsed = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + .map_or_else( + || trimmed.parse::(), + |hex| usize::from_str_radix(hex, 16), + ); + parsed.map_err(|error| { + CliError::usage(format!( + "invalid offset value '{trimmed}' in --offset-range: {error}" + )) + }) +} + +fn parse_direction(value: &str) -> Result { + match value { + "callers" => Ok(XrefDirection::Callers), + "callees" => Ok(XrefDirection::Callees), + "both" => Ok(XrefDirection::Both), + other => Err(CliError::usage(format!( + "invalid --direction value '{other}'; expected callers, callees, or both" + ))), + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn looks_like_assembly_path(value: &str) -> bool { + let candidate = PathBuf::from(value); + candidate.exists() + && candidate.is_file() + && candidate + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("dll") || extension.eq_ignore_ascii_case("exe") + }) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") + } + + fn fixture_assembly() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + .join("GameAssembly.dll") + } + + #[test] + fn parse_paths_supports_lines_and_jsonl() { + let first = fixture_assembly(); + let second = workspace_root().join("Cargo.toml"); + assert_eq!( + parse_paths_from_string( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Lines + ) + .expect("line paths"), + vec![second.clone(), first.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("json path"), + serde_json::to_string(&second.display().to_string()).expect("json path") + ), + InputFormat::Jsonl + ) + .expect("jsonl paths"), + vec![second, first] + ); + } + + #[test] + fn run_find_body_and_xref_against_fixture_assembly() { + let find_exit = run(&Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + }, + command: Command::Find(FindArgs { + assemblies: vec![fixture_assembly()], + query: MethodQuery { + match_pattern: Some(parse_regex("--match", "StartProject").expect("regex")), + type_pattern: None, + signature_pattern: None, + limit: None, + }, + }), + }) + .expect("find"); + assert_eq!(find_exit, ExitCode::Success); + + let target = MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject", + None, + ) + .expect("target"); + let body_exit = run(&Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + }, + command: Command::Body(BodyArgs { + assemblies: vec![fixture_assembly()], + target: target.clone(), + instructions_start: 0, + instructions_count: None, + offset_range: None, + }), + }) + .expect("body"); + assert_eq!(body_exit, ExitCode::Success); + + let xref_exit = run(&Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + }, + command: Command::Xref(XrefArgs { + assemblies: vec![fixture_assembly()], + target, + direction: XrefDirection::Both, + virtual_dispatch: false, + }), + }) + .expect("xref"); + assert_eq!(xref_exit, ExitCode::Success); + } + + #[test] + fn parse_common_flags_after_subcommand() { + let fixture = fixture_assembly().display().to_string(); + let (outcome, cli) = parse_cli_from([ + "asmflow", + "xref", + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject", + "--assembly", + fixture.as_str(), + "--direction", + "both", + "--json", + ]) + .expect("parse"); + + assert_eq!(outcome, ParseOutcome::Run); + assert!(cli.common.json); + } + + #[test] + fn parse_rejects_zero_user_visible_limits() { + let error = + parse_cli_from(["asmflow", "find", "--limit", "0"]).expect_err("zero find limit"); + assert!(error.to_string().contains("--limit must be greater than 0")); + + let error = parse_cli_from([ + "asmflow", + "body", + "Game.Type::Method", + "--instructions-count", + "0", + ]) + .expect_err("zero instruction count"); + assert!( + error + .to_string() + .contains("--instructions-count must be greater than 0") + ); + } + + #[test] + fn parse_offset_range_accepts_decimal_and_hex_bounds() { + assert_eq!(parse_offset_range("16:32").expect("decimal"), (16, 32)); + assert_eq!(parse_offset_range("0x10:0x20").expect("hex"), (16, 32)); + assert!(parse_offset_range("0x20:0x10").is_err()); + } + + #[test] + fn render_xref_report_includes_section_counts() { + let report = ManagedXrefReport { + target: ManagedMethodDescriptor { + assembly_path: PathBuf::from("demo.dll"), + assembly_name: "Demo".to_string(), + type_name: "Game.Space".to_string(), + name: "Launch".to_string(), + qualified_name: "Game.Space::Launch".to_string(), + signature: "void Launch()".to_string(), + visibility: "public".to_string(), + is_static: false, + is_virtual: false, + is_abstract: false, + metadata_token: 1, + rva: 1, + instruction_count: 4, + call_count: 2, + string_literal_count: 0, + }, + virtual_targets: Vec::new(), + callers: Vec::new(), + callees: vec![managed::ManagedMethodReference { + metadata_token: 2, + qualified_name: "Game.Space::Queue".to_string(), + signature: "void Queue()".to_string(), + source_table: "MemberRef".to_string(), + external: false, + }], + delegate_targets: Vec::new(), + }; + + let rendered = render_xref_report(&report, XrefDirection::Both); + assert!(rendered.contains("callers count=0:")); + assert!(rendered.contains("callees count=1:")); + assert!(rendered.contains("delegate_targets count=0:")); + } +} diff --git a/crates/asmflow/src/lib.rs b/crates/asmflow/src/lib.rs new file mode 100644 index 0000000..fdeda11 --- /dev/null +++ b/crates/asmflow/src/lib.rs @@ -0,0 +1,5 @@ +//! Public entry point for the `asmflow` command crate. + +mod cli; + +pub use cli::main_entry; diff --git a/crates/asmflow/src/main.rs b/crates/asmflow/src/main.rs new file mode 100644 index 0000000..70e0cc9 --- /dev/null +++ b/crates/asmflow/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `asmflow`. + +fn main() { + std::process::exit(asmflow::main_entry()); +} diff --git a/crates/asmflow/tests/asmflow_cli.rs b/crates/asmflow/tests/asmflow_cli.rs new file mode 100644 index 0000000..ec538b4 --- /dev/null +++ b/crates/asmflow/tests/asmflow_cli.rs @@ -0,0 +1,27 @@ +//! Integration tests for the `asmflow` command. + +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn no_args_prints_quick_help_card() { + let mut command = Command::cargo_bin("asmflow").expect("binary"); + command + .assert() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "error: missing subcommand; expected find, body, or xref", + )) + .stderr(predicate::str::contains("asmflow - Mercury Toolbox")) + .stderr(predicate::str::contains("Usage:")) + .stderr(predicate::str::contains( + "asmflow [OPTIONS] [ARGS...]", + )) + .stderr(predicate::str::contains("find")) + .stderr(predicate::str::contains("body")) + .stderr(predicate::str::contains("xref")) + .stderr(predicate::str::contains( + "Type 'asmflow --help' for the full command reference.", + )); +} diff --git a/crates/asmmember/Cargo.toml b/crates/asmmember/Cargo.toml new file mode 100644 index 0000000..8edbd08 --- /dev/null +++ b/crates/asmmember/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "asmmember" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "List managed assembly members with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +managed = { path = "../managed" } +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/asmmember/src/cli.rs b/crates/asmmember/src/cli.rs new file mode 100644 index 0000000..f2a12ff --- /dev/null +++ b/crates/asmmember/src/cli.rs @@ -0,0 +1,1107 @@ +//! The `asmmember` command lists managed assembly members. + +use std::ffi::OsString; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use managed::BindingFilter; +use managed::{MemberDescriptor, MemberQuery, list_members}; +use regex_lite::{Regex, RegexBuilder}; +use serde_json::Map; +use serde_json::Value; + +const HELP: &str = "\ +List methods, fields, and properties from managed types. + +Usage: + asmmember [OPTIONS] + +Options: + --assembly Assembly path to inspect + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --kind Filter by member kind: method, field, property, all + --match Filter by member name regex + --binding Binding flags: public, nonpublic, instance, static + --pick Project selected fields such as type_name,name,signature + --include-special Include special-name methods such as property accessors + --user-code-only Hide compiler-generated backing fields and closure artifacts + --intent Apply a curated workflow preset: unity-spacecraft-workflow + --limit Maximum number of rows to emit (default: 128) + -h, --help Show this help text + -V, --version Show the command version + +Examples: + asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll Game.UI.Windows.Windows.SpaceCraftConstructionWindow + 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow' | asmmember --assembly C:\\game\\Managed\\Assembly-CSharp.dll --input-format lines --match 'Build|Launch' + asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll Game.UI.Windows.Windows.SpaceCraftConstructionWindow --binding public,nonpublic,instance + asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll Data.SpacecraftConstructData --pick kind,name,visibility,signature + asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll --user-code-only Data.SpacecraftConstructData + asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --match SpaceCraft --json | asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll --input-format jsonl --intent unity-spacecraft-workflow --json | ConvertFrom-Json + asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll Data.SpacecraftConstructData --json | ConvertFrom-Json + +Notes: + unity-spacecraft-workflow enables non-public binding and --user-code-only for reverse-engineering triage + If a known Unity type looks empty, rerun with --intent unity-spacecraft-workflow or --binding public,nonpublic,instance,static +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + assembly_paths: Vec, + query: MemberQuery, + pick: Vec, + intent: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IntentPreset { + UnitySpacecraftWorkflow, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("asmmember {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + assembly_paths: Vec::new(), + query: MemberQuery { + limit: Some(128), + ..MemberQuery::default() + }, + pick: Vec::new(), + intent: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("assembly") => { + cli.assembly_paths.push(PathBuf::from(parser_value_string( + &mut parser, + "--assembly", + )?)); + } + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("kind") => cli.query.kind = Some(parser_value_string(&mut parser, "--kind")?), + Long("match") => { + cli.query.match_pattern = Some(parse_regex( + "--match", + &parser_value_string(&mut parser, "--match")?, + )?); + } + Long("binding") => { + cli.query.binding = + parse_binding_filter(&parser_value_string(&mut parser, "--binding")?)?; + } + Long("pick") => { + cli.pick.extend(split_csv_values(&parser_value_string( + &mut parser, + "--pick", + )?)); + } + Long("include-special") => cli.query.include_special = true, + Long("user-code-only") => cli.query.user_code_only = true, + Long("intent") => { + cli.intent = Some(parse_intent(&parser_value_string( + &mut parser, + "--intent", + )?)?); + } + Long("limit") => { + cli.query.limit = Some(parse_positive_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + ArgValue(value) => cli + .query + .type_names + .push(os_string_to_string(value, "type name")?), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_string_to_string(value, flag) +} + +fn os_string_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_regex(flag: &str, value: &str) -> Result { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(|error| CliError::usage(format!("invalid {flag} regex '{value}': {error}"))) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn split_csv_values(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() +} + +fn parse_intent(value: &str) -> Result { + match value { + "unity-spacecraft-workflow" => Ok(IntentPreset::UnitySpacecraftWorkflow), + other => Err(CliError::usage(format!( + "invalid --intent value '{other}'; expected unity-spacecraft-workflow" + ))), + } +} + +fn parse_binding_filter(value: &str) -> Result { + let mut filter = BindingFilter { + include_public: false, + include_non_public: false, + include_instance: false, + include_static: false, + }; + + for part in value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + { + match part { + "public" => filter.include_public = true, + "nonpublic" => filter.include_non_public = true, + "instance" => filter.include_instance = true, + "static" => filter.include_static = true, + _ => { + return Err(CliError::usage(format!( + "invalid --binding flag '{part}'; expected public, nonpublic, instance, or static" + ))); + } + } + } + + if !filter.include_public && !filter.include_non_public { + return Err(CliError::usage( + "--binding must include at least one of public or nonpublic", + )); + } + if !filter.include_instance && !filter.include_static { + return Err(CliError::usage( + "--binding must include at least one of instance or static", + )); + } + + Ok(filter) +} + +fn run(cli: &Cli) -> Result { + validate_pick_fields(&cli.pick)?; + if cli.assembly_paths.is_empty() { + return Err(CliError::usage("provide at least one --assembly path")); + } + + let mut query = apply_intent_defaults(cli.query.clone(), cli.intent)?; + if query.type_names.is_empty() { + query.type_names = collect_type_names(cli)?; + } + if query.type_names.is_empty() { + return Err(CliError::usage( + "provide at least one type name or pipe type names into stdin", + )); + } + + let rows = list_members(&cli.assembly_paths, &query) + .map_err(|error| CliError::runtime(error.to_string()))?; + + let render_mode = cli.common.render_mode(); + match render_mode { + RenderMode::Json | RenderMode::Toon => { + if cli.pick.is_empty() { + print_structured(&rows, render_mode)?; + } else { + let payload = rows + .iter() + .map(|row| project_member(row, &cli.pick)) + .collect::>(); + print_structured(&payload, render_mode)?; + } + } + RenderMode::Text => { + if rows.is_empty() { + if !cli.common.quiet { + println!("{}", zero_matches_message(&query)); + } + } else { + for row in &rows { + println!("{}", render_member(row, &cli.pick)); + } + } + } + } + + Ok(map_result_count(rows.len())) +} + +fn collect_type_names(cli: &Cli) -> Result, CliError> { + if should_read_stdin(false, cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + return parse_type_names_from_string(&buffer, cli.common.input_format); + } + + Ok(Vec::new()) +} + +fn parse_type_names_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + match input_format { + InputFormat::Auto | InputFormat::Lines => Ok(buffer + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(parse_type_name_line) + .collect()), + InputFormat::Jsonl => parse_type_names_from_jsonl(buffer), + } +} + +fn parse_type_name_line(line: &str) -> String { + line.strip_prefix("full_name=") + .or_else(|| line.strip_prefix("type_name=")) + .unwrap_or(line) + .trim() + .to_string() +} + +fn parse_type_names_from_jsonl(buffer: &str) -> Result, CliError> { + let trimmed = buffer.trim(); + if trimmed.starts_with('[') { + return parse_type_names_from_json_array(trimmed); + } + + let mut names = Vec::new(); + for (index, line) in buffer.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value: Value = serde_json::from_str(trimmed).map_err(|error| { + CliError::usage(format!("invalid JSONL type line {}: {error}", index + 1)) + })?; + let object = value.as_object().ok_or_else(|| { + CliError::usage(format!( + "invalid JSONL type line {}: expected an object with full_name or type_name", + index + 1 + )) + })?; + let Some(name) = object + .get("full_name") + .or_else(|| object.get("type_name")) + .and_then(Value::as_str) + else { + return Err(CliError::usage(format!( + "invalid JSONL type line {}: expected full_name or type_name", + index + 1 + ))); + }; + names.push(name.to_string()); + } + Ok(names) +} + +fn parse_type_names_from_json_array(buffer: &str) -> Result, CliError> { + let value: Value = serde_json::from_str(buffer) + .map_err(|error| CliError::usage(format!("invalid JSON array type input: {error}")))?; + let items = value + .as_array() + .ok_or_else(|| CliError::usage("invalid JSON array type input: expected an array"))?; + let mut names = Vec::new(); + for (index, item) in items.iter().enumerate() { + let object = item.as_object().ok_or_else(|| { + CliError::usage(format!( + "invalid JSON array type item {}: expected an object with full_name or type_name", + index + 1 + )) + })?; + let Some(name) = object + .get("full_name") + .or_else(|| object.get("type_name")) + .and_then(Value::as_str) + else { + return Err(CliError::usage(format!( + "invalid JSON array type item {}: expected full_name or type_name", + index + 1 + ))); + }; + names.push(name.to_string()); + } + Ok(names) +} + +fn apply_intent_defaults( + mut query: MemberQuery, + intent: Option, +) -> Result { + if let Some(intent) = intent { + let pattern = workflow_regex(intent)?; + if query.match_pattern.is_none() { + query.match_pattern = Some(pattern); + } + if query.binding == BindingFilter::default() { + query.binding = full_binding_filter(); + } + query.user_code_only = true; + } + Ok(query) +} + +fn workflow_regex(intent: IntentPreset) -> Result { + match intent { + IntentPreset::UnitySpacecraftWorkflow => parse_regex( + "--intent", + "build|project|construct|construction|launch|queue|vehicle|rocket|progress|finish|complete|space\\s*craft|spacecraft", + ), + } +} + +const fn full_binding_filter() -> BindingFilter { + BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: true, + } +} + +fn render_member(row: &MemberDescriptor, pick: &[String]) -> String { + if pick.is_empty() { + return match row { + MemberDescriptor::Method { + type_name, + visibility, + signature, + .. + } => format!("{type_name} method visibility={visibility} signature={signature}"), + MemberDescriptor::Field { + type_name, + visibility, + signature, + .. + } => format!("{type_name} field visibility={visibility} signature={signature}"), + MemberDescriptor::Property { + type_name, + visibility, + signature, + .. + } => format!("{type_name} property visibility={visibility} signature={signature}"), + }; + } + + pick.iter() + .filter_map(|field| projected_member_text(row, field)) + .collect::>() + .join(" ") +} + +fn validate_pick_fields(fields: &[String]) -> Result<(), CliError> { + for field in fields { + match field.as_str() { + "kind" | "assembly_path" | "assembly_name" | "type_name" | "name" | "visibility" + | "is_static" | "is_virtual" | "is_abstract" | "return_type" | "parameters" + | "signature" | "is_literal" | "is_init_only" | "field_type" | "property_type" + | "getter_visibility" | "setter_visibility" => {} + other => { + return Err(CliError::usage(format!( + "invalid --pick field '{other}'; expected kind, assembly_path, assembly_name, type_name, name, visibility, is_static, is_virtual, is_abstract, return_type, parameters, signature, is_literal, is_init_only, field_type, property_type, getter_visibility, or setter_visibility" + ))); + } + } + } + + Ok(()) +} + +fn project_member(row: &MemberDescriptor, pick: &[String]) -> Value { + let mut object = Map::new(); + for field in pick { + if let Some(value) = projected_member_value(row, field) { + object.insert(field.clone(), value); + } + } + Value::Object(object) +} + +fn projected_member_text(row: &MemberDescriptor, field: &str) -> Option { + projected_member_value(row, field).map(|value| match value { + Value::Null => format!("{field}=null"), + Value::Bool(boolean) => format!("{field}={boolean}"), + Value::Number(number) => format!("{field}={number}"), + Value::String(text) => format!("{field}={text}"), + Value::Array(array) => format!( + "{field}={}", + array + .iter() + .map(compact_value_text) + .collect::>() + .join(",") + ), + Value::Object(_) => format!("{field}={}", compact_value_text(&value)), + }) +} + +fn projected_member_value(row: &MemberDescriptor, field: &str) -> Option { + match field { + "kind" => Some(Value::String(member_kind(row).to_string())), + "assembly_path" => Some(Value::String(member_assembly_path(row))), + "assembly_name" => Some(Value::String(member_assembly_name(row).to_string())), + "type_name" => Some(Value::String(member_type_name(row).to_string())), + "name" => Some(Value::String(member_name(row).to_string())), + "visibility" => Some(Value::String(member_visibility(row).to_string())), + "is_static" => Some(Value::Bool(member_is_static(row))), + "is_virtual" => member_is_virtual(row).map(Value::Bool), + "is_abstract" => member_is_abstract(row).map(Value::Bool), + "return_type" => member_return_type(row).map(|value| Value::String(value.to_string())), + "parameters" => Some(Value::Array( + member_parameters(row) + .iter() + .map(|parameter| { + let mut object = Map::new(); + object.insert( + "name".to_string(), + parameter.name.clone().map_or(Value::Null, Value::String), + ); + object.insert( + "parameter_type".to_string(), + Value::String(parameter.parameter_type.clone()), + ); + Value::Object(object) + }) + .collect::>(), + )), + "signature" => Some(Value::String(member_signature(row).to_string())), + "is_literal" => member_is_literal(row).map(Value::Bool), + "is_init_only" => member_is_init_only(row).map(Value::Bool), + "field_type" => member_field_type(row).map(|value| Value::String(value.to_string())), + "property_type" => member_property_type(row).map(|value| Value::String(value.to_string())), + "getter_visibility" => Some( + member_getter_visibility(row) + .map_or(Value::Null, |value| Value::String(value.to_string())), + ), + "setter_visibility" => Some( + member_setter_visibility(row) + .map_or(Value::Null, |value| Value::String(value.to_string())), + ), + _ => None, + } +} + +fn compact_value_text(value: &Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(boolean) => boolean.to_string(), + Value::Number(number) => number.to_string(), + Value::String(text) => text.clone(), + Value::Array(array) => array + .iter() + .map(compact_value_text) + .collect::>() + .join("|"), + Value::Object(object) => object + .iter() + .map(|(key, value)| format!("{key}:{}", compact_value_text(value))) + .collect::>() + .join("|"), + } +} + +fn zero_matches_message(query: &MemberQuery) -> String { + if query.binding == full_binding_filter() { + "0 matches (tip: try --intent unity-spacecraft-workflow or narrow --match)".to_string() + } else { + "0 matches (tip: try --binding public,nonpublic,instance,static or --intent unity-spacecraft-workflow)".to_string() + } +} + +const fn member_kind(row: &MemberDescriptor) -> &'static str { + match row { + MemberDescriptor::Method { .. } => "method", + MemberDescriptor::Field { .. } => "field", + MemberDescriptor::Property { .. } => "property", + } +} + +fn member_assembly_path(row: &MemberDescriptor) -> String { + match row { + MemberDescriptor::Method { assembly_path, .. } + | MemberDescriptor::Field { assembly_path, .. } + | MemberDescriptor::Property { assembly_path, .. } => assembly_path.display().to_string(), + } +} + +fn member_assembly_name(row: &MemberDescriptor) -> &str { + match row { + MemberDescriptor::Method { assembly_name, .. } + | MemberDescriptor::Field { assembly_name, .. } + | MemberDescriptor::Property { assembly_name, .. } => assembly_name, + } +} + +fn member_type_name(row: &MemberDescriptor) -> &str { + match row { + MemberDescriptor::Method { type_name, .. } + | MemberDescriptor::Field { type_name, .. } + | MemberDescriptor::Property { type_name, .. } => type_name, + } +} + +fn member_name(row: &MemberDescriptor) -> &str { + match row { + MemberDescriptor::Method { name, .. } + | MemberDescriptor::Field { name, .. } + | MemberDescriptor::Property { name, .. } => name, + } +} + +fn member_visibility(row: &MemberDescriptor) -> &str { + match row { + MemberDescriptor::Method { visibility, .. } + | MemberDescriptor::Field { visibility, .. } + | MemberDescriptor::Property { visibility, .. } => visibility, + } +} + +const fn member_is_static(row: &MemberDescriptor) -> bool { + match row { + MemberDescriptor::Method { is_static, .. } + | MemberDescriptor::Field { is_static, .. } + | MemberDescriptor::Property { is_static, .. } => *is_static, + } +} + +const fn member_is_virtual(row: &MemberDescriptor) -> Option { + match row { + MemberDescriptor::Method { is_virtual, .. } => Some(*is_virtual), + MemberDescriptor::Field { .. } | MemberDescriptor::Property { .. } => None, + } +} + +const fn member_is_abstract(row: &MemberDescriptor) -> Option { + match row { + MemberDescriptor::Method { is_abstract, .. } => Some(*is_abstract), + MemberDescriptor::Field { .. } | MemberDescriptor::Property { .. } => None, + } +} + +fn member_return_type(row: &MemberDescriptor) -> Option<&str> { + match row { + MemberDescriptor::Method { return_type, .. } => Some(return_type), + MemberDescriptor::Field { .. } | MemberDescriptor::Property { .. } => None, + } +} + +fn member_parameters(row: &MemberDescriptor) -> &[managed::ParameterDescriptor] { + match row { + MemberDescriptor::Method { parameters, .. } + | MemberDescriptor::Property { parameters, .. } => parameters, + MemberDescriptor::Field { .. } => &[], + } +} + +fn member_signature(row: &MemberDescriptor) -> &str { + match row { + MemberDescriptor::Method { signature, .. } + | MemberDescriptor::Field { signature, .. } + | MemberDescriptor::Property { signature, .. } => signature, + } +} + +const fn member_is_literal(row: &MemberDescriptor) -> Option { + match row { + MemberDescriptor::Field { is_literal, .. } => Some(*is_literal), + MemberDescriptor::Method { .. } | MemberDescriptor::Property { .. } => None, + } +} + +const fn member_is_init_only(row: &MemberDescriptor) -> Option { + match row { + MemberDescriptor::Field { is_init_only, .. } => Some(*is_init_only), + MemberDescriptor::Method { .. } | MemberDescriptor::Property { .. } => None, + } +} + +fn member_field_type(row: &MemberDescriptor) -> Option<&str> { + match row { + MemberDescriptor::Field { field_type, .. } => Some(field_type), + MemberDescriptor::Method { .. } | MemberDescriptor::Property { .. } => None, + } +} + +fn member_property_type(row: &MemberDescriptor) -> Option<&str> { + match row { + MemberDescriptor::Property { property_type, .. } => Some(property_type), + MemberDescriptor::Method { .. } | MemberDescriptor::Field { .. } => None, + } +} + +fn member_getter_visibility(row: &MemberDescriptor) -> Option<&str> { + match row { + MemberDescriptor::Property { + getter_visibility, .. + } => getter_visibility.as_deref(), + MemberDescriptor::Method { .. } | MemberDescriptor::Field { .. } => None, + } +} + +fn member_setter_visibility(row: &MemberDescriptor) -> Option<&str> { + match row { + MemberDescriptor::Property { + setter_visibility, .. + } => setter_visibility.as_deref(), + MemberDescriptor::Method { .. } | MemberDescriptor::Field { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use common::{ColorChoice, InputFormat}; + + use super::*; + + fn cli() -> Cli { + Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: ColorChoice::Never, + quiet: false, + }, + assembly_paths: vec![PathBuf::from("fixture.dll")], + query: MemberQuery::default(), + pick: Vec::new(), + intent: None, + } + } + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") + } + + fn fixture_assembly() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + .join("GameAssembly.dll") + } + + #[test] + fn binding_filter_parser_is_strict() { + let filter = parse_binding_filter("public,nonpublic,instance,static").expect("binding"); + assert!(filter.include_public); + assert!(filter.include_non_public); + assert!(filter.include_instance); + assert!(filter.include_static); + assert!(matches!( + parse_binding_filter("public"), + Err(CliError::Usage(message)) if message.contains("instance or static") + )); + } + + #[test] + fn run_requires_type_name_and_assembly() { + let error = run(&Cli { + assembly_paths: Vec::new(), + ..cli() + }) + .expect_err("missing assembly should fail"); + assert!(matches!( + error, + CliError::Usage(message) if message.contains("--assembly") + )); + + let error = run(&cli()).expect_err("missing type names should fail"); + assert!(matches!( + error, + CliError::Usage(message) if message.contains("provide at least one type name") + )); + } + + #[test] + fn parse_cli_recognizes_member_filters() { + let (_, parsed) = parse_cli_from([ + "asmmember", + "--assembly", + "fixture.dll", + "--intent", + "unity-spacecraft-workflow", + "--kind", + "method", + "--match", + "project", + "--binding", + "public,nonpublic,instance,static", + "--include-special", + "--user-code-only", + "--limit", + "3", + "Type.Name", + ]) + .expect("cli parse"); + + assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]); + assert_eq!(parsed.query.kind.as_deref(), Some("method")); + assert!(parsed.query.include_special); + assert!(parsed.query.user_code_only); + assert_eq!(parsed.query.limit, Some(3)); + assert_eq!(parsed.query.type_names, vec!["Type.Name".to_string()]); + assert_eq!(parsed.intent, Some(IntentPreset::UnitySpacecraftWorkflow)); + } + + #[test] + fn parse_cli_applies_compact_default_limit() { + let (_, parsed) = + parse_cli_from(["asmmember", "--assembly", "fixture.dll", "Type.Name"]).expect("cli"); + assert_eq!(parsed.query.limit, Some(128)); + } + + #[test] + fn parse_cli_rejects_zero_limit() { + let error = parse_cli_from(["asmmember", "--assembly", "fixture.dll", "--limit", "0"]) + .expect_err("zero limit"); + assert!(error.to_string().contains("--limit must be greater than 0")); + } + + #[test] + fn run_emits_text_and_no_results_for_fixture_members() { + let success = run(&Cli { + common: common_args(false, InputFormat::Auto), + assembly_paths: vec![fixture_assembly()], + query: MemberQuery { + type_names: vec![ + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), + ], + kind: Some("method".to_string()), + match_pattern: Some(parse_regex("--match", "project").expect("regex")), + binding: parse_binding_filter("public,nonpublic,instance,static").expect("binding"), + include_special: false, + user_code_only: false, + limit: None, + }, + pick: Vec::new(), + intent: None, + }) + .expect("successful run"); + assert_eq!(success, ExitCode::Success); + + let no_results = run(&Cli { + common: common_args(false, InputFormat::Auto), + assembly_paths: vec![fixture_assembly()], + query: MemberQuery { + type_names: vec![ + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), + ], + kind: Some("field".to_string()), + match_pattern: Some(parse_regex("--match", "^missing$").expect("regex")), + binding: parse_binding_filter("public,nonpublic,instance,static").expect("binding"), + include_special: false, + user_code_only: false, + limit: None, + }, + pick: Vec::new(), + intent: None, + }) + .expect("no results run"); + assert_eq!(no_results, ExitCode::NoResults); + } + + #[test] + fn parse_cli_recognizes_pick_fields() { + let (_, parsed) = parse_cli_from([ + "asmmember", + "--assembly", + "fixture.dll", + "--pick", + "kind,type_name,name,signature", + "Type.Name", + ]) + .expect("cli parse"); + + assert_eq!( + parsed.pick, + vec![ + "kind".to_string(), + "type_name".to_string(), + "name".to_string(), + "signature".to_string(), + ] + ); + } + + #[test] + fn parse_type_names_supports_jsonl_from_asmtype() { + let names = parse_type_names_from_string( + "{\"full_name\":\"Game.UI.Windows.Windows.SpaceCraftConstructionWindow\"}\n{\"type_name\":\"Data.SpacecraftConstructData\"}\n", + InputFormat::Jsonl, + ) + .expect("jsonl type names"); + + assert_eq!( + names, + vec![ + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), + "Data.SpacecraftConstructData".to_string(), + ] + ); + } + + #[test] + fn parse_type_names_supports_json_array_handoff_from_asmtype() { + let names = parse_type_names_from_string( + "[{\"full_name\":\"Game.UI.Windows.Windows.SpaceCraftConstructionWindow\"},{\"type_name\":\"Data.SpacecraftConstructData\"}]", + InputFormat::Jsonl, + ) + .expect("json array type names"); + + assert_eq!( + names, + vec![ + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), + "Data.SpacecraftConstructData".to_string(), + ] + ); + } + + #[test] + fn parse_type_names_supports_key_value_lines_from_asmtype_pick() { + let names = parse_type_names_from_string( + "full_name=Game.UI.Windows.Windows.SpaceCraftConstructionWindow\ntype_name=Data.SpacecraftConstructData\n", + InputFormat::Lines, + ) + .expect("key value lines"); + + assert_eq!( + names, + vec![ + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), + "Data.SpacecraftConstructData".to_string(), + ] + ); + } + + #[test] + fn intent_preset_enables_non_public_workflow_search() { + let query = apply_intent_defaults( + MemberQuery::default(), + Some(IntentPreset::UnitySpacecraftWorkflow), + ) + .expect("intent"); + assert!(query.binding.include_public); + assert!(query.binding.include_non_public); + assert!(query.user_code_only); + assert!( + query + .match_pattern + .as_ref() + .is_some_and(|pattern| pattern.is_match("FinishProject")) + ); + } + + #[test] + fn projected_member_value_exposes_selected_fields() { + let row = MemberDescriptor::Method { + assembly_path: PathBuf::from("fixture.dll"), + assembly_name: "Fixture".to_string(), + type_name: "Demo.Type".to_string(), + name: "Build".to_string(), + visibility: "public".to_string(), + is_static: false, + is_virtual: true, + is_abstract: false, + return_type: "System.Void".to_string(), + parameters: Vec::new(), + signature: "public void Build()".to_string(), + }; + + assert_eq!( + projected_member_value(&row, "kind"), + Some(Value::String("method".to_string())) + ); + assert_eq!( + projected_member_value(&row, "type_name"), + Some(Value::String("Demo.Type".to_string())) + ); + } + + #[test] + fn projection_helpers_cover_field_property_and_invalid_pick_paths() { + let field = MemberDescriptor::Field { + assembly_path: PathBuf::from("fixture.dll"), + assembly_name: "Fixture".to_string(), + type_name: "Demo.Type".to_string(), + name: "value".to_string(), + visibility: "private".to_string(), + is_static: false, + is_literal: false, + is_init_only: true, + field_type: "System.Int32".to_string(), + signature: "private initonly System.Int32 value".to_string(), + }; + let property = MemberDescriptor::Property { + assembly_path: PathBuf::from("fixture.dll"), + assembly_name: "Fixture".to_string(), + type_name: "Demo.Type".to_string(), + name: "Name".to_string(), + visibility: "public".to_string(), + is_static: false, + property_type: "System.String".to_string(), + parameters: Vec::new(), + getter_visibility: Some("public".to_string()), + setter_visibility: Some("private".to_string()), + signature: "public System.String Name { get; private set; }".to_string(), + }; + + assert!(matches!( + validate_pick_fields(&["nope".to_string()]), + Err(CliError::Usage(message)) if message.contains("invalid --pick field") + )); + assert_eq!( + projected_member_value(&field, "field_type"), + Some(Value::String("System.Int32".to_string())) + ); + assert_eq!( + projected_member_value(&property, "setter_visibility"), + Some(Value::String("private".to_string())) + ); + assert!( + render_member(&property, &["kind".to_string(), "signature".to_string()]) + .contains("kind=property") + ); + } + + #[test] + fn zero_matches_hint_suggests_broader_binding_when_filter_is_narrow() { + let narrow = MemberQuery::default(); + assert!(zero_matches_message(&narrow).contains("--binding")); + assert_eq!( + zero_matches_message(&MemberQuery { + binding: full_binding_filter(), + ..MemberQuery::default() + }), + "0 matches (tip: try --intent unity-spacecraft-workflow or narrow --match)" + ); + } +} diff --git a/crates/asmmember/src/lib.rs b/crates/asmmember/src/lib.rs new file mode 100644 index 0000000..a4cbc3b --- /dev/null +++ b/crates/asmmember/src/lib.rs @@ -0,0 +1,5 @@ +//! Public entry point for the `asmmember` command crate. + +mod cli; + +pub use cli::main_entry; diff --git a/crates/asmmember/src/main.rs b/crates/asmmember/src/main.rs new file mode 100644 index 0000000..da060ed --- /dev/null +++ b/crates/asmmember/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `asmmember`. + +fn main() { + std::process::exit(asmmember::main_entry()); +} diff --git a/crates/asmmember/tests/asmmember_cli.rs b/crates/asmmember/tests/asmmember_cli.rs new file mode 100644 index 0000000..7b539d8 --- /dev/null +++ b/crates/asmmember/tests/asmmember_cli.rs @@ -0,0 +1,105 @@ +//! Integration tests for the `asmmember` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("asmmember").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn managed_fixture_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") +} + +fn fixture_assembly() -> PathBuf { + managed_fixture_dir().join("GameAssembly.dll") +} + +#[test] +fn help_includes_examples_and_binding_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--assembly")) + .stdout(predicate::str::contains("--binding")) + .stdout(predicate::str::contains("--user-code-only")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} + +#[test] +fn emits_methods_fields_and_properties_as_json() { + let mut command = cargo_command(); + let output = command + .arg("--assembly") + .arg(fixture_assembly()) + .arg("Game.UI.Windows.Windows.SpaceCraftConstructionWindow") + .arg("--match") + .arg("Build|Project|Launch|Queue|Complete") + .arg("--binding") + .arg("public,nonpublic,instance,static") + .arg("--user-code-only") + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + let payload = serde_json::from_slice::(&output).expect("json payload"); + let entries = payload.as_array().expect("array payload"); + assert!( + entries + .iter() + .any(|entry| entry["kind"] == "method" && entry["name"] == "StartProject") + ); + assert!( + entries + .iter() + .any(|entry| entry["kind"] == "field" && entry["name"] == "_buildTicks") + ); + assert!( + entries + .iter() + .any(|entry| entry["kind"] == "property" && entry["name"] == "ProjectName") + ); + assert!(!entries.iter().any(|entry| { + entry["kind"] == "field" + && entry["name"] + .as_str() + .is_some_and(|name| name.contains("BackingField")) + })); +} + +#[test] +fn supports_type_names_from_powershell_pipeline() { + let binary = assert_cmd::cargo::cargo_bin("asmmember"); + let assembly = fixture_assembly(); + let script = format!( + "'Game.UI.Windows.Windows.SpaceCraftConstructionWindow' | & '{}' --assembly '{}' --input-format lines --match Project --json", + binary.display(), + assembly.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"StartProject\"")); +} diff --git a/crates/asmref/Cargo.toml b/crates/asmref/Cargo.toml new file mode 100644 index 0000000..893628c --- /dev/null +++ b/crates/asmref/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "asmref" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect managed assembly references with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +managed = { path = "../managed" } +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true diff --git a/crates/asmref/src/lib.rs b/crates/asmref/src/lib.rs new file mode 100644 index 0000000..76d9c9d --- /dev/null +++ b/crates/asmref/src/lib.rs @@ -0,0 +1,598 @@ +//! The `asmref` command inspects managed assembly references. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + read_existing_stdin_path_records, should_read_stdin, write_stdout, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use managed::{ + DependencyDiagnosisReport, DependencyReferenceDiagnostic, DiagnoseQuery, ReferenceQuery, + ResolutionStatus, diagnose_dependencies, inspect_references, +}; + +const HELP: &str = "\ +Inspect managed assembly references and simple resolution status. + +Usage: + asmref [OPTIONS] [ASSEMBLY...] + asmref diagnose [OPTIONS] [ASSEMBLY...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --resolve-dir Additional directory to search for references + -h, --help Show this help text + -V, --version Show the command version + +Examples: + asmref .\\fixtures\\managed\\bin\\GameAssembly.dll --resolve-dir .\\fixtures\\managed\\bin + asmref diagnose .\\Plugins\\Example.Plugin.dll --resolve-dir .\\Libraries --resolve-dir .\\Managed --format toon + 'C:\\game\\Managed\\Assembly-CSharp.dll' | asmref --input-format lines --resolve-dir C:\\game\\Managed + asmref .\\fixtures\\managed\\bin\\GameAssembly.dll --json | ConvertFrom-Json +"; + +const DIAGNOSE_HELP: &str = "\ +Diagnose managed assembly dependency closure resolution. + +Usage: + asmref diagnose [OPTIONS] [ASSEMBLY...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --resolve-dir Additional directory to search for dependencies + --test-only-pattern Extra case-insensitive test-only name/path pattern + --no-default-test-patterns Disable built-in test-only markers + -h, --help Show this help text + -V, --version Show the command version + +Examples: + asmref diagnose .\\Plugins\\Example.Plugin.dll --resolve-dir .\\Libraries --resolve-dir .\\Managed --format toon + asmref diagnose .\\RootPlugin.dll --test-only-pattern Project.Tests --json + asmref diagnose .\\RootPlugin.dll --no-default-test-patterns --toon +"; + +#[derive(Debug, Clone)] +struct Cli { + mode: CommandMode, + common: CommonArgs, + assembly_paths: Vec, + query: ReferenceQuery, + diagnose_query: DiagnoseQuery, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandMode { + Inspect, + Diagnose, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help(CommandMode), + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help(CommandMode::Inspect), _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Help(CommandMode::Diagnose), _)) => { + print!("{DIAGNOSE_HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("asmref {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, help_for_mode(cli.mode)); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +const fn help_for_mode(mode: CommandMode) -> &'static str { + match mode { + CommandMode::Inspect => HELP, + CommandMode::Diagnose => DIAGNOSE_HELP, + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + mode: CommandMode::Inspect, + common: CommonArgs::default(), + assembly_paths: Vec::new(), + query: ReferenceQuery::default(), + diagnose_query: DiagnoseQuery::default(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help(cli.mode), cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("resolve-dir") => cli.push_resolve_dir(PathBuf::from(parser_value_string( + &mut parser, + "--resolve-dir", + )?)), + Long("test-only-pattern") if cli.mode == CommandMode::Diagnose => cli + .diagnose_query + .test_only_patterns + .push(parser_value_string(&mut parser, "--test-only-pattern")?), + Long("no-default-test-patterns") if cli.mode == CommandMode::Diagnose => { + cli.diagnose_query.use_default_test_patterns = false; + } + ArgValue(value) + if cli.mode == CommandMode::Inspect + && cli.assembly_paths.is_empty() + && value.to_string_lossy() == "diagnose" => + { + cli.mode = CommandMode::Diagnose; + } + ArgValue(value) => cli.assembly_paths.push(PathBuf::from(value)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +impl Cli { + fn push_resolve_dir(&mut self, path: PathBuf) { + self.query.resolve_dirs.push(path.clone()); + self.diagnose_query.resolve_dirs.push(path); + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn run(cli: &Cli) -> Result { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one assembly path or pipe assembly paths into stdin", + )); + } + + match cli.mode { + CommandMode::Inspect => run_inspect(cli, &paths), + CommandMode::Diagnose => run_diagnose(cli, &paths), + } +} + +fn run_inspect(cli: &Cli, paths: &[PathBuf]) -> Result { + let mut reports = inspect_references(paths, &cli.query) + .map_err(|error| CliError::runtime(error.to_string()))?; + let exit_code = map_result_count(reports.len()); + + match cli.common.render_mode() { + RenderMode::Json => { + if reports.len() == 1 { + print_json(&reports.remove(0))?; + } else { + print_json(&reports)?; + } + } + RenderMode::Toon => { + if reports.len() == 1 { + print_structured(&reports.remove(0), RenderMode::Toon)?; + } else { + print_structured(&reports, RenderMode::Toon)?; + } + } + RenderMode::Text => { + for report in &reports { + if report.references.is_empty() { + println!( + "{} references=0 path={}", + report.assembly.assembly_name, + report.assembly.path.display() + ); + continue; + } + for reference in &report.references { + println!( + "{} name={} resolved={} path={}", + report.assembly.assembly_name, + reference.name, + reference.resolved, + reference + .resolved_path + .as_deref() + .map_or_else(|| "-".to_string(), |value| value.display().to_string()) + ); + } + } + } + } + + Ok(exit_code) +} + +fn run_diagnose(cli: &Cli, paths: &[PathBuf]) -> Result { + let report = diagnose_dependencies(paths, &cli.diagnose_query) + .map_err(|error| CliError::runtime(error.to_string()))?; + let exit_code = if report.summary.error_count == 0 { + ExitCode::Success + } else { + ExitCode::NoResults + }; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => write_stdout(&render_diagnose_text(&report))?, + } + + Ok(exit_code) +} + +fn render_diagnose_text(report: &DependencyDiagnosisReport) -> String { + let mut output = String::new(); + let summary = &report.summary; + let _ = writeln!( + &mut output, + "summary roots={} assemblies={} references={} resolved={} missing={} conflicts={} test_only={} errors={} warnings={} infos={}", + summary.root_count, + summary.assembly_count, + summary.reference_count, + summary.resolved_count, + summary.missing_count, + summary.conflict_count, + summary.test_only_count, + summary.error_count, + summary.warning_count, + summary.info_count + ); + + output.push_str("missing\n"); + for reference in report + .references + .iter() + .filter(|item| item.resolution_status == ResolutionStatus::Missing) + { + let _ = writeln!( + &mut output, + " {} -> {} requested={}", + reference.source_assembly, reference.reference_name, reference.requested_version + ); + } + + output.push_str("conflicts\n"); + for conflict in &report.conflicts { + let _ = writeln!( + &mut output, + " {} reason={} candidates={}", + conflict.reference_name, + conflict.reason, + conflict.candidates.len() + ); + } + + output.push_str("winners\n"); + for winner in &report.winners { + let _ = writeln!( + &mut output, + " {} requested={} winner={} version={} reason={:?}", + winner.reference_name, + winner.requested_version, + winner.winner.assembly.path.display(), + winner + .winner + .assembly + .assembly_version + .as_deref() + .unwrap_or("-"), + winner.reason + ); + } + + output.push_str("test_only\n"); + for candidate in &report.test_only { + let _ = writeln!( + &mut output, + " {} version={} path={}", + candidate.assembly.assembly_name, + candidate + .assembly + .assembly_version + .as_deref() + .unwrap_or("-"), + candidate.assembly.path.display() + ); + } + + output.push_str("risks\n"); + for risk in &report.risks { + let _ = writeln!( + &mut output, + " {} {} source={} reference={} path={} message={}", + risk.severity, + risk.kind, + risk.source_assembly.as_deref().unwrap_or("-"), + risk.reference_name.as_deref().unwrap_or("-"), + risk.path + .as_deref() + .map_or_else(|| "-".to_string(), |path| path.display().to_string()), + risk.message + ); + } + + output.push_str("notable_refs\n"); + for reference in &report.notable_refs { + output.push_str(&render_notable_reference(reference)); + } + + if !report.scan_warnings.is_empty() { + output.push_str("scan_warnings\n"); + for warning in &report.scan_warnings { + let _ = writeln!(&mut output, " {warning}"); + } + } + + output +} + +fn render_notable_reference(reference: &DependencyReferenceDiagnostic) -> String { + format!( + " {} -> {} requested={} status={:?} winner={}\n", + reference.source_assembly, + reference.reference_name, + reference.requested_version, + reference.resolution_status, + reference.winner.as_ref().map_or_else( + || "-".to_string(), + |candidate| candidate.assembly.path.display().to_string() + ) + ) +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin( + !cli.assembly_paths.is_empty(), + cli.common.stdin_is_terminal(), + ) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if let Some(parsed) = + read_existing_stdin_path_records(&buffer, cli.common.input_format, "asmref")? + { + if !parsed.is_empty() { + return Ok(parsed); + } + } + } + + common::expand_input_patterns(&cli.assembly_paths, "asmref") +} + +#[allow(dead_code)] +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + read_existing_stdin_path_records(buffer, input_format, "asmref")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use common::{ColorChoice, InputFormat}; + + use super::*; + + fn cli() -> Cli { + Cli { + mode: CommandMode::Inspect, + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: ColorChoice::Never, + quiet: false, + }, + assembly_paths: Vec::new(), + query: ReferenceQuery::default(), + diagnose_query: DiagnoseQuery::default(), + } + } + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") + } + + fn fixture_assembly() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + .join("GameAssembly.dll") + } + + fn fixture_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + } + + #[test] + fn parse_paths_reads_lines_and_jsonl_paths() { + let first = fixture_assembly(); + let second = workspace_root().join("Cargo.toml"); + assert_eq!( + parse_paths_from_string( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Lines + ) + .expect("line paths"), + vec![second, first.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("json path") + ), + InputFormat::Jsonl + ) + .expect("jsonl paths"), + vec![first] + ); + } + + #[test] + fn run_requires_at_least_one_path() { + let error = run(&cli()).expect_err("missing paths should fail"); + assert!(matches!( + error, + CliError::Usage(message) if message.contains("provide at least one assembly path") + )); + } + + #[test] + fn parse_cli_collects_paths_and_resolve_dirs() { + let (_, parsed) = parse_cli_from([ + "asmref", + "--resolve-dir", + "managed", + "--json", + "fixture.dll", + ]) + .expect("cli parse"); + + assert!(parsed.common.json); + assert_eq!(parsed.mode, CommandMode::Inspect); + assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]); + assert_eq!(parsed.query.resolve_dirs, vec![PathBuf::from("managed")]); + assert_eq!( + parsed.diagnose_query.resolve_dirs, + vec![PathBuf::from("managed")] + ); + } + + #[test] + fn parse_cli_collects_diagnose_options() { + let (_, parsed) = parse_cli_from([ + "asmref", + "diagnose", + "--resolve-dir", + "managed", + "--test-only-pattern", + "Project.Tests", + "--no-default-test-patterns", + "fixture.dll", + ]) + .expect("cli parse"); + + assert_eq!(parsed.mode, CommandMode::Diagnose); + assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]); + assert_eq!( + parsed.diagnose_query.resolve_dirs, + vec![PathBuf::from("managed")] + ); + assert_eq!( + parsed.diagnose_query.test_only_patterns, + vec!["Project.Tests"] + ); + assert!(!parsed.diagnose_query.use_default_test_patterns); + } + + #[test] + fn run_emits_success_for_fixture_reference_report() { + let exit_code = run(&Cli { + common: common_args(false, InputFormat::Auto), + assembly_paths: vec![fixture_assembly()], + mode: CommandMode::Inspect, + query: ReferenceQuery { + resolve_dirs: vec![fixture_dir()], + }, + diagnose_query: DiagnoseQuery::default(), + }) + .expect("fixture reference run"); + + assert_eq!(exit_code, ExitCode::Success); + } +} diff --git a/crates/asmref/src/main.rs b/crates/asmref/src/main.rs new file mode 100644 index 0000000..73c2a5d --- /dev/null +++ b/crates/asmref/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `asmref`. + +fn main() { + std::process::exit(asmref::main_entry()); +} diff --git a/crates/asmref/tests/asmref_cli.rs b/crates/asmref/tests/asmref_cli.rs new file mode 100644 index 0000000..b0cbc4e --- /dev/null +++ b/crates/asmref/tests/asmref_cli.rs @@ -0,0 +1,227 @@ +//! Integration tests for the `asmref` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("asmref").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn managed_fixture_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") +} + +fn fixture_assembly() -> PathBuf { + managed_fixture_dir().join("GameAssembly.dll") +} + +fn diagnose_fixture_root() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("diagnose-bin") + .join("root") + .join("RootPlugin.dll") +} + +fn diagnose_server_a_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("diagnose-bin") + .join("server-a") +} + +fn diagnose_server_b_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("diagnose-bin") + .join("server-b") +} + +fn framework_reference_free_assembly() -> PathBuf { + PathBuf::from(r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\mscorlib.dll") +} + +#[test] +fn help_includes_examples_and_resolve_dir_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--resolve-dir")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} + +#[test] +fn emits_reference_resolution_as_json() { + let fixture_dir = managed_fixture_dir(); + let mut command = cargo_command(); + let output = command + .arg(fixture_assembly()) + .arg("--resolve-dir") + .arg(&fixture_dir) + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + let payload = serde_json::from_slice::(&output).expect("json payload"); + let refs = payload["references"].as_array().expect("references array"); + assert!( + refs.iter().any(|entry| { + entry["name"] == "FixtureSupport" + && entry["resolved"] == true + && entry["resolved_path"] + .as_str() + .is_some_and(|value: &str| value.ends_with("FixtureSupport.dll")) + }), + "expected FixtureSupport reference to resolve" + ); +} + +#[test] +fn text_mode_reports_zero_reference_empty_state() { + let mut command = cargo_command(); + command + .arg(framework_reference_free_assembly()) + .assert() + .success() + .stdout(predicate::str::contains("references=0")); +} + +#[test] +fn supports_path_input_from_powershell_pipeline() { + let binary = assert_cmd::cargo::cargo_bin("asmref"); + let input = fixture_assembly(); + let fixture_dir = managed_fixture_dir(); + let script = format!( + "'{}' | & '{}' --input-format lines --resolve-dir '{}' --json", + input.display(), + binary.display(), + fixture_dir.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"FixtureSupport\"")); +} + +#[test] +fn diagnose_help_mentions_closure_options() { + let mut command = cargo_command(); + command + .args(["diagnose", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("asmref diagnose")) + .stdout(predicate::str::contains("--resolve-dir")) + .stdout(predicate::str::contains("--test-only-pattern")) + .stdout(predicate::str::contains("--no-default-test-patterns")) + .stdout(predicate::str::contains("--format ")) + .stdout(predicate::str::contains("--toon")); +} + +#[test] +fn diagnose_errors_show_diagnose_usage() { + let mut command = cargo_command(); + command + .arg("diagnose") + .assert() + .code(2) + .stderr(predicate::str::contains( + "asmref diagnose [OPTIONS] [ASSEMBLY...]", + )); +} + +#[test] +fn diagnose_reports_missing_conflicts_winners_and_risks() { + let mut command = cargo_command(); + let output = command + .arg("diagnose") + .arg(diagnose_fixture_root()) + .arg("--resolve-dir") + .arg(diagnose_server_a_dir()) + .arg("--resolve-dir") + .arg(diagnose_server_b_dir()) + .arg("--json") + .assert() + .code(1) + .get_output() + .stdout + .clone(); + let payload = serde_json::from_slice::(&output).expect("json payload"); + + assert_eq!(payload["summary"]["root_count"], 1); + assert!( + payload["summary"]["error_count"] + .as_u64() + .is_some_and(|count| count > 0) + ); + + let references = payload["references"].as_array().expect("references array"); + assert!(references.iter().any(|entry| { + entry["reference_name"] == "MissingOnly" && entry["resolution_status"] == "missing" + })); + + let candidates = payload["candidates"].as_array().expect("candidates array"); + assert!(candidates.iter().any(|entry| { + entry["assembly"]["assembly_name"] == "RuntimeDependency" + && entry["assembly"]["assembly_version"] == "2.0.0.0" + })); + + let winners = payload["winners"].as_array().expect("winners array"); + assert!(winners.iter().any(|entry| { + entry["reference_name"] == "0Harmony" + && entry["winner"]["assembly"]["assembly_version"] == "2.2.2.0" + })); + + let conflicts = payload["conflicts"].as_array().expect("conflicts array"); + assert!( + conflicts + .iter() + .any(|entry| entry["reference_name"] == "RuntimeDependency") + ); + + let test_only = payload["test_only"].as_array().expect("test_only array"); + assert!( + test_only + .iter() + .any(|entry| entry["assembly"]["assembly_name"] == "TestOnlySupport") + ); + + let risks = payload["risks"].as_array().expect("risks array"); + for expected in [ + "missing_reference", + "version_mismatch", + "test_only_dependency", + "missing_method", + "missing_type", + ] { + assert!( + risks.iter().any(|entry| entry["kind"] == expected), + "expected risk kind {expected} in {risks:#?}" + ); + } +} diff --git a/crates/asmtype/Cargo.toml b/crates/asmtype/Cargo.toml new file mode 100644 index 0000000..901c17c --- /dev/null +++ b/crates/asmtype/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "asmtype" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "List managed assembly types with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +managed = { path = "../managed" } +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/asmtype/src/lib.rs b/crates/asmtype/src/lib.rs new file mode 100644 index 0000000..ad789dc --- /dev/null +++ b/crates/asmtype/src/lib.rs @@ -0,0 +1,1078 @@ +//! The `asmtype` command lists managed assembly types. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_quick_help_error, print_structured, + read_existing_stdin_path_records, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use managed::{ + BindingFilter, MemberDescriptor, MemberQuery, TypeDescriptor, TypeQuery, list_members, + list_types, +}; +use regex_lite::{Regex, RegexBuilder}; +use serde_json::{Map, Value}; + +const HELP: &str = "\ +List managed assembly types with compact AI-friendly output. + +Usage: + asmtype [OPTIONS] [ASSEMBLY...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --match Filter by full type name regex + --namespace Filter by namespace regex + --kind Filter by type kind: class, struct, enum, interface, delegate + --public-only Only include public or nested public types + --base Filter by resolved base type regex + --interface Filter by resolved interface regex + --pick Project selected fields such as full_name,base_type + --with-member-match Keep only types whose members match the regex + --show-matched-members Include member names that satisfied --with-member-match + --user-code-only Ignore compiler-generated members when using --with-member-match + --intent Apply a curated workflow preset: unity-spacecraft-workflow + --limit Maximum number of rows to emit + -h, --help Show this help text + -V, --version Show the command version + +Examples: + asmtype .\\target\\managed-fixture\\GameAssembly.dll --match SpaceCraft + 'C:\\game\\Managed\\Assembly-CSharp.dll' | asmtype --input-format lines --match 'Rocket|Launch' + asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --with-member-match 'StartProject|QueueVehicle' + asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --base '^Game\\.ObjectInfoDataScripts\\.ProductionItem$' --pick full_name,base_type + asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --with-member-match 'Build|Launch' --show-matched-members --json | ConvertFrom-Json + asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --intent unity-spacecraft-workflow --json | ConvertFrom-Json + asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --json | ConvertFrom-Json + +Notes: + unity-spacecraft-workflow expands both type and member keywords and enables --user-code-only +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + assembly_paths: Vec, + query: TypeQuery, + pick: Vec, + with_member_match: Option, + show_matched_members: bool, + user_code_only: bool, + intent: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IntentPreset { + UnitySpacecraftWorkflow, +} + +#[derive(Debug, Clone)] +struct ExpandedTypeQuery { + query: TypeQuery, + with_member_match: Option, + user_code_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TypeOutputRow { + descriptor: TypeDescriptor, + matched_members: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("asmtype {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + assembly_paths: Vec::new(), + query: TypeQuery::default(), + pick: Vec::new(), + with_member_match: None, + show_matched_members: false, + user_code_only: false, + intent: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("match") => { + cli.query.match_pattern = Some(parse_regex( + "--match", + &parser_value_string(&mut parser, "--match")?, + )?); + } + Long("namespace") => { + cli.query.namespace_pattern = Some(parse_regex( + "--namespace", + &parser_value_string(&mut parser, "--namespace")?, + )?); + } + Long("kind") => { + cli.query.kind = Some(parser_value_string(&mut parser, "--kind")?); + } + Long("public-only") => cli.query.public_only = true, + Long("base") => { + cli.query.base_pattern = Some(parse_regex( + "--base", + &parser_value_string(&mut parser, "--base")?, + )?); + } + Long("interface") => { + cli.query.interface_pattern = Some(parse_regex( + "--interface", + &parser_value_string(&mut parser, "--interface")?, + )?); + } + Long("pick") => { + cli.pick.extend(split_csv_values(&parser_value_string( + &mut parser, + "--pick", + )?)); + } + Long("with-member-match") => { + cli.with_member_match = Some(parse_regex( + "--with-member-match", + &parser_value_string(&mut parser, "--with-member-match")?, + )?); + } + Long("show-matched-members") => cli.show_matched_members = true, + Long("user-code-only") => cli.user_code_only = true, + Long("intent") => { + cli.intent = Some(parse_intent(&parser_value_string( + &mut parser, + "--intent", + )?)?); + } + Long("limit") => { + cli.query.limit = Some(parse_positive_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + ArgValue(value) => cli.assembly_paths.push(PathBuf::from(value)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_regex(flag: &str, value: &str) -> Result { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(|error| CliError::usage(format!("invalid {flag} regex '{value}': {error}"))) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn split_csv_values(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() +} + +fn parse_intent(value: &str) -> Result { + match value { + "unity-spacecraft-workflow" => Ok(IntentPreset::UnitySpacecraftWorkflow), + other => Err(CliError::usage(format!( + "invalid --intent value '{other}'; expected unity-spacecraft-workflow" + ))), + } +} + +fn run(cli: &Cli) -> Result { + validate_pick_fields(&cli.pick)?; + let rows = collect_rows(cli)?; + let render_mode = cli.common.render_mode(); + + match render_mode { + RenderMode::Json | RenderMode::Toon => { + if cli.pick.is_empty() && !cli.show_matched_members { + let descriptors = rows + .iter() + .map(|row| row.descriptor.clone()) + .collect::>(); + print_structured(&descriptors, render_mode)?; + } else { + let payload = rows + .iter() + .map(|row| project_type_row(row, &cli.pick, cli.show_matched_members)) + .collect::, _>>()?; + print_structured(&payload, render_mode)?; + } + } + RenderMode::Text => { + if rows.is_empty() { + if !cli.common.quiet { + println!("0 matches"); + } + } else { + for row in &rows { + println!( + "{}", + render_type_row(row, &cli.pick, cli.show_matched_members) + ); + } + } + } + } + + if matches!(render_mode, RenderMode::Json | RenderMode::Toon) && rows.is_empty() { + Ok(ExitCode::Success) + } else { + Ok(map_result_count(rows.len())) + } +} + +fn collect_rows(cli: &Cli) -> Result, CliError> { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one assembly path or pipe assembly paths into stdin", + )); + } + + let expanded = apply_intent_defaults( + cli.query.clone(), + cli.with_member_match.clone(), + cli.user_code_only, + cli.intent, + )?; + let mut query = expanded.query.clone(); + let final_limit = query.limit; + if expanded.with_member_match.is_some() { + query.limit = None; + } + + let mut rows = + list_types(&paths, &query).map_err(|error| CliError::runtime(error.to_string()))?; + let mut matched_members = BTreeMap::<(PathBuf, String), Vec>::new(); + if let Some(member_pattern) = &expanded.with_member_match { + matched_members = collect_member_matches(&rows, member_pattern, expanded.user_code_only)?; + rows.retain(|row| { + matched_members.contains_key(&(row.assembly_path.clone(), row.full_name.clone())) + }); + } + if let Some(limit) = final_limit { + rows.truncate(limit); + } + + Ok(rows + .into_iter() + .map(|descriptor| TypeOutputRow { + matched_members: matched_members + .remove(&( + descriptor.assembly_path.clone(), + descriptor.full_name.clone(), + )) + .unwrap_or_default(), + descriptor, + }) + .collect()) +} + +fn apply_intent_defaults( + mut query: TypeQuery, + mut with_member_match: Option, + mut user_code_only: bool, + intent: Option, +) -> Result { + if let Some(intent) = intent { + let pattern = workflow_regex(intent)?; + if query.match_pattern.is_none() { + query.match_pattern = Some(pattern.clone()); + } + if with_member_match.is_none() { + with_member_match = Some(pattern); + } + user_code_only = true; + } + + Ok(ExpandedTypeQuery { + query, + with_member_match, + user_code_only, + }) +} + +fn workflow_regex(intent: IntentPreset) -> Result { + match intent { + IntentPreset::UnitySpacecraftWorkflow => parse_regex( + "--intent", + "space\\s*craft|spacecraft|project|build|construct|construction|launch|queue|vehicle|rocket|progress|mission|complete|finish", + ), + } +} + +fn collect_member_matches( + rows: &[TypeDescriptor], + pattern: &Regex, + user_code_only: bool, +) -> Result>, CliError> { + let mut by_assembly = BTreeMap::>::new(); + for row in rows { + by_assembly + .entry(row.assembly_path.clone()) + .or_default() + .insert(row.full_name.clone()); + } + + let mut matched_types = BTreeMap::<(PathBuf, String), Vec>::new(); + for (assembly_path, type_names) in by_assembly { + let members = list_members( + std::slice::from_ref(&assembly_path), + &MemberQuery { + type_names: type_names.into_iter().collect(), + kind: None, + match_pattern: Some(pattern.clone()), + binding: full_binding_filter(), + include_special: false, + user_code_only, + limit: None, + }, + ) + .map_err(|error| CliError::runtime(error.to_string()))?; + for member in members { + let entry = matched_types + .entry((assembly_path.clone(), member_type_name(&member).to_string())) + .or_default(); + entry.push(member_name(&member).to_string()); + } + } + + for names in matched_types.values_mut() { + names.sort(); + names.dedup(); + } + + Ok(matched_types) +} + +fn member_type_name(member: &MemberDescriptor) -> &str { + match member { + MemberDescriptor::Method { type_name, .. } + | MemberDescriptor::Field { type_name, .. } + | MemberDescriptor::Property { type_name, .. } => type_name, + } +} + +fn member_name(member: &MemberDescriptor) -> &str { + match member { + MemberDescriptor::Method { name, .. } + | MemberDescriptor::Field { name, .. } + | MemberDescriptor::Property { name, .. } => name, + } +} + +fn display_matched_members(names: &[String]) -> Vec { + let filtered = names + .iter() + .filter(|name| !is_backing_field_name(name)) + .cloned() + .collect::>(); + if filtered.is_empty() { + names.to_vec() + } else { + filtered + } +} + +fn is_backing_field_name(name: &str) -> bool { + name.contains("k__BackingField") +} + +const fn full_binding_filter() -> BindingFilter { + BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: true, + } +} + +fn validate_pick_fields(fields: &[String]) -> Result<(), CliError> { + for field in fields { + match field.as_str() { + "assembly_path" | "assembly_name" | "full_name" | "namespace" | "name" | "kind" + | "visibility" | "is_public" | "is_abstract" | "is_sealed" | "base_type" + | "interfaces" | "matched_members" => {} + other => { + return Err(CliError::usage(format!( + "invalid --pick field '{other}'; expected assembly_path, assembly_name, full_name, namespace, name, kind, visibility, is_public, is_abstract, is_sealed, base_type, interfaces, or matched_members" + ))); + } + } + } + + Ok(()) +} + +fn render_type_row(row: &TypeOutputRow, pick: &[String], show_matched_members: bool) -> String { + if pick.is_empty() { + let mut rendered = format!( + "{} kind={} visibility={} base={} assembly={}", + row.descriptor.full_name, + row.descriptor.kind, + row.descriptor.visibility, + row.descriptor.base_type.as_deref().unwrap_or("-"), + row.descriptor.assembly_name + ); + if show_matched_members && !row.matched_members.is_empty() { + let display_members = display_matched_members(&row.matched_members); + rendered.push_str(" matched_members="); + rendered.push_str(&display_members.join(",")); + } + return rendered; + } + + pick.iter() + .filter_map(|field| projected_field_text(row, field, show_matched_members)) + .map(std::borrow::Cow::into_owned) + .collect::>() + .join(" ") +} + +fn project_type_row( + row: &TypeOutputRow, + pick: &[String], + show_matched_members: bool, +) -> Result { + if pick.is_empty() { + let mut value = serde_json::to_value(&row.descriptor) + .map_err(|error| CliError::runtime(format!("failed to render json: {error}")))?; + if show_matched_members && !row.matched_members.is_empty() { + let display_members = display_matched_members(&row.matched_members); + let Value::Object(object) = &mut value else { + return Err(CliError::runtime( + "failed to render asmtype row as a JSON object", + )); + }; + object.insert( + "matched_members".to_string(), + Value::Array( + display_members + .iter() + .cloned() + .map(Value::String) + .collect::>(), + ), + ); + } + return Ok(value); + } + + let mut object = Map::new(); + for field in pick { + if let Some(value) = projected_field_value(row, field, show_matched_members) { + object.insert(field.clone(), value); + } + } + Ok(Value::Object(object)) +} + +fn projected_field_text<'a>( + row: &'a TypeOutputRow, + field: &str, + show_matched_members: bool, +) -> Option> { + match field { + "assembly_path" => Some(std::borrow::Cow::Owned(format!( + "assembly_path={}", + row.descriptor.assembly_path.display() + ))), + "assembly_name" => Some(std::borrow::Cow::Owned(format!( + "assembly_name={}", + row.descriptor.assembly_name + ))), + "full_name" => Some(std::borrow::Cow::Owned(format!( + "full_name={}", + row.descriptor.full_name + ))), + "namespace" => Some(std::borrow::Cow::Owned(format!( + "namespace={}", + row.descriptor.namespace.as_deref().unwrap_or("null") + ))), + "name" => Some(std::borrow::Cow::Owned(format!( + "name={}", + row.descriptor.name + ))), + "kind" => Some(std::borrow::Cow::Owned(format!( + "kind={}", + row.descriptor.kind + ))), + "visibility" => Some(std::borrow::Cow::Owned(format!( + "visibility={}", + row.descriptor.visibility + ))), + "is_public" => Some(std::borrow::Cow::Owned(format!( + "is_public={}", + row.descriptor.is_public + ))), + "is_abstract" => Some(std::borrow::Cow::Owned(format!( + "is_abstract={}", + row.descriptor.is_abstract + ))), + "is_sealed" => Some(std::borrow::Cow::Owned(format!( + "is_sealed={}", + row.descriptor.is_sealed + ))), + "base_type" => Some(std::borrow::Cow::Owned(format!( + "base_type={}", + row.descriptor.base_type.as_deref().unwrap_or("null") + ))), + "interfaces" => Some(std::borrow::Cow::Owned(format!( + "interfaces={}", + if row.descriptor.interfaces.is_empty() { + "-".to_string() + } else { + row.descriptor.interfaces.join(",") + } + ))), + "matched_members" if show_matched_members || !row.matched_members.is_empty() => { + let display_members = display_matched_members(&row.matched_members); + Some(std::borrow::Cow::Owned(format!( + "matched_members={}", + if display_members.is_empty() { + "-".to_string() + } else { + display_members.join(",") + } + ))) + } + "matched_members" => Some(std::borrow::Cow::Borrowed("matched_members=-")), + _ => None, + } +} + +fn projected_field_value( + row: &TypeOutputRow, + field: &str, + show_matched_members: bool, +) -> Option { + match field { + "assembly_path" => Some(Value::String( + row.descriptor.assembly_path.display().to_string(), + )), + "assembly_name" => Some(Value::String(row.descriptor.assembly_name.clone())), + "full_name" => Some(Value::String(row.descriptor.full_name.clone())), + "namespace" => Some( + row.descriptor + .namespace + .clone() + .map_or(Value::Null, Value::String), + ), + "name" => Some(Value::String(row.descriptor.name.clone())), + "kind" => Some(Value::String(row.descriptor.kind.clone())), + "visibility" => Some(Value::String(row.descriptor.visibility.clone())), + "is_public" => Some(Value::Bool(row.descriptor.is_public)), + "is_abstract" => Some(Value::Bool(row.descriptor.is_abstract)), + "is_sealed" => Some(Value::Bool(row.descriptor.is_sealed)), + "base_type" => Some( + row.descriptor + .base_type + .clone() + .map_or(Value::Null, Value::String), + ), + "interfaces" => Some(Value::Array( + row.descriptor + .interfaces + .iter() + .cloned() + .map(Value::String) + .collect::>(), + )), + "matched_members" if show_matched_members || !row.matched_members.is_empty() => { + let display_members = display_matched_members(&row.matched_members); + Some(Value::Array( + display_members + .iter() + .cloned() + .map(Value::String) + .collect::>(), + )) + } + "matched_members" => Some(Value::Array(Vec::new())), + _ => None, + } +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin( + !cli.assembly_paths.is_empty(), + cli.common.stdin_is_terminal(), + ) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if let Some(parsed) = + read_existing_stdin_path_records(&buffer, cli.common.input_format, "asmtype")? + { + if !parsed.is_empty() { + return Ok(parsed); + } + } + } + + common::expand_input_patterns(&cli.assembly_paths, "asmtype") +} + +#[allow(dead_code)] +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + read_existing_stdin_path_records(buffer, input_format, "asmtype")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use common::{ColorChoice, InputFormat}; + + use super::*; + + fn cli() -> Cli { + Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: ColorChoice::Never, + quiet: false, + }, + assembly_paths: Vec::new(), + query: TypeQuery::default(), + pick: Vec::new(), + with_member_match: None, + show_matched_members: false, + user_code_only: false, + intent: None, + } + } + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") + } + + fn fixture_assembly() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + .join("GameAssembly.dll") + } + + #[test] + fn parse_paths_reads_lines_and_jsonl_paths() { + let first = fixture_assembly(); + let second = workspace_root().join("Cargo.toml"); + assert_eq!( + parse_paths_from_string( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Lines + ) + .expect("line paths"), + vec![second, first.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("json path") + ), + InputFormat::Jsonl + ) + .expect("jsonl paths"), + vec![first] + ); + } + + #[test] + fn run_requires_at_least_one_path() { + let error = run(&cli()).expect_err("missing paths should fail"); + assert!(matches!( + error, + CliError::Usage(message) if message.contains("provide at least one assembly path") + )); + } + + #[test] + fn parse_cli_recognizes_filters_and_limit() { + let (_, parsed) = parse_cli_from([ + "asmtype", + "--match", + "spacecraft", + "--with-member-match", + "StartProject|QueueVehicle", + "--user-code-only", + "--intent", + "unity-spacecraft-workflow", + "--namespace", + "game\\.ui", + "--kind", + "class", + "--public-only", + "--limit", + "2", + "fixture.dll", + ]) + .expect("cli parse"); + + assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]); + assert_eq!(parsed.query.kind.as_deref(), Some("class")); + assert!(parsed.query.public_only); + assert_eq!(parsed.query.limit, Some(2)); + assert!( + parsed + .with_member_match + .as_ref() + .is_some_and(|pattern| pattern.is_match("StartProject")) + ); + assert!(parsed.user_code_only); + assert_eq!(parsed.intent, Some(IntentPreset::UnitySpacecraftWorkflow)); + assert!( + parsed + .query + .match_pattern + .as_ref() + .is_some_and(|pattern| pattern.is_match("SpaceCraftWindow")) + ); + } + + #[test] + fn parse_cli_rejects_zero_limit() { + let error = + parse_cli_from(["asmtype", "--limit", "0", "fixture.dll"]).expect_err("zero limit"); + assert!(error.to_string().contains("--limit must be greater than 0")); + } + + #[test] + fn run_emits_text_and_no_results_exit_codes() { + let success = run(&Cli { + common: common_args(false, InputFormat::Auto), + assembly_paths: vec![fixture_assembly()], + query: TypeQuery { + match_pattern: Some(parse_regex("--match", "spacecraft").expect("regex")), + ..TypeQuery::default() + }, + pick: Vec::new(), + with_member_match: None, + show_matched_members: false, + user_code_only: false, + intent: None, + }) + .expect("successful run"); + assert_eq!(success, ExitCode::Success); + + let no_results = run(&Cli { + common: common_args(false, InputFormat::Auto), + assembly_paths: vec![fixture_assembly()], + query: TypeQuery { + match_pattern: Some(parse_regex("--match", "^missing$").expect("regex")), + ..TypeQuery::default() + }, + pick: Vec::new(), + with_member_match: None, + show_matched_members: false, + user_code_only: false, + intent: None, + }) + .expect("no results run"); + assert_eq!(no_results, ExitCode::NoResults); + } + + #[test] + fn member_match_filter_surfaces_workflow_types() { + let rows = collect_rows(&Cli { + common: common_args(true, InputFormat::Auto), + assembly_paths: vec![fixture_assembly()], + query: TypeQuery::default(), + pick: Vec::new(), + with_member_match: Some( + parse_regex("--with-member-match", "StartProject|QueueVehicle") + .expect("member regex"), + ), + show_matched_members: true, + user_code_only: false, + intent: None, + }) + .expect("rows"); + + assert!(rows.iter().any(|row| { + row.descriptor.full_name == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow" + })); + assert!(rows.iter().any( + |row| row.descriptor.full_name == "Game.UI.Windows.Windows.SpaceCraftProjectWindow" + )); + assert!(rows.iter().any(|row| !row.matched_members.is_empty())); + } + + #[test] + fn intent_preset_provides_useful_default_patterns() { + let query = apply_intent_defaults( + TypeQuery::default(), + None, + false, + Some(IntentPreset::UnitySpacecraftWorkflow), + ) + .expect("intent"); + assert!( + query + .query + .match_pattern + .as_ref() + .is_some_and(|pattern| pattern.is_match("SpaceCraftConstructionWindow")) + ); + assert!( + query + .with_member_match + .as_ref() + .is_some_and(|pattern| pattern.is_match("StartProject")) + ); + assert!(query.user_code_only); + } + + #[test] + fn user_code_only_member_matching_can_drop_backing_field_only_hits() { + let rows = list_types( + &[fixture_assembly()], + &TypeQuery { + match_pattern: Some(parse_regex("--match", "spacecraft").expect("regex")), + ..TypeQuery::default() + }, + ) + .expect("types"); + let regex = parse_regex("--with-member-match", "k__BackingField").expect("regex"); + + let noisy = collect_member_matches(&rows, ®ex, false).expect("noisy rows"); + let filtered = collect_member_matches(&rows, ®ex, true).expect("filtered rows"); + + assert!(!noisy.is_empty()); + assert!(filtered.is_empty()); + } + + #[test] + fn display_matched_members_prefers_user_facing_hits_over_backing_fields() { + let mixed = display_matched_members(&[ + "k__BackingField".to_string(), + "StartProject".to_string(), + "k__BackingField".to_string(), + ]); + assert_eq!(mixed, vec!["StartProject".to_string()]); + + let backing_only = display_matched_members(&["k__BackingField".to_string()]); + assert_eq!( + backing_only, + vec!["k__BackingField".to_string()] + ); + } + + #[test] + fn parse_cli_recognizes_pick_and_show_flags() { + let (_, parsed) = parse_cli_from([ + "asmtype", + "--pick", + "full_name,base_type", + "--show-matched-members", + "fixture.dll", + ]) + .expect("cli parse"); + + assert_eq!( + parsed.pick, + vec!["full_name".to_string(), "base_type".to_string()] + ); + assert!(parsed.show_matched_members); + } + + #[test] + fn projection_helpers_render_requested_fields_and_validate_names() { + let row = TypeOutputRow { + descriptor: TypeDescriptor { + assembly_path: PathBuf::from("fixture.dll"), + assembly_name: "Fixture".to_string(), + full_name: "Demo.Type".to_string(), + namespace: Some("Demo".to_string()), + name: "Type".to_string(), + kind: "class".to_string(), + visibility: "public".to_string(), + is_public: true, + is_abstract: false, + is_sealed: false, + base_type: Some("System.Object".to_string()), + interfaces: vec!["Demo.Interface".to_string()], + }, + matched_members: vec!["Build".to_string(), "Launch".to_string()], + }; + + assert!(matches!( + validate_pick_fields(&["nope".to_string()]), + Err(CliError::Usage(message)) if message.contains("invalid --pick field") + )); + assert_eq!( + projected_field_value(&row, "matched_members", true), + Some(Value::Array(vec![ + Value::String("Build".to_string()), + Value::String("Launch".to_string()), + ])) + ); + assert!( + render_type_row( + &row, + &["full_name".to_string(), "matched_members".to_string()], + true + ) + .contains("matched_members=Build,Launch") + ); + assert!(matches!( + project_type_row(&row, &["base_type".to_string()], false), + Ok(Value::Object(object)) if object["base_type"] == "System.Object" + )); + } + + #[test] + fn matched_member_projection_hides_backing_fields_when_user_facing_hits_exist() { + let row = TypeOutputRow { + descriptor: TypeDescriptor { + assembly_path: PathBuf::from("fixture.dll"), + assembly_name: "Fixture".to_string(), + full_name: "Demo.Type".to_string(), + namespace: Some("Demo".to_string()), + name: "Type".to_string(), + kind: "class".to_string(), + visibility: "public".to_string(), + is_public: true, + is_abstract: false, + is_sealed: false, + base_type: Some("System.Object".to_string()), + interfaces: Vec::new(), + }, + matched_members: vec![ + "k__BackingField".to_string(), + "StartProject".to_string(), + ], + }; + + assert_eq!( + projected_field_value(&row, "matched_members", true), + Some(Value::Array(vec![Value::String( + "StartProject".to_string() + )])) + ); + assert!(render_type_row(&row, &[], true).contains("matched_members=StartProject")); + } +} diff --git a/crates/asmtype/src/main.rs b/crates/asmtype/src/main.rs new file mode 100644 index 0000000..a6dc4b0 --- /dev/null +++ b/crates/asmtype/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `asmtype`. + +fn main() { + std::process::exit(asmtype::main_entry()); +} diff --git a/crates/asmtype/tests/asmtype_cli.rs b/crates/asmtype/tests/asmtype_cli.rs new file mode 100644 index 0000000..146bf8f --- /dev/null +++ b/crates/asmtype/tests/asmtype_cli.rs @@ -0,0 +1,137 @@ +//! Integration tests for the `asmtype` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("asmtype").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn managed_fixture_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") +} + +fn fixture_assembly() -> PathBuf { + managed_fixture_dir().join("GameAssembly.dll") +} + +#[test] +fn help_includes_examples_and_pipeline_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout( + predicate::str::contains( + "asmtype .\\fixtures\\managed\\GameAssembly\\GameAssembly.csproj", + ) + .not(), + ) + .stdout(predicate::str::contains("asmtype .\\target\\")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("--match")) + .stdout(predicate::str::contains("--user-code-only")); +} + +#[test] +fn filters_types_as_json() { + let mut command = cargo_command(); + let output = command + .arg(fixture_assembly()) + .arg("--match") + .arg("SpaceCraft|Spacecraft") + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + let payload = serde_json::from_slice::(&output).expect("json payload"); + let entries = payload.as_array().expect("array payload"); + assert!(entries.len() >= 4, "expected at least 4 matching types"); + assert!( + entries + .iter() + .any(|entry| entry["full_name"] + == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow") + ); + assert!( + entries.iter().any(|entry| entry["kind"] == "struct" + && entry["full_name"] == "Data.SpacecraftConstructData") + ); +} + +#[test] +fn json_no_match_is_successful_empty_array_for_pipelines() { + let mut command = cargo_command(); + command + .arg(fixture_assembly()) + .arg("--match") + .arg("^DefinitelyMissingType$") + .arg("--json") + .assert() + .success() + .stdout(predicate::eq("[]\n")); +} + +#[test] +fn supports_powershell_pipeline_input() { + let binary = assert_cmd::cargo::cargo_bin("asmtype"); + let input = fixture_assembly(); + let script = format!( + "'{}' | & '{}' --input-format lines --match SpaceCraft --json", + input.display(), + binary.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains( + "\"full_name\":\"Game.UI.Windows.Windows.SpaceCraftConstructionWindow\"", + )); +} + +#[test] +fn show_matched_members_hides_backing_fields_when_user_facing_hits_exist() { + let mut command = cargo_command(); + let output = command + .arg(fixture_assembly()) + .arg("--with-member-match") + .arg("StartProject|QueueVehicle|k__BackingField") + .arg("--show-matched-members") + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + let payload = serde_json::from_slice::(&output).expect("json payload"); + let entries = payload.as_array().expect("array payload"); + let construction = entries + .iter() + .find(|entry| entry["full_name"] == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow") + .expect("construction row"); + assert_eq!( + construction["matched_members"], + Value::Array(vec![Value::String("StartProject".to_string())]) + ); +} diff --git a/crates/await/Cargo.toml b/crates/await/Cargo.toml new file mode 100644 index 0000000..5bd9606 --- /dev/null +++ b/crates/await/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "await" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Wait for paths, ports, HTTP endpoints, or repeated commands with bounded polling." +keywords.workspace = true +categories.workspace = true + +[lib] +name = "awaitcmd" + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +native-tls.workspace = true +runtimekit = { path = "../runtimekit" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/await/src/lib.rs b/crates/await/src/lib.rs new file mode 100644 index 0000000..71b3125 --- /dev/null +++ b/crates/await/src/lib.rs @@ -0,0 +1,1656 @@ +//! The `await` command polls readiness conditions until success or timeout. +#![allow( + clippy::multiple_crate_versions, + reason = "native-tls is already used elsewhere in the workspace and cargo deny remains the dependency audit gate" +)] + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::thread; +use std::time::{Duration, Instant}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, print_text, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use native_tls::{TlsConnector, TlsStream}; +use runtimekit::{ + ShellMode, collect_command_values, parse_duration_flag, parse_i32_flag, parse_shell_mode, + parse_usize_flag, parser_value_string, render_command, run_command_capture_with_shell, + tail_bytes_to_string, +}; +use serde::Serialize; + +const HELP: &str = "\ +Wait for paths, ports, HTTP endpoints, or repeated commands with bounded polling. + +Use `await` when you would otherwise write a hand-rolled while loop around a readiness check. + +Usage: + await [OPTIONS] path [--state exists|missing] + await [OPTIONS] port + await [OPTIONS] http [--status ] + await [OPTIONS] run [--shell ] [--cwd ] [--exit-code ] [--tail-bytes ] -- + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --timeout Total wait budget (default: 30s) + --interval Poll interval between attempts (default: 250ms) + -h, --help Show this help text + -V, --version Show the command version + +Subcommand options: + path --state exists|missing Required path state (default: exists) + http --status Require an exact HTTP status code; otherwise 2xx-3xx is ready + run --shell Launch mode for the command probe: raw, pwsh, cmd (default: raw) + run --cwd Working directory used when spawning the probe command + run --exit-code Ready exit code for run mode (default: 0) + run --tail-bytes Bytes of stdout/stderr to keep from the last run (default: 4096) + +Examples: + await path .\\target\\ready.flag + await path .\\target\\ready.flag --state missing + await port tcp://127.0.0.1:5000 --interval 200ms + await http http://127.0.0.1:8080/health --status 204 + await run --shell pwsh -- .\\scripts\\health.ps1 +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + timeout: Duration, + interval: Duration, + condition: Condition, +} + +#[derive(Debug, Clone)] +enum Condition { + Path { + path: PathBuf, + state: PathState, + }, + Port { + target: TcpTarget, + }, + Http { + target: HttpTarget, + expected_status: Option, + }, + Run { + shell: ShellMode, + cwd: Option, + expected_exit_code: i32, + tail_bytes: usize, + command: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum PathState { + Exists, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TcpTarget { + host: String, + port: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HttpTarget { + raw: String, + scheme: HttpScheme, + host: String, + port: u16, + path_and_query: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HttpScheme { + Http, + Https, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct WaitReport { + ok: bool, + duration_ms: u128, + attempts: usize, + condition: ConditionReport, + last_result: LastResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ConditionReport { + Path { + target: String, + state: PathState, + }, + Port { + target: String, + }, + Http { + target: String, + status: Option, + }, + Run { + shell: String, + command: String, + exit_code: i32, + cwd: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum LastResult { + Path { + ok: bool, + exists: bool, + }, + Port { + ok: bool, + target: String, + error: Option, + }, + Http { + ok: bool, + target: String, + status_code: Option, + error: Option, + }, + Run { + ok: bool, + exit_code: Option, + timed_out: bool, + stdout_tail: String, + stderr_tail: String, + }, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("await {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +#[allow( + clippy::too_many_lines, + reason = "manual CLI parsing keeps the command contract explicit and mirrors sibling crates" +)] +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut timeout = Duration::from_secs(30); + let mut interval = Duration::from_millis(250); + let mut condition = None::; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok((ParseOutcome::Help, empty_cli(common, timeout, interval))); + } + Long("version") | Short('V') => { + return Ok((ParseOutcome::Version, empty_cli(common, timeout, interval))); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("timeout") => { + timeout = parse_duration_flag( + "--timeout", + &parser_value_string(&mut parser, "--timeout")?, + )?; + } + Long("interval") => { + interval = parse_interval_flag(&parser_value_string(&mut parser, "--interval")?)?; + } + ArgValue(value) => { + let token = runtimekit::os_to_utf8(value, "subcommand")?; + condition = Some(parse_condition( + &token, + &mut parser, + &mut common, + &mut timeout, + &mut interval, + )?); + break; + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let condition = condition + .ok_or_else(|| CliError::usage("provide a subcommand: path, port, http, or run"))?; + Ok(( + ParseOutcome::Run, + Cli { + common, + timeout, + interval, + condition, + }, + )) +} + +fn empty_cli(common: CommonArgs, timeout: Duration, interval: Duration) -> Cli { + Cli { + common, + timeout, + interval, + condition: Condition::Path { + path: PathBuf::new(), + state: PathState::Exists, + }, + } +} + +fn parse_condition( + token: &str, + parser: &mut lexopt::Parser, + common: &mut CommonArgs, + timeout: &mut Duration, + interval: &mut Duration, +) -> Result { + match token { + "path" => parse_path_condition(parser, common, timeout, interval), + "port" => parse_port_condition(parser, common, timeout, interval), + "http" => parse_http_condition(parser, common, timeout, interval), + "run" => parse_run_condition(parser, common, timeout, interval), + _ => Err(CliError::usage( + "unknown subcommand; expected path, port, http, or run", + )), + } +} + +fn parse_path_condition( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, + timeout: &mut Duration, + interval: &mut Duration, +) -> Result { + let mut path = None::; + let mut state = PathState::Exists; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("timeout") => { + *timeout = + parse_duration_flag("--timeout", &parser_value_string(parser, "--timeout")?)?; + } + Long("interval") => { + *interval = parse_interval_flag(&parser_value_string(parser, "--interval")?)?; + } + Long("state") => { + state = parse_path_state(&parser_value_string(parser, "--state")?)?; + } + ArgValue(value) if path.is_none() => { + path = Some(PathBuf::from(value)); + } + _ => { + return Err(CliError::usage( + "unsupported path argument; use --help to see available options", + )); + } + } + } + let path = path.ok_or_else(|| CliError::usage("path mode requires a target path"))?; + Ok(Condition::Path { path, state }) +} + +fn parse_port_condition( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, + timeout: &mut Duration, + interval: &mut Duration, +) -> Result { + let mut target = None::; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("timeout") => { + *timeout = + parse_duration_flag("--timeout", &parser_value_string(parser, "--timeout")?)?; + } + Long("interval") => { + *interval = parse_interval_flag(&parser_value_string(parser, "--interval")?)?; + } + ArgValue(value) if target.is_none() => { + target = Some(parse_tcp_target(&runtimekit::os_to_utf8( + value, + "port target", + )?)?); + } + _ => { + return Err(CliError::usage( + "unsupported port argument; use --help to see available options", + )); + } + } + } + let target = + target.ok_or_else(|| CliError::usage("port mode requires a tcp://host:port target"))?; + Ok(Condition::Port { target }) +} + +fn parse_http_condition( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, + timeout: &mut Duration, + interval: &mut Duration, +) -> Result { + let mut target = None::; + let mut expected_status = None::; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("timeout") => { + *timeout = + parse_duration_flag("--timeout", &parser_value_string(parser, "--timeout")?)?; + } + Long("interval") => { + *interval = parse_interval_flag(&parser_value_string(parser, "--interval")?)?; + } + Long("status") => { + expected_status = Some(parse_u16_flag( + "--status", + &parser_value_string(parser, "--status")?, + )?); + } + ArgValue(value) if target.is_none() => { + target = Some(parse_http_target(&runtimekit::os_to_utf8( + value, + "http target", + )?)?); + } + _ => { + return Err(CliError::usage( + "unsupported http argument; use --help to see available options", + )); + } + } + } + let target = target + .ok_or_else(|| CliError::usage("http mode requires an http:// or https:// target"))?; + Ok(Condition::Http { + target, + expected_status, + }) +} + +fn parse_run_condition( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, + timeout: &mut Duration, + interval: &mut Duration, +) -> Result { + let mut shell = ShellMode::Raw; + let mut cwd = None::; + let mut expected_exit_code = 0_i32; + let mut tail_bytes = 4096_usize; + let mut command = None::>; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("timeout") => { + *timeout = + parse_duration_flag("--timeout", &parser_value_string(parser, "--timeout")?)?; + } + Long("interval") => { + *interval = parse_interval_flag(&parser_value_string(parser, "--interval")?)?; + } + Long("shell") => { + shell = parse_shell_mode("--shell", &parser_value_string(parser, "--shell")?)?; + } + Long("cwd") => { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + cwd = Some(PathBuf::from(value)); + } + Long("exit-code") => { + expected_exit_code = + parse_i32_flag("--exit-code", &parser_value_string(parser, "--exit-code")?)?; + } + Long("tail-bytes") => { + tail_bytes = parse_usize_flag( + "--tail-bytes", + &parser_value_string(parser, "--tail-bytes")?, + )?; + } + ArgValue(value) => { + command = Some(collect_command_values(parser, value)?); + break; + } + _ => { + return Err(CliError::usage( + "unsupported run argument; use --help to see available options", + )); + } + } + } + + let command = command.ok_or_else(|| CliError::usage("run mode requires a command after --"))?; + Ok(Condition::Run { + shell, + cwd, + expected_exit_code, + tail_bytes, + command, + }) +} + +fn parse_path_state(value: &str) -> Result { + match value { + "exists" => Ok(PathState::Exists), + "missing" => Ok(PathState::Missing), + other => Err(CliError::usage(format!( + "invalid --state value '{other}'; expected exists or missing" + ))), + } +} + +fn parse_interval_flag(value: &str) -> Result { + let interval = parse_duration_flag("--interval", value)?; + if interval.is_zero() { + return Err(CliError::usage("--interval must be greater than 0")); + } + Ok(interval) +} + +fn parse_u16_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_tcp_target(value: &str) -> Result { + let Some(authority) = value.strip_prefix("tcp://") else { + return Err(CliError::usage( + "invalid port target; expected tcp://host:port", + )); + }; + let (host, port) = parse_host_and_port( + authority, + None, + "invalid port target; expected tcp://host:port", + "invalid port target port", + )?; + Ok(TcpTarget { host, port }) +} + +fn parse_http_target(raw: &str) -> Result { + let (scheme_name, rest) = raw + .split_once("://") + .ok_or_else(|| CliError::usage("invalid http target: missing scheme"))?; + let (scheme, default_port) = match scheme_name { + "http" => (HttpScheme::Http, 80), + "https" => (HttpScheme::Https, 443), + other => { + return Err(CliError::usage(format!( + "invalid http target scheme '{other}'; expected http or https" + ))); + } + }; + let split_index = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..split_index]; + if authority.is_empty() { + return Err(CliError::usage( + "invalid http target: target must include a host", + )); + } + let (host, port) = parse_host_and_port( + authority, + Some(default_port), + "invalid http target: target must include a host", + "invalid http target port", + )?; + Ok(HttpTarget { + raw: raw.to_string(), + scheme, + host, + port, + path_and_query: normalize_path_and_query(&rest[split_index..]), + }) +} + +fn parse_host_and_port( + authority: &str, + default_port: Option, + missing_port_message: &str, + invalid_port_context: &str, +) -> Result<(String, u16), CliError> { + if let Some(bracketed) = authority.strip_prefix('[') { + let Some((host, remainder)) = bracketed.split_once(']') else { + return Err(CliError::usage(missing_port_message)); + }; + let port = if let Some(rest) = remainder.strip_prefix(':') { + parse_u16_flag(invalid_port_context, rest)? + } else { + default_port.ok_or_else(|| CliError::usage(missing_port_message))? + }; + return Ok((host.to_string(), port)); + } + + if let Some((host, port)) = authority.rsplit_once(':') + && !host.contains(':') + { + return Ok(( + host.to_string(), + parse_u16_flag(invalid_port_context, port)?, + )); + } + + let port = default_port.ok_or_else(|| CliError::usage(missing_port_message))?; + Ok((authority.to_string(), port)) +} + +fn normalize_path_and_query(remainder: &str) -> String { + let without_fragment = remainder.split('#').next().unwrap_or_default(); + if without_fragment.is_empty() { + "/".to_string() + } else if without_fragment.starts_with('?') { + format!("/{without_fragment}") + } else { + without_fragment.to_string() + } +} + +fn run(cli: &Cli) -> Result { + let started = Instant::now(); + let mut attempts = 0_usize; + let mut last_result = None::; + + loop { + let elapsed = started.elapsed(); + let remaining = cli.timeout.saturating_sub(elapsed); + if attempts > 0 && remaining.is_zero() { + let report = build_report( + cli, + attempts, + elapsed, + last_result.expect("last result should exist after at least one probe"), + ); + emit_report(cli, &report)?; + return Ok(ExitCode::NoResults); + } + + attempts += 1; + let current_result = probe_once(&cli.condition, cli.interval, remaining)?; + let success = last_result_ok(¤t_result); + last_result = Some(current_result); + if success { + let report = build_report( + cli, + attempts, + started.elapsed(), + last_result + .take() + .expect("last result should exist after a successful probe"), + ); + emit_report(cli, &report)?; + return Ok(ExitCode::Success); + } + if started.elapsed() >= cli.timeout { + let report = build_report( + cli, + attempts, + started.elapsed(), + last_result + .take() + .expect("last result should exist before timeout reporting"), + ); + emit_report(cli, &report)?; + return Ok(ExitCode::NoResults); + } + let sleep_for = cli + .interval + .min(cli.timeout.saturating_sub(started.elapsed())); + if !sleep_for.is_zero() { + thread::sleep(sleep_for); + } + } +} + +fn probe_once( + condition: &Condition, + interval: Duration, + remaining: Duration, +) -> Result { + match condition { + Condition::Path { path, state } => { + let exists = path.exists(); + Ok(LastResult::Path { + ok: matches!(state, PathState::Exists) == exists, + exists, + }) + } + Condition::Port { target } => Ok(probe_port(target, interval, remaining)), + Condition::Http { + target, + expected_status, + } => Ok(probe_http(target, *expected_status, interval, remaining)), + Condition::Run { + shell, + cwd, + expected_exit_code, + tail_bytes, + command, + } => { + let probe_timeout = Some(if remaining.is_zero() { + interval + } else { + remaining + }); + let captured = + run_command_capture_with_shell(command, *shell, cwd.as_deref(), probe_timeout)?; + let ok = !captured.timed_out && captured.exit_code == Some(*expected_exit_code); + Ok(LastResult::Run { + ok, + exit_code: captured.exit_code, + timed_out: captured.timed_out, + stdout_tail: tail_bytes_to_string(&captured.stdout, *tail_bytes), + stderr_tail: tail_bytes_to_string(&captured.stderr, *tail_bytes), + }) + } + } +} + +fn probe_port(target: &TcpTarget, interval: Duration, remaining: Duration) -> LastResult { + let timeout = bounded_probe_timeout(remaining, interval, Duration::from_secs(1)); + let resolve = (target.host.as_str(), target.port).to_socket_addrs(); + let mut last_error = None::; + let addresses = match resolve { + Ok(addresses) => addresses.collect::>(), + Err(error) => { + return LastResult::Port { + ok: false, + target: format!("tcp://{}:{}", target.host, target.port), + error: Some(format!("tcp resolve failed: {error}")), + }; + } + }; + for address in addresses { + match TcpStream::connect_timeout(&address, timeout) { + Ok(stream) => { + drop(stream); + return LastResult::Port { + ok: true, + target: format!("tcp://{}:{}", target.host, target.port), + error: None, + }; + } + Err(error) => last_error = Some(error.to_string()), + } + } + LastResult::Port { + ok: false, + target: format!("tcp://{}:{}", target.host, target.port), + error: last_error, + } +} + +fn probe_http( + target: &HttpTarget, + expected_status: Option, + interval: Duration, + remaining: Duration, +) -> LastResult { + let timeout = bounded_probe_timeout(remaining, interval, Duration::from_secs(2)); + match connect_socket(&target.host, target.port, timeout) { + Ok(stream) => match build_transport(stream, target.scheme, &target.host) { + Ok(mut transport) => { + let request = format!( + "HEAD {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept: */*\r\n\r\n", + target.path_and_query, target.host + ); + if let Err(error) = transport.write_all(request.as_bytes()) { + return LastResult::Http { + ok: false, + target: target.raw.clone(), + status_code: None, + error: Some(format!("failed to write request: {error}")), + }; + } + if let Err(error) = transport.flush() { + return LastResult::Http { + ok: false, + target: target.raw.clone(), + status_code: None, + error: Some(format!("failed to flush request: {error}")), + }; + } + let mut response = [0_u8; 1024]; + let size = match transport.read(&mut response) { + Ok(size) => size, + Err(error) => { + return LastResult::Http { + ok: false, + target: target.raw.clone(), + status_code: None, + error: Some(format!("failed to read response: {error}")), + }; + } + }; + let status_code = parse_status_code(&response[..size]); + let (ok, error) = evaluate_http_status(status_code, expected_status); + LastResult::Http { + ok, + target: target.raw.clone(), + status_code, + error, + } + } + Err(error) => LastResult::Http { + ok: false, + target: target.raw.clone(), + status_code: None, + error: Some(error), + }, + }, + Err(error) => LastResult::Http { + ok: false, + target: target.raw.clone(), + status_code: None, + error: Some(format!("connection failed: {error}")), + }, + } +} + +fn bounded_probe_timeout(remaining: Duration, interval: Duration, cap: Duration) -> Duration { + let base = if remaining.is_zero() { + interval + } else { + remaining + }; + let bounded = base.min(interval).min(cap); + if bounded.is_zero() { + Duration::from_millis(1) + } else { + bounded + } +} + +fn connect_socket(host: &str, port: u16, timeout: Duration) -> Result { + let mut addresses = (host, port).to_socket_addrs()?; + let Some(address) = addresses.next() else { + return Err(std::io::Error::new( + std::io::ErrorKind::AddrNotAvailable, + "target resolved to no addresses", + )); + }; + let stream = TcpStream::connect_timeout(&address, timeout)?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + Ok(stream) +} + +fn build_transport(stream: TcpStream, scheme: HttpScheme, host: &str) -> Result { + match scheme { + HttpScheme::Http => Ok(Transport::Plain(stream)), + HttpScheme::Https => { + let connector = TlsConnector::new() + .map_err(|error| format!("failed to create TLS connector: {error}"))?; + let tls_stream = connector + .connect(host, stream) + .map_err(|error| format!("TLS handshake failed: {error}"))?; + Ok(Transport::Tls(Box::new(tls_stream))) + } + } +} + +fn parse_status_code(response: &[u8]) -> Option { + let response = String::from_utf8_lossy(response); + let status_line = response.lines().next()?; + status_line + .split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) +} + +fn evaluate_http_status( + status_code: Option, + expected_status: Option, +) -> (bool, Option) { + match (status_code, expected_status) { + (Some(actual), Some(expected)) if actual == expected => (true, None), + (Some(actual), Some(expected)) => ( + false, + Some(format!("expected status {expected}, got {actual}")), + ), + (None, Some(expected)) => ( + false, + Some(format!( + "expected status {expected}, got no HTTP status line" + )), + ), + (Some(actual), None) if (200..400).contains(&actual) => (true, None), + (Some(actual), None) => (false, Some(format!("unexpected HTTP status {actual}"))), + (None, None) => (false, Some("missing HTTP status line".to_string())), + } +} + +fn build_report( + cli: &Cli, + attempts: usize, + elapsed: Duration, + last_result: LastResult, +) -> WaitReport { + let condition = condition_report(&cli.condition); + let ok = last_result_ok(&last_result); + WaitReport { + ok, + duration_ms: elapsed.as_millis(), + attempts, + condition, + last_result, + } +} + +fn emit_report(cli: &Cli, report: &WaitReport) -> Result<(), CliError> { + match cli.common.render_mode() { + RenderMode::Json => print_json(report), + RenderMode::Toon => print_structured(report, RenderMode::Toon), + RenderMode::Text => print_text(render_report_text(report)), + } +} + +fn condition_report(condition: &Condition) -> ConditionReport { + match condition { + Condition::Path { path, state } => ConditionReport::Path { + target: path.display().to_string(), + state: *state, + }, + Condition::Port { target } => ConditionReport::Port { + target: format!("tcp://{}:{}", target.host, target.port), + }, + Condition::Http { + target, + expected_status, + } => ConditionReport::Http { + target: target.raw.clone(), + status: *expected_status, + }, + Condition::Run { + shell, + cwd, + expected_exit_code, + tail_bytes: _, + command, + } => ConditionReport::Run { + shell: shell_label(*shell).to_string(), + command: render_command(command), + exit_code: *expected_exit_code, + cwd: cwd.as_ref().map(|path| path.display().to_string()), + }, + } +} + +const fn last_result_ok(result: &LastResult) -> bool { + match result { + LastResult::Path { ok, .. } + | LastResult::Port { ok, .. } + | LastResult::Http { ok, .. } + | LastResult::Run { ok, .. } => *ok, + } +} + +fn render_report_text(report: &WaitReport) -> String { + let mut text = format!( + "{} condition={} attempts={} duration_ms={} last={}", + if report.ok { "ok" } else { "timeout" }, + render_condition_text(&report.condition), + report.attempts, + report.duration_ms, + render_last_result_text(&report.last_result) + ); + if let LastResult::Run { + ok: false, + stdout_tail, + stderr_tail, + .. + } = &report.last_result + { + if !stderr_tail.is_empty() { + text.push_str("\nstderr_tail:\n"); + text.push_str(stderr_tail); + } + if !stdout_tail.is_empty() { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str("stdout_tail:\n"); + text.push_str(stdout_tail); + } + } + text +} + +fn render_condition_text(condition: &ConditionReport) -> String { + match condition { + ConditionReport::Path { target, state } => { + format!("path:{}:{}", path_state_label(*state), target) + } + ConditionReport::Port { target } => format!("port:{target}"), + ConditionReport::Http { target, status } => status.map_or_else( + || format!("http:{target}"), + |code| format!("http:{target}:status={code}"), + ), + ConditionReport::Run { + shell, + command, + exit_code, + cwd, + } => { + let mut text = format!("run:{shell}:exit={exit_code}:{command}"); + if let Some(cwd) = cwd { + write!(text, ":cwd={cwd}").expect("writing to a String cannot fail"); + } + text + } + } +} + +fn render_last_result_text(result: &LastResult) -> String { + match result { + LastResult::Path { ok, exists } => format!("path:ok={ok}:exists={exists}"), + LastResult::Port { ok, target, error } => error.as_ref().map_or_else( + || format!("port:ok={ok}:target={target}"), + |error| format!("port:ok={ok}:target={target}:error={}", quote_text(error)), + ), + LastResult::Http { + ok, + target, + status_code, + error, + } => { + let mut text = format!("http:ok={ok}:target={target}"); + if let Some(status_code) = status_code { + write!(text, ":status_code={status_code}") + .expect("writing to a String cannot fail"); + } + if let Some(error) = error { + write!(text, ":error={}", quote_text(error)) + .expect("writing to a String cannot fail"); + } + text + } + LastResult::Run { + ok, + exit_code, + timed_out, + .. + } => format!( + "run:ok={ok}:exit_code={}:timed_out={timed_out}", + exit_code.map_or_else(|| "none".to_string(), |code| code.to_string()) + ), + } +} + +fn quote_text(value: &str) -> String { + if value.chars().any(char::is_whitespace) { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value.to_string() + } +} + +const fn shell_label(shell: ShellMode) -> &'static str { + match shell { + ShellMode::Raw => "raw", + ShellMode::Pwsh => "pwsh", + ShellMode::Cmd => "cmd", + } +} + +const fn path_state_label(state: PathState) -> &'static str { + match state { + PathState::Exists => "exists", + PathState::Missing => "missing", + } +} + +#[derive(Debug)] +enum Transport { + Plain(TcpStream), + Tls(Box>), +} + +impl Read for Transport { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Plain(stream) => stream.read(buf), + Self::Tls(stream) => stream.read(buf), + } + } +} + +impl Write for Transport { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Self::Plain(stream) => stream.write(buf), + Self::Tls(stream) => stream.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Plain(stream) => stream.flush(), + Self::Tls(stream) => stream.flush(), + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + use std::time::Duration; + + use super::*; + use common::CommonArgs; + + fn command(items: &[&str]) -> Vec { + items.iter().map(OsString::from).collect() + } + + fn spawn_http_server(response: &'static [u8]) -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener"); + let port = listener.local_addr().expect("address").port(); + thread::spawn(move || { + for _ in 0..3 { + let Ok((mut stream, _)) = listener.accept() else { + continue; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + if stream.write_all(response).is_ok() { + break; + } + } + }); + port + } + + #[test] + fn parse_targets_accept_v3_contracts() { + assert_eq!( + parse_tcp_target("tcp://127.0.0.1:80").expect("target"), + TcpTarget { + host: "127.0.0.1".to_string(), + port: 80, + } + ); + assert!(matches!( + parse_tcp_target("127.0.0.1:80"), + Err(CliError::Usage(message)) if message.contains("tcp://host:port") + )); + let http = parse_http_target("http://127.0.0.1").expect("http"); + assert_eq!(http.path_and_query, "/"); + assert_eq!(parse_status_code(b"HTTP/1.1 204 No Content\r\n"), Some(204)); + } + + #[test] + fn parse_cli_rejects_invalid_interval_and_missing_subcommand() { + assert!(matches!( + parse_cli_from(["await", "--interval", "0ms", "path", "."]), + Err(CliError::Usage(message)) if message.contains("--interval must be greater than 0") + )); + assert!(matches!( + parse_cli_from(["await", "path", ".", "--interval", "0ms"]), + Err(CliError::Usage(message)) if message.contains("--interval must be greater than 0") + )); + assert!(matches!( + parse_cli_from(["await", "--timeout", "1s"]), + Err(CliError::Usage(message)) if message.contains("provide a subcommand") + )); + } + + #[test] + fn parse_cli_accepts_run_mode_and_common_flags() { + let (_, cli) = parse_cli_from([ + "await", + "--json", + "--timeout", + "1s", + "--interval", + "100ms", + "run", + "--shell", + "cmd", + "--exit-code", + "7", + "--tail-bytes", + "32", + "--", + "cmd", + "/d", + "/s", + "/c", + "exit 7", + ]) + .expect("run mode"); + assert!(cli.common.json); + assert_eq!(cli.timeout, Duration::from_secs(1)); + assert_eq!(cli.interval, Duration::from_millis(100)); + assert!(matches!( + cli.condition, + Condition::Run { + shell: ShellMode::Cmd, + expected_exit_code: 7, + tail_bytes: 32, + .. + } + )); + } + + #[test] + fn parse_cli_accepts_common_flags_after_subcommand() { + let (_, cli) = parse_cli_from([ + "await", + "path", + "ready.flag", + "--json", + "--timeout", + "2s", + "--interval", + "10ms", + "--state", + "exists", + ]) + .expect("path mode"); + assert!(cli.common.json); + assert_eq!(cli.timeout, Duration::from_secs(2)); + assert_eq!(cli.interval, Duration::from_millis(10)); + assert!(matches!( + cli.condition, + Condition::Path { + state: PathState::Exists, + .. + } + )); + } + + #[test] + fn render_report_text_is_compact() { + let report = WaitReport { + ok: false, + duration_ms: 500, + attempts: 3, + condition: ConditionReport::Run { + shell: "cmd".to_string(), + command: "cmd /c exit 1".to_string(), + exit_code: 0, + cwd: None, + }, + last_result: LastResult::Run { + ok: false, + exit_code: Some(1), + timed_out: false, + stdout_tail: "out".to_string(), + stderr_tail: "boom".to_string(), + }, + }; + let text = render_report_text(&report); + assert!(text.contains("timeout condition=run:cmd:exit=0:cmd /c exit 1")); + assert!(text.contains("last=run:ok=false:exit_code=1:timed_out=false")); + assert!(text.contains("stderr_tail:")); + assert!(text.contains("stdout_tail:")); + } + + #[test] + fn condition_report_and_result_mapping_are_stable() { + let condition = condition_report(&Condition::Path { + path: PathBuf::from("ready.flag"), + state: PathState::Missing, + }); + assert!(matches!( + condition, + ConditionReport::Path { + state: PathState::Missing, + .. + } + )); + let result = LastResult::Path { + ok: true, + exists: false, + }; + assert!(last_result_ok(&result)); + assert_eq!( + render_command(&command(&["pwsh", "-NoProfile"])), + "pwsh -NoProfile" + ); + } + + #[test] + fn parse_targets_cover_https_ipv6_and_invalid_inputs() { + let https = parse_http_target("https://example.com?ready=1#frag").expect("https"); + assert_eq!(https.scheme, HttpScheme::Https); + assert_eq!(https.host, "example.com"); + assert_eq!(https.port, 443); + assert_eq!(https.path_and_query, "/?ready=1"); + + let ipv6 = parse_http_target("http://[::1]/health").expect("ipv6"); + assert_eq!(ipv6.host, "::1"); + assert_eq!(ipv6.port, 80); + assert_eq!(ipv6.path_and_query, "/health"); + + let tcp = parse_tcp_target("tcp://[::1]:8080").expect("tcp"); + assert_eq!(tcp.host, "::1"); + assert_eq!(tcp.port, 8080); + + assert!(matches!( + parse_http_target("ftp://example.com"), + Err(CliError::Usage(message)) if message.contains("expected http or https") + )); + assert!(matches!( + parse_http_target("http://"), + Err(CliError::Usage(message)) if message.contains("must include a host") + )); + } + + #[test] + fn parse_cli_rejects_invalid_subcommand_specific_arguments() { + assert!(matches!( + parse_cli_from(["await", "path", "ready.flag", "--state", "ready"]), + Err(CliError::Usage(message)) if message.contains("expected exists or missing") + )); + assert!(matches!( + parse_cli_from(["await", "port", "tcp://127.0.0.1:80", "--status", "204"]), + Err(CliError::Usage(message)) if message.contains("unsupported port argument") + )); + assert!(matches!( + parse_cli_from(["await", "http", "http://127.0.0.1", "--bogus"]), + Err(CliError::Usage(message)) if message.contains("unsupported http argument") + )); + assert!(matches!( + parse_cli_from(["await", "run", "--cwd", "."]), + Err(CliError::Usage(message)) if message.contains("requires a command after --") + )); + } + + #[test] + fn http_status_helpers_cover_missing_and_mismatched_status_lines() { + assert_eq!(parse_status_code(b"not-http"), None); + + assert_eq!(evaluate_http_status(Some(302), None), (true, None)); + assert_eq!( + evaluate_http_status(Some(503), None), + (false, Some("unexpected HTTP status 503".to_string())) + ); + assert_eq!(evaluate_http_status(Some(204), Some(204)), (true, None)); + assert_eq!( + evaluate_http_status(Some(503), Some(204)), + (false, Some("expected status 204, got 503".to_string())) + ); + assert_eq!( + evaluate_http_status(None, Some(204)), + ( + false, + Some("expected status 204, got no HTTP status line".to_string()) + ) + ); + assert_eq!( + evaluate_http_status(None, None), + (false, Some("missing HTTP status line".to_string())) + ); + } + + #[test] + fn probe_helpers_cover_ready_and_error_variants() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener"); + let port_target = TcpTarget { + host: "127.0.0.1".to_string(), + port: listener.local_addr().expect("address").port(), + }; + let port_ready = probe_port( + &port_target, + Duration::from_millis(50), + Duration::from_millis(50), + ); + assert!(matches!( + port_ready, + LastResult::Port { + ok: true, + error: None, + .. + } + )); + drop(listener); + + let port_error = probe_port( + &TcpTarget { + host: "bad host".to_string(), + port: 80, + }, + Duration::from_millis(50), + Duration::from_millis(50), + ); + assert!(matches!( + port_error, + LastResult::Port { + ok: false, + error: Some(error), + .. + } if error.contains("tcp resolve failed") + )); + + let ok_port = spawn_http_server(b"HTTP/1.1 302 Found\r\nContent-Length: 0\r\n\r\n"); + let http_ready = probe_http( + &parse_http_target(&format!("http://127.0.0.1:{ok_port}/ready")).expect("target"), + None, + Duration::from_millis(500), + Duration::from_millis(500), + ); + assert!(matches!( + http_ready, + LastResult::Http { + ok: true, + status_code: Some(302), + error: None, + .. + } + )); + + let mismatch_port = + spawn_http_server(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"); + let http_mismatch = probe_http( + &parse_http_target(&format!("http://127.0.0.1:{mismatch_port}/ready")).expect("target"), + Some(204), + Duration::from_millis(500), + Duration::from_millis(500), + ); + assert!(matches!( + http_mismatch, + LastResult::Http { + ok: false, + status_code: Some(503), + error: Some(error), + .. + } if error.contains("expected status 204, got 503") + )); + + let invalid_port = spawn_http_server(b"not-http"); + let http_invalid = probe_http( + &parse_http_target(&format!("http://127.0.0.1:{invalid_port}/ready")).expect("target"), + None, + Duration::from_millis(500), + Duration::from_millis(500), + ); + assert!(matches!( + http_invalid, + LastResult::Http { + ok: false, + status_code: None, + error: Some(error), + .. + } if error.contains("missing HTTP status line") + )); + } + + #[test] + fn render_helpers_quote_errors_and_keep_non_run_reports_compact() { + assert_eq!(quote_text("two words"), "\"two words\""); + assert_eq!(quote_text("plain"), "plain"); + + let port_text = render_last_result_text(&LastResult::Port { + ok: false, + target: "tcp://127.0.0.1:9".to_string(), + error: Some("connection refused".to_string()), + }); + assert!(port_text.contains("error=\"connection refused\"")); + + let http_text = render_last_result_text(&LastResult::Http { + ok: false, + target: "http://example.com".to_string(), + status_code: Some(500), + error: Some("server busy".to_string()), + }); + assert!(http_text.contains(":status_code=500")); + assert!(http_text.contains("error=\"server busy\"")); + + let report = WaitReport { + ok: false, + duration_ms: 125, + attempts: 2, + condition: ConditionReport::Path { + target: "ready.flag".to_string(), + state: PathState::Missing, + }, + last_result: LastResult::Path { + ok: false, + exists: true, + }, + }; + let text = render_report_text(&report); + assert!(text.contains("timeout condition=path:missing:ready.flag")); + assert!(text.contains("last=path:ok=false:exists=true")); + assert!(!text.contains("stdout_tail:")); + assert!(!text.contains("stderr_tail:")); + } + + #[test] + fn build_report_and_emit_report_cover_run_condition_variants() { + let cli = Cli { + common: CommonArgs::default(), + timeout: Duration::from_secs(1), + interval: Duration::from_millis(50), + condition: Condition::Run { + shell: ShellMode::Pwsh, + cwd: Some(PathBuf::from("work")), + expected_exit_code: 7, + tail_bytes: 32, + command: command(&["tool.exe", "two words"]), + }, + }; + let report = build_report( + &cli, + 2, + Duration::from_millis(125), + LastResult::Run { + ok: false, + exit_code: None, + timed_out: true, + stdout_tail: String::new(), + stderr_tail: "boom".to_string(), + }, + ); + assert!(!report.ok); + assert_eq!(shell_label(ShellMode::Raw), "raw"); + assert_eq!(shell_label(ShellMode::Pwsh), "pwsh"); + assert_eq!(shell_label(ShellMode::Cmd), "cmd"); + assert!(render_condition_text(&report.condition).contains("run:pwsh:exit=7:tool.exe")); + assert!(render_condition_text(&report.condition).contains(":cwd=work")); + assert_eq!( + render_last_result_text(&report.last_result), + "run:ok=false:exit_code=none:timed_out=true" + ); + assert!(emit_report(&cli, &report).is_ok()); + + let json_cli = Cli { + common: CommonArgs { + json: true, + format: None, + ..CommonArgs::default() + }, + ..cli + }; + assert!(emit_report(&json_cli, &report).is_ok()); + } + + #[test] + fn probe_once_run_condition_captures_stdout_and_expected_exit_code() { + let result = probe_once( + &Condition::Run { + shell: ShellMode::Pwsh, + cwd: None, + expected_exit_code: 0, + tail_bytes: 64, + command: command(&[ + "pwsh", + "-NoProfile", + "-Command", + "Write-Output 'ready'; exit 0", + ]), + }, + Duration::from_secs(5), + Duration::from_secs(15), + ) + .expect("probe"); + + assert!(matches!( + result, + LastResult::Run { + ok: true, + exit_code: Some(0), + timed_out: false, + stdout_tail, + stderr_tail, + } if stdout_tail.contains("ready") && stderr_tail.is_empty() + )); + } + + #[test] + fn run_returns_success_and_no_results_for_path_conditions() { + let temp = tempfile::tempdir().expect("tempdir"); + let present = temp.path().join("present.flag"); + std::fs::write(&present, "ready").expect("present"); + + let success = run(&Cli { + common: CommonArgs { + quiet: true, + ..CommonArgs::default() + }, + timeout: Duration::from_millis(50), + interval: Duration::from_millis(10), + condition: Condition::Path { + path: present, + state: PathState::Exists, + }, + }) + .expect("success"); + assert_eq!(success, ExitCode::Success); + + let timeout = run(&Cli { + common: CommonArgs { + quiet: true, + ..CommonArgs::default() + }, + timeout: Duration::from_millis(40), + interval: Duration::from_millis(10), + condition: Condition::Path { + path: temp.path().join("missing.flag"), + state: PathState::Exists, + }, + }) + .expect("timeout"); + assert_eq!(timeout, ExitCode::NoResults); + } +} diff --git a/crates/await/src/main.rs b/crates/await/src/main.rs new file mode 100644 index 0000000..8b7511c --- /dev/null +++ b/crates/await/src/main.rs @@ -0,0 +1,9 @@ +//! Binary entry point for `await`. +#![allow( + clippy::multiple_crate_versions, + reason = "native-tls is already used elsewhere in the workspace and cargo deny remains the dependency audit gate" +)] + +fn main() { + std::process::exit(awaitcmd::main_entry()); +} diff --git a/crates/await/tests/await_cli.rs b/crates/await/tests/await_cli.rs new file mode 100644 index 0000000..c75c4d1 --- /dev/null +++ b/crates/await/tests/await_cli.rs @@ -0,0 +1,175 @@ +//! Integration tests for the `await` command. + +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn cargo_command() -> Command { + Command::cargo_bin("await").expect("binary") +} + +#[test] +fn no_args_prints_quick_help_card() { + let mut command = cargo_command(); + command + .assert() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "error: provide a subcommand: path, port, http, or run", + )) + .stderr(predicate::str::contains("await - Mercury Toolbox")) + .stderr(predicate::str::contains("Usage:")) + .stderr(predicate::str::contains("await [OPTIONS] path ")) + .stderr(predicate::str::contains("await [OPTIONS] port ")) + .stderr(predicate::str::contains( + "Type 'await --help' for the full command reference.", + )); +} + +fn spawn_http_server(response: &'static [u8]) -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener"); + let port = listener.local_addr().expect("address").port(); + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let _ = stream.write_all(response); + } + }); + port +} + +#[test] +fn help_mentions_v3_modes() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("path ")) + .stdout(predicate::str::contains("port ")) + .stdout(predicate::str::contains("http ")) + .stdout(predicate::str::contains("--state exists|missing")); +} + +#[test] +fn path_mode_supports_missing_state() { + let temp = TempDir::new().expect("temp dir"); + let missing = temp.path().join("missing.flag"); + let mut command = cargo_command(); + command + .arg("path") + .arg(missing) + .arg("--json") + .arg("--state") + .arg("missing") + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"kind\":\"path\"")) + .stdout(predicate::str::contains("\"state\":\"missing\"")); +} + +#[test] +fn port_mode_uses_tcp_scheme_target() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener"); + let address = listener.local_addr().expect("address"); + let mut command = cargo_command(); + command + .arg("--json") + .arg("--timeout") + .arg("800ms") + .arg("--interval") + .arg("50ms") + .arg("port") + .arg(format!("tcp://127.0.0.1:{}", address.port())) + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"kind\":\"port\"")) + .stdout(predicate::str::contains("\"target\":\"tcp://127.0.0.1:")); + drop(listener); +} + +#[test] +fn http_mode_supports_status_matching() { + let port = spawn_http_server(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n"); + let mut command = cargo_command(); + command + .arg("--json") + .arg("--timeout") + .arg("800ms") + .arg("--interval") + .arg("50ms") + .arg("http") + .arg(format!("http://127.0.0.1:{port}/health")) + .arg("--status") + .arg("204") + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"kind\":\"http\"")) + .stdout(predicate::str::contains("\"status_code\":204")); +} + +#[cfg(windows)] +#[test] +fn run_mode_retries_until_exit_code_matches() { + let temp = TempDir::new().expect("temp dir"); + let marker = temp.path().join("ready.flag"); + let script = temp.path().join("flip.ps1"); + fs::write( + &script, + format!( + "if (Test-Path '{}') {{ exit 0 }}\nNew-Item -ItemType File -Path '{}' | Out-Null\nexit 9\n", + marker.display(), + marker.display() + ), + ) + .expect("script"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("--timeout") + .arg("20s") + .arg("--interval") + .arg("100ms") + .arg("run") + .arg("--shell") + .arg("pwsh") + .arg("--exit-code") + .arg("0") + .arg("--") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"attempts\":2")) + .stdout(predicate::str::contains("\"kind\":\"run\"")) + .stdout(predicate::str::contains("\"exit_code\":0")); +} + +#[test] +fn path_mode_times_out_when_requested_state_never_arrives() { + let temp = TempDir::new().expect("temp dir"); + let existing = temp.path().join("ready.flag"); + fs::write(&existing, "ready").expect("flag"); + + let mut command = cargo_command(); + command + .args(["--timeout", "150ms", "--interval", "50ms", "path"]) + .arg(&existing) + .arg("--state") + .arg("missing") + .assert() + .code(1) + .stdout(predicate::str::contains("timeout condition=path:missing:")) + .stdout(predicate::str::contains("last=path:ok=false:exists=true")); +} diff --git a/crates/binmeta/Cargo.toml b/crates/binmeta/Cargo.toml new file mode 100644 index 0000000..8edb783 --- /dev/null +++ b/crates/binmeta/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "binmeta" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect Windows binary metadata with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +goblin.workspace = true +humantime.workspace = true +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security_Cryptography", + "Win32_Security_Cryptography_Catalog", + "Win32_Security_Cryptography_Sip", + "Win32_Security_WinTrust", + "Win32_Storage_FileSystem", +] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/binmeta/src/lib.rs b/crates/binmeta/src/lib.rs new file mode 100644 index 0000000..73f2255 --- /dev/null +++ b/crates/binmeta/src/lib.rs @@ -0,0 +1,2432 @@ +//! The `binmeta` command inspects binary metadata. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use goblin::pe::{ + PE, characteristic, + header::{Header, machine_to_str}, + optional_header, + resource::VersionField, + subsystem, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const MAX_INSPECT_BYTES: u64 = 512 * 1024 * 1024; + +/// CLI arguments for the `binmeta` binary. +#[derive(Debug, Clone)] +pub struct Cli { + /// Shared output and stdin policy flags. + pub common: CommonArgs, + /// Explicit files to inspect. + pub paths: Vec, +} + +const HELP: &str = "\ +Inspect Windows binary metadata with AI-friendly output. + +Usage: + binmeta [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + binmeta .\\fixtures\\binmeta\\plain.txt + binmeta .\\target\\debug\\jsonlgrep.exe --json | ConvertFrom-Json + fd -e exe -e dll . .\\target\\debug | binmeta --input-format lines --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum BinaryKind { + Pe, + NotPe, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum PeRole { + Exe, + Dll, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SectionSummary { + name: String, + virtual_size: u32, + virtual_address: u32, + raw_size: u32, + raw_offset: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ImportLibrarySummary { + library: String, + symbol_count: usize, +} + +#[allow( + clippy::struct_excessive_bools, + reason = "the JSON contract intentionally exposes a fixed set of independent heuristic toggles for scripts and AI consumers" +)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +struct HeuristicHints { + dotnet: bool, + unity: bool, + il2cpp: bool, + mono: bool, + bepinex: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AsciiHintNeedle { + marker: &'static [u8], + category: HintCategory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HintCategory { + Dotnet, + Unity, + Il2cpp, + Mono, + Bepinex, +} + +const ASCII_HINT_NEEDLES: &[AsciiHintNeedle] = &[ + AsciiHintNeedle { + marker: b"mscoree.dll", + category: HintCategory::Dotnet, + }, + AsciiHintNeedle { + marker: b"UnityEngine", + category: HintCategory::Unity, + }, + AsciiHintNeedle { + marker: b"UnityPlayer", + category: HintCategory::Unity, + }, + AsciiHintNeedle { + marker: b"il2cpp", + category: HintCategory::Il2cpp, + }, + AsciiHintNeedle { + marker: b"global-metadata.dat", + category: HintCategory::Il2cpp, + }, + AsciiHintNeedle { + marker: b"mono-2.0", + category: HintCategory::Mono, + }, + AsciiHintNeedle { + marker: b"mono.dll", + category: HintCategory::Mono, + }, + AsciiHintNeedle { + marker: b"monobdwgc", + category: HintCategory::Mono, + }, + AsciiHintNeedle { + marker: b"BepInEx", + category: HintCategory::Bepinex, + }, + AsciiHintNeedle { + marker: b"doorstop", + category: HintCategory::Bepinex, + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PeMetadata { + architecture: String, + machine: String, + machine_raw: u16, + subsystem: String, + subsystem_raw: u16, + role: PeRole, + bitness: u8, + timestamp_rfc3339: String, + timestamp_unix: u32, + section_count: usize, + sections: Vec, + library_count: usize, + libraries: Vec, + import_symbol_count: usize, + imports_by_library: Vec, + export_count: usize, + has_clr: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct VersionMetadata { + file_version: Option, + product_version: Option, + company_name: Option, + file_description: Option, + product_name: Option, + original_filename: Option, + internal_name: Option, + legal_copyright: Option, + fixed_file_version: Option, + fixed_product_version: Option, + file_flags: Option, + file_type: Option, + file_subtype: Option, + language_codepages: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SignatureStatus { + Valid, + NotSigned, + Untrusted, + Expired, + Revoked, + BadDigest, + Unsupported, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SignatureType { + Embedded, + Catalog, + None, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CertificateSummary { + subject: Option, + issuer: Option, + serial_number: Option, + thumbprint_sha1: Option, + thumbprint_sha256: Option, + not_before_rfc3339: Option, + not_after_rfc3339: Option, + expired_now: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct TimestampSummary { + signer: Option, + signing_time_rfc3339: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SignatureMetadata { + embedded_present: bool, + embedded_certificate_count: usize, + embedded_certificate_table_size: u32, + status: SignatureStatus, + status_message: String, + signature_type: SignatureType, + catalog_signed: bool, + catalog_path: Option, + catalog_member_tag: Option, + signer: Option, + chain: Vec, + timestamp: Option, + warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct BinaryReport { + path: PathBuf, + kind: BinaryKind, + extension: Option, + size_bytes: u64, + modified_rfc3339: String, + sha256: String, + hints: Vec, + heuristics: HeuristicHints, + pe: Option, + version: Option, + signature: Option, + parse_error: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("binmeta {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +/// Executes the `binmeta` command with the provided arguments. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when no paths are supplied and [`CliError::Runtime`] for file or parsing failures. +pub fn run(cli: &Cli) -> Result { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let mut reports = Vec::new(); + for path in &paths { + reports.push(inspect_path(path)?); + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&reports)?, + RenderMode::Toon => print_structured(&reports, RenderMode::Toon)?, + RenderMode::Text => { + for report in &reports { + println!("{}", render_text_report(report)); + } + } + } + + Ok(map_result_count(reports.len())) +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let paths = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !paths.is_empty() { + return Ok(paths); + } + } + + common::expand_input_patterns(&cli.paths, "binmeta") +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "binmeta")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn inspect_path(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + let modified = metadata.modified().map_err(|error| { + CliError::runtime(format!( + "failed to read modified time for {}: {error}", + path.display() + )) + })?; + ensure_file_size_at_most(path, metadata.len(), MAX_INSPECT_BYTES)?; + let bytes = fs::read(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let sha256 = sha256_hex(&bytes); + + let (kind, pe, version, signature, parse_error) = match PE::parse(&bytes) { + Ok(pe) => ( + BinaryKind::Pe, + Some(extract_pe_metadata(&pe)), + extract_version_metadata(&pe).or_else(|| extract_platform_version_metadata(path)), + Some(extract_signature_metadata(path, &pe)), + None, + ), + Err(error) => match extract_header_only_pe_metadata(&bytes) { + Ok((metadata, certificate_table_size)) => ( + BinaryKind::Pe, + Some(metadata), + extract_platform_version_metadata(path), + Some(extract_signature_metadata_from_parts( + path, + certificate_table_size > 0, + 0, + certificate_table_size, + )), + Some(error.to_string()), + ), + Err(_) => ( + BinaryKind::NotPe, + None, + None, + None, + looks_like_pe(&bytes).then(|| error.to_string()), + ), + }, + }; + + let heuristics = detect_hints(path, &bytes, pe.as_ref()); + Ok(BinaryReport { + path: path.to_path_buf(), + kind, + extension: extension_label(path), + size_bytes: metadata.len(), + modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(), + sha256, + hints: heuristics.labels(), + heuristics, + pe, + version, + signature, + parse_error, + }) +} + +fn extract_pe_metadata(pe: &PE<'_>) -> PeMetadata { + let machine_raw = pe.header.coff_header.machine; + let machine = machine_to_str(machine_raw).to_string(); + let architecture = machine.to_ascii_lowercase(); + let subsystem_raw = pe + .header + .optional_header + .as_ref() + .map_or(0, |header| header.windows_fields.subsystem); + let subsystem = subsystem_label(subsystem_raw).to_string(); + let sections = pe + .sections + .iter() + .map(|section| SectionSummary { + name: section_name(section), + virtual_size: section.virtual_size, + virtual_address: section.virtual_address, + raw_size: section.size_of_raw_data, + raw_offset: section.pointer_to_raw_data, + }) + .collect::>(); + + let libraries = pe + .libraries + .iter() + .map(ToString::to_string) + .collect::>(); + let imports_by_library = summarize_imports(pe); + let timestamp_unix = pe.header.coff_header.time_date_stamp; + let timestamp_rfc3339 = humantime::format_rfc3339_seconds( + SystemTime::UNIX_EPOCH + Duration::from_secs(u64::from(timestamp_unix)), + ) + .to_string(); + + PeMetadata { + architecture, + machine, + machine_raw, + subsystem, + subsystem_raw, + role: if pe.is_lib { PeRole::Dll } else { PeRole::Exe }, + bitness: if pe.is_64 { 64 } else { 32 }, + timestamp_rfc3339, + timestamp_unix, + section_count: sections.len(), + sections, + library_count: libraries.len(), + libraries, + import_symbol_count: pe.imports.len(), + imports_by_library, + export_count: pe.exports.len(), + has_clr: pe.clr_data.is_some(), + } +} + +fn ensure_file_size_at_most(path: &Path, len: u64, max_bytes: u64) -> Result<(), CliError> { + if len > max_bytes { + return Err(CliError::runtime(format!( + "{} is {len} byte(s), above the binmeta inspection limit of {max_bytes} byte(s)", + path.display() + ))); + } + Ok(()) +} + +fn extract_header_only_pe_metadata(bytes: &[u8]) -> Result<(PeMetadata, u32), String> { + let header = Header::parse(bytes).map_err(|error| error.to_string())?; + let mut section_offset = header.dos_header.pe_pointer as usize + + 4 + + 20 + + usize::from(header.coff_header.size_of_optional_header); + let sections = header + .coff_header + .sections(bytes, &mut section_offset) + .unwrap_or_default(); + let machine_raw = header.coff_header.machine; + let machine = machine_to_str(machine_raw).to_string(); + let architecture = machine.to_ascii_lowercase(); + let subsystem_raw = header + .optional_header + .as_ref() + .map_or(0, |optional| optional.windows_fields.subsystem); + let timestamp_unix = header.coff_header.time_date_stamp; + let timestamp_rfc3339 = humantime::format_rfc3339_seconds( + SystemTime::UNIX_EPOCH + Duration::from_secs(u64::from(timestamp_unix)), + ) + .to_string(); + let section_summaries = sections + .iter() + .map(|section| SectionSummary { + name: section_name(section), + virtual_size: section.virtual_size, + virtual_address: section.virtual_address, + raw_size: section.size_of_raw_data, + raw_offset: section.pointer_to_raw_data, + }) + .collect::>(); + let has_clr = header + .optional_header + .as_ref() + .and_then(|optional| optional.data_directories.get_clr_runtime_header()) + .is_some(); + let certificate_table_size = header + .optional_header + .as_ref() + .and_then(|optional| optional.data_directories.get_certificate_table()) + .map_or(0, |table| table.size); + + Ok(( + PeMetadata { + architecture, + machine, + machine_raw, + subsystem: subsystem_label(subsystem_raw).to_string(), + subsystem_raw, + role: if characteristic::is_dll(header.coff_header.characteristics) { + PeRole::Dll + } else { + PeRole::Exe + }, + bitness: pe_bitness(header.optional_header.as_ref()), + timestamp_rfc3339, + timestamp_unix, + section_count: section_summaries.len(), + sections: section_summaries, + library_count: 0, + libraries: Vec::new(), + import_symbol_count: 0, + imports_by_library: Vec::new(), + export_count: 0, + has_clr, + }, + certificate_table_size, + )) +} + +fn pe_bitness(optional_header: Option<&optional_header::OptionalHeader>) -> u8 { + optional_header.map_or(0, |optional| { + if optional.standard_fields.magic == optional_header::IMAGE_NT_OPTIONAL_HDR64_MAGIC { + 64 + } else { + 32 + } + }) +} + +fn summarize_imports(pe: &PE<'_>) -> Vec { + let mut counts = BTreeMap::::new(); + for import in &pe.imports { + *counts.entry(import.dll.to_string()).or_default() += 1; + } + + counts + .into_iter() + .map(|(library, symbol_count)| ImportLibrarySummary { + library, + symbol_count, + }) + .collect::>() +} + +fn extract_version_metadata(pe: &PE<'_>) -> Option { + let version = pe.resource_data.as_ref()?.version_info?; + let strings = version.string_info; + let fixed = version.fixed_info; + + Some(VersionMetadata { + file_version: non_empty(strings.file_version()), + product_version: non_empty(strings.product_version()), + company_name: non_empty(strings.company_name()), + file_description: non_empty(strings.file_description()), + product_name: non_empty(strings.product_name()), + original_filename: non_empty(strings.original_filename()), + internal_name: non_empty(strings.internal_name()), + legal_copyright: non_empty(strings.legal_copyright()), + fixed_file_version: fixed + .map(|info| VersionField::from_ms_ls(info.file_version_ms, info.file_version_ls)) + .map(|version| version.to_string()), + fixed_product_version: fixed + .map(|info| VersionField::from_ms_ls(info.product_version_ms, info.product_version_ls)) + .map(|version| version.to_string()), + file_flags: fixed.map(|info| info.file_flags), + file_type: fixed.map(|info| info.file_type), + file_subtype: fixed.map(|info| info.file_subtype), + language_codepages: Vec::new(), + }) +} + +#[cfg(not(windows))] +fn extract_platform_version_metadata(_path: &Path) -> Option { + None +} + +#[cfg(windows)] +fn extract_platform_version_metadata(path: &Path) -> Option { + version_info_win32::extract(path) +} + +#[cfg(windows)] +#[allow( + unsafe_op_in_unsafe_fn, + unsafe_code, + reason = "Windows version resources are exposed through version.dll FFI; pointers only reference the owned GetFileVersionInfo buffer" +)] +mod version_info_win32 { + use std::ffi::OsStr; + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + use std::ptr::null_mut; + + use windows_sys::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, + }; + + use super::{VersionMetadata, non_empty}; + + const STRING_FIELDS: &[(&str, StringField)] = &[ + ("CompanyName", StringField::CompanyName), + ("FileDescription", StringField::FileDescription), + ("FileVersion", StringField::FileVersion), + ("InternalName", StringField::InternalName), + ("LegalCopyright", StringField::LegalCopyright), + ("OriginalFilename", StringField::OriginalFilename), + ("ProductName", StringField::ProductName), + ("ProductVersion", StringField::ProductVersion), + ]; + + #[derive(Debug, Clone, Copy)] + enum StringField { + CompanyName, + FileDescription, + FileVersion, + InternalName, + LegalCopyright, + OriginalFilename, + ProductName, + ProductVersion, + } + + #[allow( + clippy::redundant_pub_crate, + reason = "parent module calls the platform helper while the cfg-gated module stays crate-local" + )] + pub(super) fn extract(path: &Path) -> Option { + let wide_path = wide_os(path.as_os_str()); + let mut handle = 0u32; + let size = unsafe { + // SAFETY: `wide_path` is a null-terminated UTF-16 path and `handle` is a valid out pointer. + GetFileVersionInfoSizeW(wide_path.as_ptr(), &raw mut handle) + }; + if size == 0 { + return None; + } + + let mut buffer = vec![0u8; size as usize]; + let ok = unsafe { + // SAFETY: `buffer` is allocated to the exact size reported by version.dll. + GetFileVersionInfoW(wide_path.as_ptr(), 0, size, buffer.as_mut_ptr().cast()) + }; + if ok == 0 { + return None; + } + + let fixed = unsafe { + // SAFETY: The query function only returns pointers into the owned `buffer`. + query_fixed(&buffer) + }; + let language_codepages = unsafe { + // SAFETY: The query function only reads translation pairs inside the owned `buffer`. + query_translations(&buffer) + }; + let mut metadata = VersionMetadata { + file_version: None, + product_version: None, + company_name: None, + file_description: None, + product_name: None, + original_filename: None, + internal_name: None, + legal_copyright: None, + fixed_file_version: fixed + .map(|info| version_pair(info.dwFileVersionMS, info.dwFileVersionLS)), + fixed_product_version: fixed + .map(|info| version_pair(info.dwProductVersionMS, info.dwProductVersionLS)), + file_flags: fixed.map(|info| info.dwFileFlags), + file_type: fixed.map(|info| info.dwFileType), + file_subtype: fixed.map(|info| info.dwFileSubtype), + language_codepages: language_codepages.clone(), + }; + + for language_codepage in language_codepages + .iter() + .chain(std::iter::once(&"040904B0".to_string())) + { + fill_strings(&buffer, language_codepage, &mut metadata); + } + + Some(metadata).filter(version_has_any_field) + } + + fn fill_strings(buffer: &[u8], language_codepage: &str, metadata: &mut VersionMetadata) { + for (name, field) in STRING_FIELDS { + let Some(value) = (unsafe { + // SAFETY: The query function only returns strings inside the owned `buffer`. + query_string( + buffer, + &format!("\\StringFileInfo\\{language_codepage}\\{name}"), + ) + }) else { + continue; + }; + let target = match field { + StringField::CompanyName => &mut metadata.company_name, + StringField::FileDescription => &mut metadata.file_description, + StringField::FileVersion => &mut metadata.file_version, + StringField::InternalName => &mut metadata.internal_name, + StringField::LegalCopyright => &mut metadata.legal_copyright, + StringField::OriginalFilename => &mut metadata.original_filename, + StringField::ProductName => &mut metadata.product_name, + StringField::ProductVersion => &mut metadata.product_version, + }; + if target.is_none() { + *target = non_empty(Some(value)); + } + } + } + + unsafe fn query_fixed(buffer: &[u8]) -> Option { + let mut pointer = null_mut(); + let mut len = 0u32; + let subblock = wide_str("\\"); + if VerQueryValueW( + buffer.as_ptr().cast(), + subblock.as_ptr(), + &raw mut pointer, + &raw mut len, + ) == 0 + || pointer.is_null() + || len < u32::try_from(size_of::()).ok()? + { + return None; + } + Some(*pointer.cast::()) + } + + unsafe fn query_translations(buffer: &[u8]) -> Vec { + let mut pointer = null_mut(); + let mut len = 0u32; + let subblock = wide_str("\\VarFileInfo\\Translation"); + if VerQueryValueW( + buffer.as_ptr().cast(), + subblock.as_ptr(), + &raw mut pointer, + &raw mut len, + ) == 0 + || pointer.is_null() + || len < 4 + { + return vec!["040904B0".to_string()]; + } + let pair_count = len as usize / 4; + let words = std::slice::from_raw_parts(pointer.cast::(), pair_count * 2); + let mut values = Vec::with_capacity(pair_count); + for pair in words.chunks_exact(2) { + values.push(format!("{:04X}{:04X}", pair[0], pair[1])); + } + values + } + + unsafe fn query_string(buffer: &[u8], subblock: &str) -> Option { + let mut pointer = null_mut(); + let mut len = 0u32; + let wide_subblock = wide_str(subblock); + if VerQueryValueW( + buffer.as_ptr().cast(), + wide_subblock.as_ptr(), + &raw mut pointer, + &raw mut len, + ) == 0 + || pointer.is_null() + || len == 0 + { + return None; + } + let slice = std::slice::from_raw_parts(pointer.cast::(), len as usize); + Some(utf16z_to_string(slice)) + } + + const fn version_has_any_field(metadata: &VersionMetadata) -> bool { + metadata.file_version.is_some() + || metadata.product_version.is_some() + || metadata.company_name.is_some() + || metadata.file_description.is_some() + || metadata.product_name.is_some() + || metadata.fixed_file_version.is_some() + || metadata.fixed_product_version.is_some() + } + + fn version_pair(ms: u32, ls: u32) -> String { + format!("{}.{}.{}.{}", ms >> 16, ms & 0xffff, ls >> 16, ls & 0xffff) + } + + fn wide_os(value: &OsStr) -> Vec { + value.encode_wide().chain([0]).collect() + } + + fn wide_str(value: &str) -> Vec { + value.encode_utf16().chain([0]).collect() + } + + fn utf16z_to_string(buffer: &[u16]) -> String { + let len = buffer + .iter() + .position(|value| *value == 0) + .unwrap_or(buffer.len()); + String::from_utf16_lossy(&buffer[..len]) + } +} + +fn non_empty(value: Option) -> Option { + value.and_then(|text| { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn extract_signature_metadata(path: &Path, pe: &PE<'_>) -> SignatureMetadata { + let embedded_certificate_table_size = pe.certificates.iter().map(|cert| cert.length).sum(); + let embedded_certificate_count = pe.certificates.len(); + extract_signature_metadata_from_parts( + path, + embedded_certificate_count > 0, + embedded_certificate_count, + embedded_certificate_table_size, + ) +} + +fn extract_signature_metadata_from_parts( + path: &Path, + embedded_present: bool, + embedded_certificate_count: usize, + embedded_certificate_table_size: u32, +) -> SignatureMetadata { + let mut metadata = SignatureMetadata { + embedded_present, + embedded_certificate_count, + embedded_certificate_table_size, + status: if embedded_present { + SignatureStatus::Unsupported + } else { + SignatureStatus::NotSigned + }, + status_message: if embedded_present { + "embedded certificate table present; trust verification is unavailable on this platform" + .to_string() + } else { + "no embedded certificate table found".to_string() + }, + signature_type: if embedded_present { + SignatureType::Unknown + } else { + SignatureType::None + }, + catalog_signed: false, + catalog_path: None, + catalog_member_tag: None, + signer: None, + chain: Vec::new(), + timestamp: None, + warnings: Vec::new(), + }; + + apply_platform_trust(path, &mut metadata); + metadata +} + +#[cfg(not(windows))] +fn apply_platform_trust(_path: &Path, _metadata: &mut SignatureMetadata) {} + +#[cfg(windows)] +fn apply_platform_trust(path: &Path, metadata: &mut SignatureMetadata) { + if let Err(error) = wintrust::apply(path, metadata) { + metadata.status = SignatureStatus::Error; + metadata.signature_type = if metadata.embedded_present { + SignatureType::Embedded + } else { + SignatureType::Unknown + }; + metadata.status_message = error; + } +} + +#[cfg(windows)] +#[allow( + unsafe_op_in_unsafe_fn, + unsafe_code, + reason = "Windows trust and catalog APIs are only exposed through FFI; wrappers keep handles, pointers, and zeroed Win32 structs scoped" +)] +pub(crate) mod wintrust { + use std::ffi::OsStr; + use std::fs::File; + use std::mem::{size_of, zeroed}; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::AsRawHandle; + use std::path::{Path, PathBuf}; + use std::ptr::{null, null_mut}; + use std::time::{Duration, SystemTime}; + + use windows_sys::Win32::Foundation::{ + CERT_E_EXPIRED, CERT_E_REVOKED, CERT_E_UNTRUSTEDROOT, CRYPT_E_SECURITY_SETTINGS, FILETIME, + HANDLE, INVALID_HANDLE_VALUE, TRUST_E_BAD_DIGEST, TRUST_E_EXPLICIT_DISTRUST, + TRUST_E_NOSIGNATURE, + }; + use windows_sys::Win32::Security::Cryptography::Catalog::{ + CATALOG_INFO, CryptCATAdminAcquireContext, CryptCATAdminCalcHashFromFileHandle, + CryptCATAdminEnumCatalogFromHash, CryptCATAdminReleaseCatalogContext, + CryptCATAdminReleaseContext, CryptCATCatalogInfoFromContext, + }; + use windows_sys::Win32::Security::Cryptography::{ + CERT_CONTEXT, CERT_NAME_ISSUER_FLAG, CERT_NAME_SIMPLE_DISPLAY_TYPE, CERT_SHA1_HASH_PROP_ID, + CERT_SHA256_HASH_PROP_ID, CertGetCertificateContextProperty, CertGetNameStringW, + }; + use windows_sys::Win32::Security::WinTrust::{ + WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_CATALOG_INFO, WINTRUST_DATA, WINTRUST_DATA_0, + WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_CATALOG, WTD_CHOICE_FILE, + WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY, WTD_UI_NONE, + WTHelperGetProvCertFromChain, WTHelperGetProvSignerFromChain, + WTHelperProvDataFromStateData, WinVerifyTrustEx, + }; + use windows_sys::core::GUID; + + use super::{ + CertificateSummary, SignatureMetadata, SignatureStatus, SignatureType, TimestampSummary, + bytes_to_hex, + }; + + #[allow( + clippy::redundant_pub_crate, + reason = "parent module calls the platform helper while the cfg-gated module stays crate-local" + )] + pub(super) fn apply(path: &Path, metadata: &mut SignatureMetadata) -> Result<(), String> { + if metadata.embedded_present { + let result = verify_file(path); + merge_result(metadata, result, SignatureType::Embedded); + return Ok(()); + } + + if let Some(result) = verify_catalog(path)? { + merge_result(metadata, result, SignatureType::Catalog); + metadata.catalog_signed = metadata.status == SignatureStatus::Valid; + } else { + metadata.status = SignatureStatus::NotSigned; + metadata.status_message = + "no embedded signature or matching system catalog was found".to_string(); + metadata.signature_type = SignatureType::None; + } + + Ok(()) + } + + fn merge_result( + metadata: &mut SignatureMetadata, + result: TrustResult, + signature_type: SignatureType, + ) { + metadata.status = result.status; + metadata.status_message = result.status_message; + metadata.signature_type = if result.status == SignatureStatus::NotSigned { + SignatureType::None + } else { + signature_type + }; + metadata.catalog_path = result.catalog_path; + metadata.catalog_member_tag = result.catalog_member_tag; + metadata.signer = result.signer; + metadata.chain = result.chain; + metadata.timestamp = result.timestamp; + } + + struct TrustResult { + status: SignatureStatus, + status_message: String, + catalog_path: Option, + catalog_member_tag: Option, + signer: Option, + chain: Vec, + timestamp: Option, + } + + fn verify_file(path: &Path) -> TrustResult { + let wide_path = wide_path(path); + let mut file_info = WINTRUST_FILE_INFO { + cbStruct: struct_size::(), + pcwszFilePath: wide_path.as_ptr(), + hFile: null_mut(), + pgKnownSubject: null_mut(), + }; + let mut data = trust_data(); + data.dwUnionChoice = WTD_CHOICE_FILE; + data.Anonymous.pFile = &raw mut file_info; + verify_with_state(&mut data, None, None) + } + + fn verify_catalog(path: &Path) -> Result, String> { + let file = File::open(path) + .map_err(|error| format!("failed to open file for catalog verification: {error}"))?; + let handle = file.as_raw_handle() as HANDLE; + let admin = CatalogAdmin::acquire()?; + let hash = CatalogAdmin::hash_file(handle)?; + let Some(catalog) = admin.first_catalog(&hash) else { + return Ok(None); + }; + let catalog_path = catalog.path()?; + let member_tag = bytes_to_hex(&hash).to_ascii_uppercase(); + let wide_catalog = wide_os(catalog_path.as_os_str()); + let wide_member_tag = wide_os(OsStr::new(&member_tag)); + let wide_member_file = wide_path(path); + let mut catalog_info = WINTRUST_CATALOG_INFO { + cbStruct: struct_size::(), + dwCatalogVersion: 0, + pcwszCatalogFilePath: wide_catalog.as_ptr(), + pcwszMemberTag: wide_member_tag.as_ptr(), + pcwszMemberFilePath: wide_member_file.as_ptr(), + hMemberFile: handle, + pbCalculatedFileHash: hash.as_ptr().cast_mut(), + cbCalculatedFileHash: u32::try_from(hash.len()).expect("catalog hash length fits u32"), + pcCatalogContext: null_mut(), + hCatAdmin: admin.0, + }; + let mut data = trust_data(); + data.dwUnionChoice = WTD_CHOICE_CATALOG; + data.Anonymous.pCatalog = &raw mut catalog_info; + let mut result = verify_with_state(&mut data, Some(catalog_path), Some(member_tag)); + result.catalog_path = result.catalog_path.or_else(|| catalog.path().ok()); + Ok(Some(result)) + } + + fn trust_data() -> WINTRUST_DATA { + WINTRUST_DATA { + cbStruct: struct_size::(), + pPolicyCallbackData: null_mut(), + pSIPClientData: null_mut(), + dwUIChoice: WTD_UI_NONE, + fdwRevocationChecks: WTD_REVOKE_NONE, + dwUnionChoice: 0, + Anonymous: WINTRUST_DATA_0::default(), + dwStateAction: WTD_STATEACTION_VERIFY, + hWVTStateData: null_mut(), + pwszURLReference: null_mut(), + dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL, + dwUIContext: 0, + pSignatureSettings: null_mut(), + } + } + + fn verify_with_state( + data: &mut WINTRUST_DATA, + catalog_path: Option, + catalog_member_tag: Option, + ) -> TrustResult { + let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + let status_code = unsafe { + // SAFETY: `data` points to initialized WINTRUST_DATA whose union arm matches + // `dwUnionChoice`; all pointed-to UTF-16 buffers live until the call returns. + WinVerifyTrustEx(null_mut(), &raw mut action, data) + }; + let (signer, chain, timestamp) = unsafe { + // SAFETY: WinVerifyTrustEx initialized `hWVTStateData` for the active verify state. + // Helper pointers are only read before the matching CLOSE action below. + extract_provider_state(data.hWVTStateData) + }; + data.dwStateAction = WTD_STATEACTION_CLOSE; + unsafe { + // SAFETY: Closing the state handle returned in the previous WinVerifyTrustEx call. + let _ = WinVerifyTrustEx(null_mut(), &raw mut action, data); + } + + TrustResult { + status: status_from_code(status_code), + status_message: status_message(status_code), + catalog_path, + catalog_member_tag, + signer, + chain, + timestamp, + } + } + + unsafe fn extract_provider_state( + state_data: HANDLE, + ) -> ( + Option, + Vec, + Option, + ) { + if state_data.is_null() { + return (None, Vec::new(), None); + } + let prov_data = WTHelperProvDataFromStateData(state_data); + if prov_data.is_null() { + return (None, Vec::new(), None); + } + let signer = WTHelperGetProvSignerFromChain(prov_data, 0, 0, 0); + if signer.is_null() { + return (None, Vec::new(), None); + } + let cert_count = (*signer).csCertChain; + let mut chain = Vec::new(); + for index in 0..cert_count { + let cert = WTHelperGetProvCertFromChain(signer, index); + if !cert.is_null() { + if let Some(summary) = summarize_cert((*cert).pCert) { + chain.push(summary); + } + } + } + let signer_summary = chain.first().cloned(); + let timestamp_signer = WTHelperGetProvSignerFromChain(prov_data, 0, 1, 0); + let timestamp = if timestamp_signer.is_null() { + None + } else { + let cert = WTHelperGetProvCertFromChain(timestamp_signer, 0); + let timestamp_cert = if cert.is_null() { + None + } else { + summarize_cert((*cert).pCert) + }; + Some(TimestampSummary { + signer: timestamp_cert, + signing_time_rfc3339: filetime_to_rfc3339((*timestamp_signer).sftVerifyAsOf), + }) + }; + (signer_summary, chain, timestamp) + } + + unsafe fn summarize_cert(cert: *const CERT_CONTEXT) -> Option { + if cert.is_null() || (*cert).pCertInfo.is_null() { + return None; + } + let info = (*cert).pCertInfo; + let subject = cert_display_name(cert, 0); + let issuer = cert_display_name(cert, CERT_NAME_ISSUER_FLAG); + let serial_number = + blob_to_reversed_hex((*info).SerialNumber.pbData, (*info).SerialNumber.cbData); + let thumbprint_sha1 = cert_property_hash(cert, CERT_SHA1_HASH_PROP_ID); + let thumbprint_sha256 = cert_property_hash(cert, CERT_SHA256_HASH_PROP_ID); + let not_before_rfc3339 = filetime_to_rfc3339((*info).NotBefore); + let not_after_rfc3339 = filetime_to_rfc3339((*info).NotAfter); + let expired_now = Some(filetime_to_system_time((*info).NotAfter) < SystemTime::now()); + Some(CertificateSummary { + subject, + issuer, + serial_number, + thumbprint_sha1, + thumbprint_sha256, + not_before_rfc3339, + not_after_rfc3339, + expired_now, + }) + } + + unsafe fn cert_display_name(cert: *const CERT_CONTEXT, flags: u32) -> Option { + let needed = CertGetNameStringW( + cert, + CERT_NAME_SIMPLE_DISPLAY_TYPE, + flags, + null(), + null_mut(), + 0, + ); + if needed <= 1 { + return None; + } + let mut buffer = vec![0u16; needed as usize]; + let written = CertGetNameStringW( + cert, + CERT_NAME_SIMPLE_DISPLAY_TYPE, + flags, + null(), + buffer.as_mut_ptr(), + needed, + ); + (written > 1).then(|| utf16z_to_string(&buffer)) + } + + unsafe fn cert_property_hash(cert: *const CERT_CONTEXT, property_id: u32) -> Option { + let mut size = 0u32; + if CertGetCertificateContextProperty(cert, property_id, null_mut(), &raw mut size) == 0 + || size == 0 + { + return None; + } + let mut bytes = vec![0u8; size as usize]; + if CertGetCertificateContextProperty( + cert, + property_id, + bytes.as_mut_ptr().cast(), + &raw mut size, + ) == 0 + { + return None; + } + Some(bytes_to_hex(&bytes)) + } + + unsafe fn blob_to_reversed_hex(data: *mut u8, size: u32) -> Option { + if data.is_null() || size == 0 { + return None; + } + let slice = std::slice::from_raw_parts(data, size as usize); + let reversed = slice.iter().rev().copied().collect::>(); + Some(bytes_to_hex(&reversed)) + } + + const fn status_from_code(code: i32) -> SignatureStatus { + match code { + 0 => SignatureStatus::Valid, + TRUST_E_NOSIGNATURE => SignatureStatus::NotSigned, + TRUST_E_BAD_DIGEST => SignatureStatus::BadDigest, + CERT_E_EXPIRED => SignatureStatus::Expired, + CERT_E_REVOKED => SignatureStatus::Revoked, + TRUST_E_EXPLICIT_DISTRUST | CERT_E_UNTRUSTEDROOT | CRYPT_E_SECURITY_SETTINGS => { + SignatureStatus::Untrusted + } + _ => SignatureStatus::Error, + } + } + + fn status_message(code: i32) -> String { + match status_from_code(code) { + SignatureStatus::Valid => "signature verified successfully".to_string(), + SignatureStatus::NotSigned => "no Authenticode signature was found".to_string(), + SignatureStatus::Untrusted => format!("signature is not trusted ({code:#010x})"), + SignatureStatus::Expired => "signing certificate is expired".to_string(), + SignatureStatus::Revoked => "signing certificate is revoked".to_string(), + SignatureStatus::BadDigest => "file digest does not match the signature".to_string(), + SignatureStatus::Unsupported => "trust verification is unsupported".to_string(), + SignatureStatus::Error => format!("WinTrust verification failed ({code:#010x})"), + } + } + + struct CatalogAdmin(isize); + + impl CatalogAdmin { + fn acquire() -> Result { + let mut handle = 0isize; + let ok = unsafe { + // SAFETY: Output pointer is valid and subsystem GUID is null for default catalog admin. + CryptCATAdminAcquireContext(&raw mut handle, null(), 0) + }; + if ok == 0 || handle == 0 { + Err("failed to acquire catalog admin context".to_string()) + } else { + Ok(Self(handle)) + } + } + + fn hash_file(file: HANDLE) -> Result, String> { + let mut size = 0u32; + let first = unsafe { + // SAFETY: Query call with null hash buffer is the documented size-discovery form. + CryptCATAdminCalcHashFromFileHandle(file, &raw mut size, null_mut(), 0) + }; + if first == 0 || size == 0 { + return Err("failed to size catalog hash buffer".to_string()); + } + let mut hash = vec![0u8; size as usize]; + let ok = unsafe { + // SAFETY: `hash` is sized from the API's previous size discovery call. + CryptCATAdminCalcHashFromFileHandle(file, &raw mut size, hash.as_mut_ptr(), 0) + }; + if ok == 0 { + Err("failed to calculate catalog hash".to_string()) + } else { + hash.truncate(size as usize); + Ok(hash) + } + } + + fn first_catalog(&self, hash: &[u8]) -> Option> { + let mut previous = 0isize; + let context = unsafe { + // SAFETY: `hash` is a stable buffer for the duration of the enumeration call. + CryptCATAdminEnumCatalogFromHash( + self.0, + hash.as_ptr(), + u32::try_from(hash.len()).expect("catalog hash length fits u32"), + 0, + &raw mut previous, + ) + }; + if context == 0 { + None + } else { + Some(CatalogContext { + admin: self, + handle: context, + }) + } + } + } + + impl Drop for CatalogAdmin { + fn drop(&mut self) { + unsafe { + // SAFETY: Releases the catalog admin handle acquired by `CryptCATAdminAcquireContext`. + let _ = CryptCATAdminReleaseContext(self.0, 0); + } + } + } + + struct CatalogContext<'a> { + admin: &'a CatalogAdmin, + handle: isize, + } + + impl CatalogContext<'_> { + fn path(&self) -> Result { + let mut info = CATALOG_INFO { + cbStruct: struct_size::(), + ..unsafe { + // SAFETY: CATALOG_INFO is a plain Win32 POD buffer initialized before FFI fills it. + zeroed() + } + }; + let ok = unsafe { + // SAFETY: `info` points to a valid writable CATALOG_INFO buffer. + CryptCATCatalogInfoFromContext(self.handle, &raw mut info, 0) + }; + if ok == 0 { + Err("failed to read catalog path".to_string()) + } else { + Ok(PathBuf::from(utf16z_to_string(&info.wszCatalogFile))) + } + } + } + + impl Drop for CatalogContext<'_> { + fn drop(&mut self) { + unsafe { + // SAFETY: Releases the catalog context returned by `CryptCATAdminEnumCatalogFromHash`. + let _ = CryptCATAdminReleaseCatalogContext(self.admin.0, self.handle, 0); + } + } + } + + fn filetime_to_rfc3339(filetime: FILETIME) -> Option { + let system_time = filetime_to_system_time(filetime); + (system_time != SystemTime::UNIX_EPOCH) + .then(|| humantime::format_rfc3339_seconds(system_time).to_string()) + } + + fn filetime_to_system_time(filetime: FILETIME) -> SystemTime { + const WINDOWS_TO_UNIX_EPOCH_100NS: u64 = 116_444_736_000_000_000; + let ticks = (u64::from(filetime.dwHighDateTime) << 32) | u64::from(filetime.dwLowDateTime); + if ticks == 0 { + return SystemTime::UNIX_EPOCH; + } + if ticks < WINDOWS_TO_UNIX_EPOCH_100NS { + return SystemTime::UNIX_EPOCH; + } + let unix_100ns = ticks - WINDOWS_TO_UNIX_EPOCH_100NS; + SystemTime::UNIX_EPOCH + Duration::from_nanos(unix_100ns.saturating_mul(100)) + } + + fn wide_path(path: &Path) -> Vec { + wide_os(path.as_os_str()) + } + + fn wide_os(value: &OsStr) -> Vec { + value.encode_wide().chain([0]).collect() + } + + fn utf16z_to_string(buffer: &[u16]) -> String { + let len = buffer + .iter() + .position(|value| *value == 0) + .unwrap_or(buffer.len()); + String::from_utf16_lossy(&buffer[..len]) + } + + fn struct_size() -> u32 { + u32::try_from(size_of::()).expect("Win32 struct size fits u32") + } + + #[allow( + dead_code, + reason = "keeps HANDLE import honest on older target metadata" + )] + const _: HANDLE = INVALID_HANDLE_VALUE; + #[allow( + dead_code, + reason = "keeps GUID import honest on older target metadata" + )] + const _: Option = None; +} + +fn detect_hints(path: &Path, bytes: &[u8], pe: Option<&PeMetadata>) -> HeuristicHints { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let byte_hints = scan_ascii_hint_markers(bytes); + let dotnet = pe.is_some_and(|metadata| metadata.has_clr) || byte_hints.dotnet; + let unity = file_name.contains("unityplayer") + || file_name.contains("assembly-csharp") + || byte_hints.unity; + let il2cpp = file_name.contains("gameassembly") + || file_name.contains("libil2cpp") + || file_name.contains("global-metadata.dat") + || (pe.is_some() && byte_hints.il2cpp); + let mono = file_name.contains("mono") || byte_hints.mono; + let bepinex = file_name.contains("bepinex") || byte_hints.bepinex; + + HeuristicHints { + dotnet, + unity, + il2cpp, + mono, + bepinex, + } +} + +fn render_text_report(report: &BinaryReport) -> String { + let kind = kind_label(report.kind); + let extension = report + .extension + .as_deref() + .map(|value| format!(" ext={value}")) + .unwrap_or_default(); + let hints = if report.hints.is_empty() { + "none".to_string() + } else { + report.hints.join(",") + }; + let mut line = format!( + "path={} kind={} size={} mtime={} sha256={}{} hints={}", + report.path.display(), + kind, + report.size_bytes, + report.modified_rfc3339, + short_hash(&report.sha256), + extension, + hints + ); + + if let Some(pe) = &report.pe { + write!( + line, + " arch={} machine={} subsystem={} role={} bits={} sections={} imports={}dll/{}sym exports={}", + pe.architecture, + pe.machine, + pe.subsystem, + role_label(pe.role), + pe.bitness, + pe.section_count, + pe.library_count, + pe.import_symbol_count, + pe.export_count + ) + .expect("writing to a String cannot fail"); + } + + if let Some(version) = &report.version { + if let Some(file_version) = version + .file_version + .as_deref() + .or(version.fixed_file_version.as_deref()) + { + write!(line, " version={}", quote_if_needed(file_version)) + .expect("writing to a String cannot fail"); + } + if let Some(company) = version.company_name.as_deref() { + write!(line, " company={}", quote_if_needed(company)) + .expect("writing to a String cannot fail"); + } + if let Some(product) = version.product_name.as_deref() { + write!(line, " product={}", quote_if_needed(product)) + .expect("writing to a String cannot fail"); + } + } + + if let Some(signature) = &report.signature { + write!( + line, + " sig={} type={}", + signature_status_label(signature.status), + signature_type_label(signature.signature_type) + ) + .expect("writing to a String cannot fail"); + if signature.catalog_signed { + line.push_str(" catalog=true"); + } + if let Some(signer) = signature + .signer + .as_ref() + .and_then(|cert| cert.subject.as_deref()) + { + write!(line, " signer={}", quote_if_needed(signer)) + .expect("writing to a String cannot fail"); + } + if let Some(expires) = signature + .signer + .as_ref() + .and_then(|cert| cert.not_after_rfc3339.as_deref()) + { + write!(line, " expires={expires}").expect("writing to a String cannot fail"); + } + } + + if let Some(error) = &report.parse_error { + write!(line, " parse_error={error}").expect("writing to a String cannot fail"); + } + + line +} + +fn extension_label(path: &Path) -> Option { + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) +} + +fn section_name(section: &goblin::pe::section_table::SectionTable) -> String { + section + .name() + .map_or_else(|_| "".to_string(), ToString::to_string) +} + +const fn subsystem_label(subsystem_raw: u16) -> &'static str { + match subsystem_raw { + subsystem::IMAGE_SUBSYSTEM_NATIVE => "native", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI => "windows_gui", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI => "windows_cui", + subsystem::IMAGE_SUBSYSTEM_OS2_CUI => "os2_cui", + subsystem::IMAGE_SUBSYSTEM_POSIX_CUI => "posix_cui", + subsystem::IMAGE_SUBSYSTEM_NATIVE_WINDOWS => "native_windows", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_CE_GUI => "windows_ce_gui", + subsystem::IMAGE_SUBSYSTEM_EFI_APPLICATION => "efi_application", + subsystem::IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER => "efi_boot_service_driver", + subsystem::IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER => "efi_runtime_driver", + subsystem::IMAGE_SUBSYSTEM_EFI_ROM => "efi_rom", + subsystem::IMAGE_SUBSYSTEM_XBOX => "xbox", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION => "windows_boot_application", + _ => "unknown", + } +} + +const fn kind_label(kind: BinaryKind) -> &'static str { + match kind { + BinaryKind::Pe => "pe", + BinaryKind::NotPe => "not_pe", + } +} + +const fn role_label(role: PeRole) -> &'static str { + match role { + PeRole::Exe => "exe", + PeRole::Dll => "dll", + } +} + +const fn signature_status_label(status: SignatureStatus) -> &'static str { + match status { + SignatureStatus::Valid => "valid", + SignatureStatus::NotSigned => "not_signed", + SignatureStatus::Untrusted => "untrusted", + SignatureStatus::Expired => "expired", + SignatureStatus::Revoked => "revoked", + SignatureStatus::BadDigest => "bad_digest", + SignatureStatus::Unsupported => "unsupported", + SignatureStatus::Error => "error", + } +} + +const fn signature_type_label(signature_type: SignatureType) -> &'static str { + match signature_type { + SignatureType::Embedded => "embedded", + SignatureType::Catalog => "catalog", + SignatureType::None => "none", + SignatureType::Unknown => "unknown", + } +} + +fn quote_if_needed(value: &str) -> String { + if value + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value.to_string() + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + bytes_to_hex(&digest) +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut text = String::with_capacity(bytes.len() * 2); + for byte in bytes { + text.push(nibble_to_hex(byte >> 4)); + text.push(nibble_to_hex(byte & 0x0f)); + } + text +} + +const fn nibble_to_hex(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'a' + (value - 10)) as char, + _ => '0', + } +} + +fn short_hash(hash: &str) -> &str { + hash.get(..12).unwrap_or(hash) +} + +fn looks_like_pe(bytes: &[u8]) -> bool { + bytes.starts_with(b"MZ") +} + +fn scan_ascii_hint_markers(haystack: &[u8]) -> HeuristicHints { + let mut hints = HeuristicHints::default(); + + for index in 0..haystack.len() { + if hints.has_all_categories() { + break; + } + + for needle in ASCII_HINT_NEEDLES { + if hints.has_category(needle.category) { + continue; + } + if ascii_case_insensitive_at(haystack, index, needle.marker) { + hints.set_category(needle.category); + } + } + } + + hints +} + +fn ascii_case_insensitive_at(haystack: &[u8], start: usize, needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .get(start..start.saturating_add(needle.len())) + .is_some_and(|window| window.eq_ignore_ascii_case(needle)) +} + +#[cfg(test)] +fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +impl HeuristicHints { + const fn has_category(&self, category: HintCategory) -> bool { + match category { + HintCategory::Dotnet => self.dotnet, + HintCategory::Unity => self.unity, + HintCategory::Il2cpp => self.il2cpp, + HintCategory::Mono => self.mono, + HintCategory::Bepinex => self.bepinex, + } + } + + const fn set_category(&mut self, category: HintCategory) { + match category { + HintCategory::Dotnet => self.dotnet = true, + HintCategory::Unity => self.unity = true, + HintCategory::Il2cpp => self.il2cpp = true, + HintCategory::Mono => self.mono = true, + HintCategory::Bepinex => self.bepinex = true, + } + } + + const fn has_all_categories(&self) -> bool { + self.dotnet && self.unity && self.il2cpp && self.mono && self.bepinex + } + + fn labels(&self) -> Vec { + let mut labels = Vec::new(); + if self.dotnet { + labels.push("dotnet".to_string()); + } + if self.unity { + labels.push("unity".to_string()); + } + if self.il2cpp { + labels.push("il2cpp".to_string()); + } + if self.mono { + labels.push("mono".to_string()); + } + if self.bepinex { + labels.push("bepinex".to_string()); + } + labels + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: common::ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_paths_supports_lines_and_jsonl_modes() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("a.txt"); + let second = temp.path().join("b.txt"); + let third = temp.path().join("c.txt"); + fs::write(&first, "a").expect("first"); + fs::write(&second, "b").expect("second"); + fs::write(&third, "c").expect("third"); + assert_eq!( + parse_paths_from_string(&format!("{}\n", first.display()), InputFormat::Lines) + .expect("lines"), + vec![first.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&second.display().to_string()).expect("json path"), + serde_json::to_string(&third.display().to_string()).expect("json path") + ), + InputFormat::Jsonl + ) + .expect("jsonl"), + vec![second, third] + ); + assert_eq!( + parse_paths_from_string(&format!("{}\n", first.display()), InputFormat::Auto) + .expect("auto"), + vec![first] + ); + } + + #[test] + fn parse_paths_reports_invalid_jsonl_lines() { + let error = parse_paths_from_string("not-json\n", InputFormat::Jsonl) + .expect_err("invalid jsonl should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("stdin JSONL path line 1 is not valid JSON") + )); + } + + #[test] + fn cli_parser_covers_help_version_rendering_and_validation_errors() { + let (outcome, cli) = parse_cli_from(["binmeta", "--help"]).expect("help parses"); + assert_eq!(outcome, ParseOutcome::Help); + assert!(cli.paths.is_empty()); + + let (outcome, _cli) = parse_cli_from(["binmeta", "--version"]).expect("version parses"); + assert_eq!(outcome, ParseOutcome::Version); + + let (outcome, cli) = parse_cli_from([ + "binmeta", + "--toon", + "--input-format", + "lines", + "--color", + "never", + "--quiet", + "a.exe", + "b.dll", + ]) + .expect("run parses"); + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.common.render_mode(), RenderMode::Toon); + assert_eq!(cli.common.input_format, InputFormat::Lines); + assert!(cli.common.quiet); + assert_eq!( + cli.paths, + vec![PathBuf::from("a.exe"), PathBuf::from("b.dll")] + ); + + let error = + parse_cli_from(["binmeta", "--format", "xml"]).expect_err("bad format rejected"); + assert!(error.to_string().contains("xml")); + + let error = parse_cli_from(["binmeta", "--unknown"]).expect_err("bad flag rejected"); + assert!(error.to_string().contains("unsupported argument")); + + let error = run(&Cli { + common: common_args(false, InputFormat::Auto), + paths: Vec::new(), + }) + .expect_err("empty path list rejected"); + assert!(error.to_string().contains("provide at least one path")); + } + + #[test] + fn inspect_path_rejects_files_above_size_limit_before_reading() { + let path = Path::new("huge.bin"); + let error = ensure_file_size_at_most(path, MAX_INSPECT_BYTES + 1, MAX_INSPECT_BYTES) + .expect_err("oversize file rejected"); + assert!( + error + .to_string() + .contains("above the binmeta inspection limit") + ); + } + + #[test] + fn subsystem_labels_and_hash_helpers_are_stable() { + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI), + "windows_cui" + ); + assert_eq!(subsystem_label(subsystem::IMAGE_SUBSYSTEM_NATIVE), "native"); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI), + "windows_gui" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_EFI_APPLICATION), + "efi_application" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION), + "windows_boot_application" + ); + assert_eq!(subsystem_label(0xffff), "unknown"); + assert_eq!(kind_label(BinaryKind::Pe), "pe"); + assert_eq!(kind_label(BinaryKind::NotPe), "not_pe"); + assert_eq!(role_label(PeRole::Exe), "exe"); + assert_eq!(role_label(PeRole::Dll), "dll"); + assert_eq!(signature_status_label(SignatureStatus::Valid), "valid"); + assert_eq!( + signature_status_label(SignatureStatus::Untrusted), + "untrusted" + ); + assert_eq!(signature_status_label(SignatureStatus::Expired), "expired"); + assert_eq!(signature_status_label(SignatureStatus::Revoked), "revoked"); + assert_eq!( + signature_status_label(SignatureStatus::BadDigest), + "bad_digest" + ); + assert_eq!( + signature_status_label(SignatureStatus::Unsupported), + "unsupported" + ); + assert_eq!(signature_status_label(SignatureStatus::Error), "error"); + assert_eq!(signature_type_label(SignatureType::Embedded), "embedded"); + assert_eq!(signature_type_label(SignatureType::Catalog), "catalog"); + assert_eq!(signature_type_label(SignatureType::Unknown), "unknown"); + assert_eq!(signature_type_label(SignatureType::None), "none"); + assert_eq!(bytes_to_hex(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef"); + assert_eq!(nibble_to_hex(15), 'f'); + assert_eq!(nibble_to_hex(99), '0'); + assert_eq!(short_hash("0123456789abcdef"), "0123456789ab"); + assert_eq!(short_hash("short"), "short"); + assert_eq!(quote_if_needed("plain"), "plain"); + assert_eq!(quote_if_needed("two words"), "\"two words\""); + assert_eq!(quote_if_needed("say \"hi\""), "\"say \\\"hi\\\"\""); + assert_eq!( + non_empty(Some(" value ".to_owned())), + Some("value".to_owned()) + ); + assert_eq!(non_empty(Some(" ".to_owned())), None); + assert_eq!( + extension_label(Path::new("C:/tools/DEMO.EXE")).as_deref(), + Some("exe") + ); + assert_eq!(extension_label(Path::new("C:/tools/demo")).as_deref(), None); + assert!(looks_like_pe(b"MZ")); + assert!(!looks_like_pe(b"ZM")); + } + + #[test] + fn heuristics_detect_known_markers() { + let hints = detect_hints( + Path::new("GameAssembly.dll"), + b"BepInEx UnityEngine il2cpp mono-2.0", + None, + ); + assert!(hints.unity); + assert!(hints.il2cpp); + assert!(hints.mono); + assert!(hints.bepinex); + assert_eq!(hints.labels(), vec!["unity", "il2cpp", "mono", "bepinex"]); + assert!(contains_ascii_case_insensitive( + b"UnityEngine", + b"unityengine" + )); + assert!(!contains_ascii_case_insensitive(b"abc", b"")); + } + + #[test] + fn signature_metadata_defaults_distinguish_unsigned_and_embedded_tables() { + let unsigned = extract_signature_metadata_from_parts(Path::new("plain.exe"), false, 0, 0); + assert!(!unsigned.embedded_present); + assert_eq!(unsigned.embedded_certificate_count, 0); + assert!(matches!( + unsigned.status, + SignatureStatus::NotSigned | SignatureStatus::Error + )); + assert!(matches!( + unsigned.signature_type, + SignatureType::None | SignatureType::Unknown + )); + assert!(!unsigned.catalog_signed); + assert!( + unsigned.status_message.contains("no embedded certificate") + || unsigned.status_message.contains("WinTrust") + || unsigned.status_message.contains("failed") + ); + + let embedded = + extract_signature_metadata_from_parts(Path::new("signed-ish.exe"), true, 2, 4096); + assert!(embedded.embedded_present); + assert_eq!(embedded.embedded_certificate_count, 2); + assert_eq!(embedded.embedded_certificate_table_size, 4096); + assert!(matches!( + embedded.status, + SignatureStatus::Unsupported | SignatureStatus::Error + )); + assert!(matches!( + embedded.signature_type, + SignatureType::Unknown | SignatureType::Embedded + )); + } + + #[test] + fn hint_detection_uses_metadata_and_filename_context() { + let pe = PeMetadata { + architecture: "x64".to_owned(), + machine: "amd64".to_owned(), + machine_raw: 0x8664, + subsystem: "windows_cui".to_owned(), + subsystem_raw: subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI, + role: PeRole::Exe, + bitness: 64, + timestamp_rfc3339: "1970-01-01T00:00:00Z".to_owned(), + timestamp_unix: 0, + section_count: 0, + sections: Vec::new(), + library_count: 0, + libraries: Vec::new(), + import_symbol_count: 0, + imports_by_library: Vec::new(), + export_count: 0, + has_clr: true, + }; + let hints = detect_hints( + Path::new("Assembly-CSharp.dll"), + b"global-metadata.dat doorstop", + Some(&pe), + ); + assert!(hints.dotnet); + assert!(hints.unity); + assert!(hints.il2cpp); + assert!(hints.bepinex); + } + + #[test] + fn ascii_hint_scanner_matches_scalar_case_insensitive_search() { + let bytes = b"MSCOREE.DLL unityengine UNITYPLAYER il2CPP GLOBAL-METADATA.DAT \ + Mono-2.0 MONO.DLL monobdwgc BepInEx DOORSTOP"; + let hints = scan_ascii_hint_markers(bytes); + + assert_eq!( + hints.dotnet, + contains_ascii_case_insensitive(bytes, b"mscoree.dll") + ); + assert_eq!( + hints.unity, + contains_ascii_case_insensitive(bytes, b"UnityEngine") + || contains_ascii_case_insensitive(bytes, b"UnityPlayer") + ); + assert_eq!( + hints.il2cpp, + contains_ascii_case_insensitive(bytes, b"il2cpp") + || contains_ascii_case_insensitive(bytes, b"global-metadata.dat") + ); + assert_eq!( + hints.mono, + contains_ascii_case_insensitive(bytes, b"mono-2.0") + || contains_ascii_case_insensitive(bytes, b"mono.dll") + || contains_ascii_case_insensitive(bytes, b"monobdwgc") + ); + assert_eq!( + hints.bepinex, + contains_ascii_case_insensitive(bytes, b"BepInEx") + || contains_ascii_case_insensitive(bytes, b"doorstop") + ); + } + + #[test] + fn inspect_path_covers_not_pe_and_broken_pe_fallbacks() { + let temp = tempdir().expect("tempdir"); + let plain = temp.path().join("plain.txt"); + let broken = temp.path().join("broken.exe"); + fs::write(&plain, b"plain-text").expect("plain fixture"); + fs::write(&broken, b"MZbroken").expect("broken fixture"); + + let plain_report = inspect_path(&plain).expect("plain report"); + assert_eq!(plain_report.kind, BinaryKind::NotPe); + assert_eq!(plain_report.extension.as_deref(), Some("txt")); + assert!(plain_report.parse_error.is_none()); + assert!(plain_report.pe.is_none()); + assert!(render_text_report(&plain_report).contains("kind=not_pe")); + + let broken_report = inspect_path(&broken).expect("broken report"); + assert_eq!(broken_report.kind, BinaryKind::NotPe); + assert_eq!(broken_report.extension.as_deref(), Some("exe")); + assert!(broken_report.parse_error.is_some()); + assert!(render_text_report(&broken_report).contains("parse_error=")); + } + + #[test] + fn inspect_path_reports_truncated_pe_without_panicking() { + let temp = tempdir().expect("tempdir"); + let truncated = temp.path().join("truncated.exe"); + let mut bytes = vec![0_u8; 0x40]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + fs::write(&truncated, bytes).expect("truncated fixture"); + + let report = inspect_path(&truncated).expect("truncated report"); + + assert_eq!(report.kind, BinaryKind::NotPe); + assert_eq!(report.extension.as_deref(), Some("exe")); + assert!(report.pe.is_none()); + assert!( + report + .parse_error + .as_deref() + .is_some_and(|message| message.contains("PE")) + ); + assert!(render_text_report(&report).contains("parse_error=")); + } + + #[test] + fn inspect_path_preserves_pe_identity_when_section_table_is_truncated() { + let temp = tempdir().expect("tempdir"); + let malformed = temp.path().join("missing-sections.exe"); + fs::write(&malformed, malformed_pe_missing_section_table()).expect("malformed fixture"); + + let report = inspect_path(&malformed).expect("malformed PE report"); + + assert_eq!(report.kind, BinaryKind::Pe); + assert!(report.parse_error.is_some()); + let pe = report.pe.as_ref().expect("header-only PE metadata"); + assert_eq!(pe.machine_raw, 0x8664); + assert_eq!(pe.bitness, 64); + assert_eq!(pe.subsystem, "windows_cui"); + assert!(pe.sections.is_empty()); + assert!(render_text_report(&report).contains("parse_error=")); + } + + #[test] + fn inspect_path_preserves_pe_identity_when_import_or_resource_directory_is_malformed() { + for (name, directory_index, expected_error) in [ + ("bad-import.exe", 1, "import"), + ("bad-resource.exe", 2, "Resource"), + ] { + let temp = tempdir().expect("tempdir"); + let malformed = temp.path().join(name); + fs::write( + &malformed, + malformed_pe_with_data_directory_rva(directory_index, 0x9000, 0x100), + ) + .expect("malformed fixture"); + + let report = inspect_path(&malformed).expect("malformed PE report"); + + assert_eq!(report.kind, BinaryKind::Pe); + assert!( + report + .parse_error + .as_deref() + .is_some_and(|message| message.contains(expected_error)), + "unexpected parse error for {name}: {:?}", + report.parse_error + ); + let pe = report.pe.as_ref().expect("header-only PE metadata"); + assert_eq!(pe.section_count, 1); + assert_eq!(pe.import_symbol_count, 0); + assert_eq!(pe.export_count, 0); + } + } + + #[test] + fn inspect_path_bounds_malformed_export_directory_without_fake_exports() { + let temp = tempdir().expect("tempdir"); + let malformed = temp.path().join("bad-export.exe"); + fs::write(&malformed, malformed_pe_export_directory_with_bad_tables()) + .expect("malformed fixture"); + + let report = inspect_path(&malformed).expect("malformed PE report"); + + assert_eq!(report.kind, BinaryKind::Pe); + let pe = report.pe.as_ref().expect("PE metadata"); + assert_eq!(pe.section_count, 1); + assert_eq!(pe.export_count, 0); + assert!(pe.libraries.is_empty()); + } + + #[cfg(windows)] + #[test] + fn inspect_path_extracts_pe_metadata_from_current_test_binary() { + let current_exe = std::env::current_exe().expect("current exe"); + let report = inspect_path(¤t_exe).expect("pe report"); + let pe = report.pe.as_ref().expect("pe metadata"); + + assert_eq!(report.kind, BinaryKind::Pe); + assert_eq!(report.extension.as_deref(), Some("exe")); + assert!(report.parse_error.is_none()); + assert!(pe.section_count > 0); + assert!(!pe.machine.is_empty()); + assert!(!pe.architecture.is_empty()); + assert!(!pe.subsystem.is_empty()); + assert_eq!(pe.library_count > 0, !pe.libraries.is_empty()); + assert_eq!(report.hints, report.heuristics.labels()); + + let rendered = render_text_report(&report); + assert!(rendered.contains("kind=pe")); + assert!(rendered.contains("arch=")); + assert!(rendered.contains("imports=")); + } + + #[test] + fn run_accepts_explicit_paths_for_text_and_json_output() { + let temp = tempdir().expect("tempdir"); + let plain = temp.path().join("plain.txt"); + fs::write(&plain, b"plain-text").expect("plain fixture"); + + let text_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + paths: vec![plain.clone()], + }) + .expect("text run"); + assert_eq!(text_exit, ExitCode::Success); + + let json_exit = run(&Cli { + common: common_args(true, InputFormat::Lines), + paths: vec![plain], + }) + .expect("json run"); + assert_eq!(json_exit, ExitCode::Success); + } + + #[test] + fn render_text_report_includes_pe_details_and_hint_labels() { + let report = BinaryReport { + path: PathBuf::from("C:\\tools\\demo.exe"), + kind: BinaryKind::Pe, + extension: Some("exe".to_string()), + size_bytes: 42, + modified_rfc3339: "2026-04-21T00:00:00Z".to_string(), + sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + hints: vec!["unity".to_string(), "bepinex".to_string()], + heuristics: HeuristicHints { + dotnet: false, + unity: true, + il2cpp: false, + mono: false, + bepinex: true, + }, + pe: Some(PeMetadata { + architecture: "x86_64".to_string(), + machine: "X86_64".to_string(), + machine_raw: 0x8664, + subsystem: "windows_cui".to_string(), + subsystem_raw: subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI, + role: PeRole::Exe, + bitness: 64, + timestamp_rfc3339: "2026-04-21T00:00:00Z".to_string(), + timestamp_unix: 1, + section_count: 3, + sections: vec![SectionSummary { + name: ".text".to_string(), + virtual_size: 1, + virtual_address: 2, + raw_size: 3, + raw_offset: 4, + }], + library_count: 2, + libraries: vec!["KERNEL32.dll".to_string(), "USER32.dll".to_string()], + import_symbol_count: 5, + imports_by_library: vec![ImportLibrarySummary { + library: "KERNEL32.dll".to_string(), + symbol_count: 5, + }], + export_count: 1, + has_clr: false, + }), + version: Some(VersionMetadata { + file_version: Some("1.2.3.4 demo".to_string()), + product_version: None, + company_name: Some("Acme Tools".to_string()), + file_description: None, + product_name: None, + original_filename: None, + internal_name: None, + legal_copyright: None, + fixed_file_version: Some("1.2.3.4".to_string()), + fixed_product_version: None, + file_flags: None, + file_type: None, + file_subtype: None, + language_codepages: Vec::new(), + }), + signature: Some(SignatureMetadata { + embedded_present: false, + embedded_certificate_count: 0, + embedded_certificate_table_size: 0, + status: SignatureStatus::NotSigned, + status_message: "no signature".to_string(), + signature_type: SignatureType::None, + catalog_signed: false, + catalog_path: None, + catalog_member_tag: None, + signer: None, + chain: Vec::new(), + timestamp: None, + warnings: Vec::new(), + }), + parse_error: Some("sample".to_string()), + }; + + let rendered = render_text_report(&report); + assert!(rendered.contains("kind=pe")); + assert!(rendered.contains("hints=unity,bepinex")); + assert!(rendered.contains("machine=X86_64")); + assert!(rendered.contains("role=exe")); + assert!(rendered.contains("version=\"1.2.3.4 demo\"")); + assert!(rendered.contains("company=\"Acme Tools\"")); + assert!(rendered.contains("sig=not_signed")); + assert!(rendered.contains("parse_error=sample")); + } + + fn malformed_pe_missing_section_table() -> Vec { + let pe_offset = 0x80_usize; + let optional_size = 0xF0_u16; + let mut bytes = vec![0_u8; pe_offset + 4 + 20 + usize::from(optional_size)]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + + let coff = pe_offset + 4; + bytes[pe_offset..coff].copy_from_slice(b"PE\0\0"); + bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes()); + bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes()); + bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes()); + bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes()); + + let optional = coff + 20; + bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes()); + bytes[optional + 68..optional + 70] + .copy_from_slice(&subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI.to_le_bytes()); + bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes()); + bytes + } + + fn malformed_pe_with_data_directory_rva( + directory_index: usize, + rva: u32, + size: u32, + ) -> Vec { + let (mut bytes, _) = minimal_pe_with_one_section(); + let optional = 0x80_usize + 4 + 20; + let directory = optional + 112 + (directory_index * 8); + bytes[directory..directory + 4].copy_from_slice(&rva.to_le_bytes()); + bytes[directory + 4..directory + 8].copy_from_slice(&size.to_le_bytes()); + bytes + } + + fn malformed_pe_export_directory_with_bad_tables() -> Vec { + let (mut bytes, export_offset) = minimal_pe_with_one_section(); + let optional = 0x80_usize + 4 + 20; + bytes[optional + 112..optional + 116].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 116..optional + 120].copy_from_slice(&0x28_u32.to_le_bytes()); + bytes[export_offset + 12..export_offset + 16].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 16..export_offset + 20].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 20..export_offset + 24].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 24..export_offset + 28].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 28..export_offset + 32].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 32..export_offset + 36].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 36..export_offset + 40].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes + } + + fn minimal_pe_with_one_section() -> (Vec, usize) { + let pe_offset = 0x80_usize; + let optional_size = 0xF0_u16; + let section_size = 40_usize; + let section_raw_offset = pe_offset + 4 + 20 + usize::from(optional_size) + section_size; + let mut bytes = vec![0_u8; section_raw_offset + 0x200]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + + let coff = pe_offset + 4; + bytes[pe_offset..coff].copy_from_slice(b"PE\0\0"); + bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes()); + bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes()); + bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes()); + bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes()); + + let optional = coff + 20; + bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes()); + bytes[optional + 16..optional + 20].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 24..optional + 32].copy_from_slice(&0x1400_0000_u64.to_le_bytes()); + bytes[optional + 32..optional + 36].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 36..optional + 40].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[optional + 68..optional + 70] + .copy_from_slice(&subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI.to_le_bytes()); + bytes[optional + 80..optional + 84].copy_from_slice(&0x2000_u32.to_le_bytes()); + bytes[optional + 84..optional + 88].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes()); + + let section = optional + usize::from(optional_size); + bytes[section..section + 8].copy_from_slice(b".rdata\0\0"); + bytes[section + 8..section + 12].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[section + 12..section + 16].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[section + 16..section + 20].copy_from_slice(&0x200_u32.to_le_bytes()); + let section_raw_offset_u32 = + u32::try_from(section_raw_offset).expect("fixture raw offset fits u32"); + bytes[section + 20..section + 24].copy_from_slice(§ion_raw_offset_u32.to_le_bytes()); + (bytes, section_raw_offset) + } +} diff --git a/crates/binmeta/src/main.rs b/crates/binmeta/src/main.rs new file mode 100644 index 0000000..d81aa27 --- /dev/null +++ b/crates/binmeta/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `binmeta`. + +fn main() { + std::process::exit(binmeta::main_entry()); +} diff --git a/crates/binmeta/tests/binmeta_cli.rs b/crates/binmeta/tests/binmeta_cli.rs new file mode 100644 index 0000000..607f342 --- /dev/null +++ b/crates/binmeta/tests/binmeta_cli.rs @@ -0,0 +1,182 @@ +//! Integration tests for the `binmeta` command. + +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; + +fn cargo_command() -> Command { + Command::cargo_bin("binmeta").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path) +} + +fn inspect_json(path: &Path) -> Value { + let mut command = cargo_command(); + let output = command + .arg(path) + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + serde_json::from_slice::(&output).expect("json payload") +} + +#[test] +fn help_includes_examples_and_json_pipeline_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "binmeta .\\fixtures\\binmeta\\plain.txt", + )) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("--input-format")); +} + +#[test] +fn inspects_plain_text_fixture_as_not_pe() { + let input = fixture("binmeta/plain.txt"); + let payload = inspect_json(&input); + let entries = payload.as_array().expect("array payload"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["kind"], "not_pe"); + assert_eq!(entries[0]["extension"], "txt"); + assert_eq!(entries[0]["sha256"].as_str().map(str::len), Some(64)); + assert!( + entries[0]["size_bytes"] + .as_u64() + .is_some_and(|value| value > 0) + ); + assert!(entries[0]["pe"].is_null()); + assert!(entries[0]["version"].is_null()); + assert!(entries[0]["signature"].is_null()); +} + +#[test] +fn supports_powershell_pipeline_input() { + let binary = assert_cmd::cargo::cargo_bin("binmeta"); + let input = fixture("binmeta/plain.txt"); + let script = format!("'{}' | & '{}' --json", input.display(), binary.display()); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"kind\":\"not_pe\"")); +} + +#[cfg(windows)] +#[test] +fn inspects_current_test_binary_as_pe() { + let current_exe = std::env::current_exe().expect("current exe"); + let payload = inspect_json(¤t_exe); + let entry = &payload.as_array().expect("array payload")[0]; + + assert_eq!(entry["kind"], "pe"); + assert_eq!(entry["path"], current_exe.display().to_string()); + assert_eq!(entry["sha256"].as_str().map(str::len), Some(64)); + assert!( + entry["pe"]["machine"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + assert!( + entry["pe"]["architecture"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + assert!( + entry["pe"]["section_count"] + .as_u64() + .is_some_and(|value| value > 0) + ); + assert!( + entry["pe"]["sections"] + .as_array() + .is_some_and(|value| !value.is_empty()) + ); + assert!(entry["pe"]["library_count"].as_u64().is_some()); + assert!(entry.get("version").is_some()); + assert!(entry.get("signature").is_some()); + assert_eq!(entry["signature"]["status"], "not_signed"); + assert_eq!(entry["signature"]["signature_type"], "none"); +} + +#[cfg(windows)] +#[test] +fn inspects_windows_binary_version_and_catalog_signature() { + let notepad = PathBuf::from(r"C:\Windows\System32\notepad.exe"); + if !notepad.is_file() { + eprintln!( + "skipping notepad signature smoke: {} missing", + notepad.display() + ); + return; + } + + let payload = inspect_json(¬epad); + let entry = &payload.as_array().expect("array payload")[0]; + + assert_eq!(entry["kind"], "pe"); + assert!( + entry["version"]["company_name"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + assert!( + entry["version"]["file_version"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + assert_eq!(entry["signature"]["status"], "valid"); + assert_eq!(entry["signature"]["catalog_signed"], true); + assert_eq!(entry["signature"]["signature_type"], "catalog"); + assert!( + entry["signature"]["signer"]["subject"] + .as_str() + .is_some_and(|value| value.contains("Microsoft")) + ); +} + +#[cfg(windows)] +#[test] +fn malformed_version_resource_still_reports_pe_identity_when_reference_exists() { + let nuitka_exe = PathBuf::from(r"C:\Users\example\Desktop\SampleTool.exe"); + if !nuitka_exe.is_file() { + eprintln!( + "skipping Nuitka resource compatibility smoke: {} missing", + nuitka_exe.display() + ); + return; + } + + let payload = inspect_json(&nuitka_exe); + let entry = &payload.as_array().expect("array payload")[0]; + assert_eq!(entry["kind"], "pe"); + assert_eq!(entry["version"]["company_name"], "Example Corp"); + assert_eq!( + entry["version"]["file_description"], + "Codex Thread Importer" + ); + assert_eq!(entry["version"]["product_name"], "Codex Thread Importer"); + assert!( + entry["parse_error"] + .as_str() + .is_some_and(|message| message.contains("ResourceString value_len")) + ); +} diff --git a/crates/chunkcat/Cargo.toml b/crates/chunkcat/Cargo.toml new file mode 100644 index 0000000..1ab804e --- /dev/null +++ b/crates/chunkcat/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "chunkcat" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "List and read deterministic chunks from large text files." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/chunkcat/src/lib.rs b/crates/chunkcat/src/lib.rs new file mode 100644 index 0000000..ec0c691 --- /dev/null +++ b/crates/chunkcat/src/lib.rs @@ -0,0 +1,1015 @@ +//! The `chunkcat` command reads deterministic chunks from text files. + +use std::collections::VecDeque; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, BufRead, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +const HELP: &str = "\ +List and read deterministic chunks from large text files. + +Default text mode prints the first chunk for quick reading. Use `--inventory` to list chunk +boundaries first, or `--chunk ` / `--tail` to jump elsewhere. + +Usage: + chunkcat [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --max-lines Maximum number of lines per chunk + --overlap Number of overlapping lines between chunks + --inventory Print chunk inventory instead of default first-chunk text + --chunk Zero-based chunk index to print + --tail Print the last chunk instead of a numbered chunk + -h, --help Show this help text + -V, --version Show the command version + +Examples: + chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 + chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 --inventory + chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 --chunk 0 + chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 --chunk 2 --json | ConvertFrom-Json + chunkcat .\\BepInEx\\LogOutput.log --max-lines 20 --tail + fd -e rs . .\\fixtures\\reading | chunkcat --input-format lines --chunk 0 +"; + +/// CLI arguments for the `chunkcat` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Maximum number of lines per chunk. + max_lines: usize, + /// Number of lines to overlap between adjacent chunks. + overlap: usize, + /// Print chunk inventory instead of the default first-chunk text view. + inventory: bool, + /// Zero-based chunk index to print instead of the chunk inventory. + chunk: Option, + /// Print the last chunk instead of a numbered chunk. + tail: bool, + /// File path to inspect when stdin is empty. + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ChunkDescriptor { + index: usize, + start_line: usize, + end_line: usize, + line_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ChunkLine { + number: usize, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SelectedChunk { + index: usize, + start_line: usize, + end_line: usize, + line_count: usize, + lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ChunkReport { + path: String, + total_lines: usize, + max_lines: usize, + overlap: usize, + chunk_count: usize, + chunks: Vec, + selected_chunk: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LineSelection { + Window { start_line: usize, end_line: usize }, + Tail { keep_lines: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoadedText { + total_lines: usize, + lines: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("chunkcat {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + max_lines: 200, + overlap: 0, + inventory: false, + chunk: None, + tail: false, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("max-lines") => { + cli.max_lines = parse_usize_flag( + "--max-lines", + &parser_value_string(&mut parser, "--max-lines")?, + )?; + } + Long("overlap") => { + cli.overlap = + parse_usize_flag("--overlap", &parser_value_string(&mut parser, "--overlap")?)?; + } + Long("inventory") => cli.inventory = true, + Long("chunk") => { + cli.chunk = Some(parse_usize_flag( + "--chunk", + &parser_value_string(&mut parser, "--chunk")?, + )?); + } + Long("tail") => cli.tail = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + if value.trim_start().starts_with('-') { + return Err(CliError::usage(format!( + "invalid {flag} value '{value}': must be a non-negative integer" + ))); + } + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + validate_options(cli.max_lines, cli.overlap)?; + validate_selection_mode(cli)?; + let path = collect_path(cli)?; + let render_mode = cli.common.render_mode(); + let prefer_default_chunk = matches!(render_mode, RenderMode::Text) && !cli.inventory; + let loaded = read_text_file(&path, line_selection(cli))?; + let path_text = path.display().to_string(); + let chunk_count = chunk_count(loaded.total_lines, cli.max_lines, cli.overlap); + let selected_index = selected_chunk_index(cli, chunk_count, prefer_default_chunk); + + if matches!(render_mode, RenderMode::Text) && !cli.inventory { + if let Some(index) = selected_index { + let selected_chunk = select_chunk( + loaded.total_lines, + cli.max_lines, + cli.overlap, + &loaded.lines, + index, + )?; + print!("{}", render_selected_chunk(&path_text, &selected_chunk)); + return Ok(map_result_count(1)); + } + } + + let chunks = plan_chunks(loaded.total_lines, cli.max_lines, cli.overlap); + let selected_chunk = selected_index + .map(|index| { + select_chunk( + loaded.total_lines, + cli.max_lines, + cli.overlap, + &loaded.lines, + index, + ) + }) + .transpose()?; + let report = ChunkReport { + path: path_text, + total_lines: loaded.total_lines, + max_lines: cli.max_lines, + overlap: cli.overlap, + chunk_count, + chunks, + selected_chunk, + }; + + match render_mode { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => { + if let Some(selected_chunk) = &report.selected_chunk { + print!("{}", render_selected_chunk(&report.path, selected_chunk)); + } else { + print!("{}", render_inventory(&report)); + } + } + } + + let result_count = report + .selected_chunk + .as_ref() + .map_or(report.chunk_count, |_| 1); + Ok(map_result_count(result_count)) +} + +fn validate_options(max_lines: usize, overlap: usize) -> Result<(), CliError> { + if max_lines == 0 { + return Err(CliError::usage("--max-lines must be greater than 0")); + } + if overlap >= max_lines { + return Err(CliError::usage( + "--overlap must be smaller than --max-lines", + )); + } + Ok(()) +} + +fn validate_selection_mode(cli: &Cli) -> Result<(), CliError> { + if cli.inventory && (cli.chunk.is_some() || cli.tail) { + return Err(CliError::usage( + "--inventory cannot be combined with --chunk or --tail", + )); + } + Ok(()) +} + +const fn selected_chunk_index( + cli: &Cli, + chunk_count: usize, + prefer_default_chunk: bool, +) -> Option { + if cli.tail { + chunk_count.checked_sub(1) + } else if chunk_count > 0 && cli.chunk.is_none() && (chunk_count == 1 || prefer_default_chunk) { + Some(0) + } else { + cli.chunk + } +} + +fn line_selection(cli: &Cli) -> LineSelection { + if cli.tail { + LineSelection::Tail { + keep_lines: cli.max_lines, + } + } else { + let chunk_index = cli.chunk.unwrap_or(0); + let (start_line, end_line) = chunk_line_window(chunk_index, cli.max_lines, cli.overlap); + LineSelection::Window { + start_line, + end_line, + } + } +} + +fn collect_path(cli: &Cli) -> Result { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let stdin_paths = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !stdin_paths.is_empty() { + return single_path(&stdin_paths); + } + } + + single_path(&common::expand_input_patterns(&cli.paths, "chunkcat")?) +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "chunkcat")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn single_path(paths: &[PathBuf]) -> Result { + match paths { + [path] => Ok(path.clone()), + [] => Err(CliError::usage( + "provide exactly one path or pipe one path into stdin", + )), + _ => Err(CliError::usage("chunkcat accepts exactly one path in v1")), + } +} + +fn read_text_file(path: &Path, selection: LineSelection) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(CliError::usage(format!( + "{} is not a regular file", + path.display() + ))); + } + + let file = fs::File::open(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let mut reader = io::BufReader::new(file); + let mut buffer = Vec::new(); + let mut total_lines = 0_usize; + let mut lines = Vec::new(); + let mut tail_lines = VecDeque::new(); + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + if bytes_read == 0 { + break; + } + + let line_text = text_line_from_bytes(path, &buffer)?; + total_lines += 1; + + match selection { + LineSelection::Window { + start_line, + end_line, + } => { + if total_lines >= start_line && total_lines <= end_line { + lines.push(ChunkLine { + number: total_lines, + text: line_text.to_string(), + }); + } + } + LineSelection::Tail { keep_lines } => { + if keep_lines > 0 { + tail_lines.push_back(ChunkLine { + number: total_lines, + text: line_text.to_string(), + }); + while tail_lines.len() > keep_lines { + tail_lines.pop_front(); + } + } + } + } + } + + if matches!(selection, LineSelection::Tail { .. }) { + lines = tail_lines.into_iter().collect(); + } + + Ok(LoadedText { total_lines, lines }) +} + +fn text_line_from_bytes<'a>(path: &Path, bytes: &'a [u8]) -> Result<&'a str, CliError> { + if bytes.contains(&0) { + return Err(binary_file_error(path)); + } + + let text = std::str::from_utf8(bytes).map_err(|_| binary_file_error(path))?; + let text = text.strip_suffix('\n').map_or(text, |without_lf| { + without_lf.strip_suffix('\r').unwrap_or(without_lf) + }); + Ok(text.trim_start_matches('\u{feff}')) +} + +fn binary_file_error(path: &Path) -> CliError { + CliError::usage(format!( + "{} looks like a binary file; chunkcat only reads text files", + path.display() + )) +} + +fn plan_chunks(total_lines: usize, max_lines: usize, overlap: usize) -> Vec { + (0..chunk_count(total_lines, max_lines, overlap)) + .filter_map(|index| chunk_descriptor_at(total_lines, max_lines, overlap, index)) + .collect() +} + +const fn chunk_count(total_lines: usize, max_lines: usize, overlap: usize) -> usize { + if total_lines == 0 { + 0 + } else { + let stride = max_lines - overlap; + ((total_lines - 1) / stride) + 1 + } +} + +fn chunk_descriptor_at( + total_lines: usize, + max_lines: usize, + overlap: usize, + index: usize, +) -> Option { + let chunk_count = chunk_count(total_lines, max_lines, overlap); + if index >= chunk_count { + return None; + } + + let (start_line, requested_end_line) = chunk_line_window(index, max_lines, overlap); + let end_line = requested_end_line.min(total_lines); + Some(ChunkDescriptor { + index, + start_line, + end_line, + line_count: end_line - start_line + 1, + }) +} + +const fn chunk_line_window(chunk_index: usize, max_lines: usize, overlap: usize) -> (usize, usize) { + let stride = max_lines - overlap; + let start_line = chunk_index.saturating_mul(stride).saturating_add(1); + let end_line = start_line.saturating_add(max_lines - 1); + (start_line, end_line) +} + +fn select_chunk( + total_lines: usize, + max_lines: usize, + overlap: usize, + lines: &[ChunkLine], + chunk_index: usize, +) -> Result { + let descriptor = + chunk_descriptor_at(total_lines, max_lines, overlap, chunk_index).ok_or_else(|| { + CliError::usage(format!( + "chunk index {chunk_index} is out of range for {} chunks", + chunk_count(total_lines, max_lines, overlap) + )) + })?; + let selected_lines = lines + .iter() + .filter(|line| line.number >= descriptor.start_line && line.number <= descriptor.end_line) + .cloned() + .collect::>(); + if selected_lines.len() != descriptor.line_count { + return Err(CliError::runtime(format!( + "failed to collect lines {}:{} from streamed input", + descriptor.start_line, descriptor.end_line + ))); + } + + Ok(SelectedChunk { + index: descriptor.index, + start_line: descriptor.start_line, + end_line: descriptor.end_line, + line_count: descriptor.line_count, + lines: selected_lines, + }) +} + +fn render_inventory(report: &ChunkReport) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "path={} total_lines={} chunks={} max_lines={} overlap={}", + report.path, report.total_lines, report.chunk_count, report.max_lines, report.overlap + ) + .expect("writing to a String cannot fail"); + writeln!( + rendered, + "summary_only=true hint=use --chunk or --tail to print content" + ) + .expect("writing to a String cannot fail"); + + for chunk in &report.chunks { + writeln!( + rendered, + "{} lines={}:{} count={}", + chunk.index, chunk.start_line, chunk.end_line, chunk.line_count + ) + .expect("writing to a String cannot fail"); + } + + rendered +} + +fn render_selected_chunk(path: &str, chunk: &SelectedChunk) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "path={} chunk={} lines={}:{} count={}", + path, chunk.index, chunk.start_line, chunk.end_line, chunk.line_count + ) + .expect("writing to a String cannot fail"); + + for line in &chunk.lines { + writeln!(rendered, "{}: {}", line.number, line.text) + .expect("writing to a String cannot fail"); + } + + rendered +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_paths_support_lines_and_jsonl() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("sample.rs"); + let second = temp.path().join("other.rs"); + fs::write(&first, "a").expect("first"); + fs::write(&second, "b").expect("second"); + assert_eq!( + parse_paths_from_string(&format!("{}\n", first.display()), InputFormat::Lines) + .expect("lines"), + vec![first.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("json path"), + serde_json::to_string(&second.display().to_string()).expect("json path") + ), + InputFormat::Jsonl, + ) + .expect("jsonl"), + vec![second, first] + ); + } + + #[test] + fn planning_chunks_respects_overlap_and_short_final_chunk() { + let chunks = plan_chunks(35, 8, 2); + assert_eq!(chunks.len(), 6); + assert_eq!(chunks[0].start_line, 1); + assert_eq!(chunks[0].end_line, 8); + assert_eq!(chunks[2].start_line, 13); + assert_eq!(chunks[5].start_line, 31); + assert_eq!(chunks[5].end_line, 35); + } + + #[test] + fn selecting_and_rendering_chunks_include_line_numbers() { + let lines = (1..=12) + .map(|line| ChunkLine { + number: line, + text: format!("line {line}"), + }) + .collect::>(); + let chunks = plan_chunks(lines.len(), 5, 1); + let selected = select_chunk(lines.len(), 5, 1, &lines, 1).expect("selected chunk"); + assert_eq!(selected.start_line, 5); + assert_eq!(selected.end_line, 9); + assert_eq!(selected.lines[0].number, 5); + assert!(render_selected_chunk("demo.txt", &selected).contains("5: line 5")); + + let inventory = render_inventory(&ChunkReport { + path: "demo.txt".to_string(), + total_lines: 12, + max_lines: 5, + overlap: 1, + chunk_count: chunks.len(), + chunks, + selected_chunk: None, + }); + assert!(inventory.contains("chunks=3")); + assert!(inventory.contains("summary_only=true")); + assert!(inventory.contains("use --chunk or --tail to print content")); + assert!(inventory.contains("1 lines=5:9")); + } + + #[test] + fn read_text_file_rejects_binary_files() { + let temp = tempdir().expect("tempdir"); + let text_path = temp.path().join("sample.txt"); + let binary_path = temp.path().join("sample.bin"); + fs::write(&text_path, b"alpha\nbeta\n").expect("text fixture"); + fs::write(&binary_path, b"\x00\x01\xff").expect("binary fixture"); + + let loaded = read_text_file( + &text_path, + LineSelection::Window { + start_line: 1, + end_line: 2, + }, + ) + .expect("text lines"); + assert_eq!( + loaded.lines, + vec![ + ChunkLine { + number: 1, + text: "alpha".to_string(), + }, + ChunkLine { + number: 2, + text: "beta".to_string(), + }, + ] + ); + + let error = read_text_file( + &binary_path, + LineSelection::Window { + start_line: 1, + end_line: 0, + }, + ) + .expect_err("binary should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("looks like a binary file") + )); + } + + #[test] + fn read_text_file_keeps_only_requested_window() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("large.txt"); + let mut content = String::new(); + for line in 1..=1_000 { + writeln!(content, "line {line}").expect("fixture line"); + } + fs::write(&path, content).expect("fixture"); + + let loaded = read_text_file( + &path, + LineSelection::Window { + start_line: 400, + end_line: 404, + }, + ) + .expect("streamed text"); + + assert_eq!(loaded.total_lines, 1_000); + assert_eq!( + loaded.lines, + vec![ + ChunkLine { + number: 400, + text: "line 400".to_string(), + }, + ChunkLine { + number: 401, + text: "line 401".to_string(), + }, + ChunkLine { + number: 402, + text: "line 402".to_string(), + }, + ChunkLine { + number: 403, + text: "line 403".to_string(), + }, + ChunkLine { + number: 404, + text: "line 404".to_string(), + }, + ] + ); + } + + #[test] + fn read_text_file_keeps_bounded_tail_candidates() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("tail.txt"); + fs::write( + &path, + (1..=12) + .map(|line| format!("line {line}")) + .collect::>() + .join("\n"), + ) + .expect("fixture"); + + let loaded = + read_text_file(&path, LineSelection::Tail { keep_lines: 4 }).expect("streamed text"); + + assert_eq!(loaded.total_lines, 12); + assert_eq!(loaded.lines.len(), 4); + assert_eq!(loaded.lines[0].number, 9); + assert_eq!(loaded.lines[3].text, "line 12"); + } + + #[test] + fn read_text_file_rejects_binary_content_after_selected_window() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("late-binary.txt"); + fs::write(&path, b"line 1\nline 2\nline 3\n\x00\n").expect("fixture"); + + let error = read_text_file( + &path, + LineSelection::Window { + start_line: 1, + end_line: 1, + }, + ) + .expect_err("late binary should fail"); + + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("looks like a binary file") + )); + } + + #[test] + fn run_supports_inventory_and_selected_chunk_modes() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.txt"); + fs::write(&path, b"one\ntwo\nthree\nfour\nfive\n").expect("fixture"); + + let inventory_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + max_lines: 3, + overlap: 1, + inventory: true, + chunk: None, + tail: false, + paths: vec![path.clone()], + }) + .expect("inventory run"); + assert_eq!(inventory_exit, ExitCode::Success); + + let selected_exit = run(&Cli { + common: common_args(true, InputFormat::Auto), + max_lines: 3, + overlap: 1, + inventory: false, + chunk: Some(1), + tail: false, + paths: vec![path], + }) + .expect("selected run"); + assert_eq!(selected_exit, ExitCode::Success); + } + + #[test] + fn option_validation_and_path_selection_report_usage_errors() { + let error = validate_options(0, 0).expect_err("zero max lines should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("--max-lines must be greater than 0") + )); + + let error = validate_options(4, 4).expect_err("overlap should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("--overlap must be smaller") + )); + + let missing = single_path(&[]).expect_err("missing path should fail"); + assert!(matches!( + missing, + CliError::Usage(message) + if message.contains("provide exactly one path") + )); + + let multiple = single_path(&[PathBuf::from("a"), PathBuf::from("b")]) + .expect_err("multiple paths should fail"); + assert!(matches!( + multiple, + CliError::Usage(message) + if message.contains("exactly one path") + )); + + let negative = parse_usize_flag("--max-lines", "-1").expect_err("negative flag"); + assert!(matches!( + negative, + CliError::Usage(message) + if message.contains("must be a non-negative integer") + && message.contains("--max-lines") + && message.contains("-1") + )); + } + + #[test] + fn parsing_and_selection_cover_error_branches() { + let jsonl_error = + parse_paths_from_string("nope\n", InputFormat::Jsonl).expect_err("bad jsonl"); + assert!(matches!( + jsonl_error, + CliError::Usage(message) + if message.contains("stdin JSONL path line 1 is not valid JSON") + )); + + let auto_paths = parse_paths_from_string("{\"name\":\"demo\"}\n", InputFormat::Auto) + .expect("auto fallback"); + assert!(auto_paths.is_empty()); + + let lines = (1..=5) + .map(|line| ChunkLine { + number: line, + text: format!("line {line}"), + }) + .collect::>(); + let error = select_chunk(lines.len(), 3, 1, &lines, 9).expect_err("out of range chunk"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("out of range") + )); + } + + #[test] + fn read_text_file_and_run_cover_directory_and_empty_file_cases() { + let temp = tempdir().expect("tempdir"); + let empty_path = temp.path().join("empty.txt"); + fs::write(&empty_path, "").expect("empty fixture"); + + let directory_error = read_text_file( + temp.path(), + LineSelection::Window { + start_line: 1, + end_line: 0, + }, + ) + .expect_err("directory should fail"); + assert!(matches!( + directory_error, + CliError::Usage(message) + if message.contains("not a regular file") + )); + + let empty_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + max_lines: 5, + overlap: 0, + inventory: false, + chunk: None, + tail: false, + paths: vec![empty_path], + }) + .expect("empty run"); + assert_eq!(empty_exit, ExitCode::NoResults); + } + + #[test] + fn tail_mode_selects_last_chunk() { + let chunks = plan_chunks(15, 4, 0); + let cli = Cli { + common: common_args(false, InputFormat::Auto), + max_lines: 4, + overlap: 0, + inventory: false, + chunk: None, + tail: true, + paths: vec![PathBuf::from("demo.txt")], + }; + + assert_eq!(selected_chunk_index(&cli, chunks.len(), false), Some(3)); + } + + #[test] + fn single_chunk_files_auto_select_first_chunk() { + let chunks = plan_chunks(12, 20, 0); + let cli = Cli { + common: common_args(false, InputFormat::Auto), + max_lines: 20, + overlap: 0, + inventory: false, + chunk: None, + tail: false, + paths: vec![PathBuf::from("demo.txt")], + }; + assert_eq!(selected_chunk_index(&cli, chunks.len(), false), Some(0)); + } + + #[test] + fn multi_chunk_text_mode_defaults_to_the_first_chunk() { + let chunks = plan_chunks(12, 4, 0); + let cli = Cli { + common: common_args(false, InputFormat::Auto), + max_lines: 4, + overlap: 0, + inventory: false, + chunk: None, + tail: false, + paths: vec![PathBuf::from("demo.txt")], + }; + assert_eq!(selected_chunk_index(&cli, chunks.len(), true), Some(0)); + assert_eq!(selected_chunk_index(&cli, chunks.len(), false), None); + } + + #[test] + fn inventory_mode_rejects_explicit_chunk_selection() { + let cli = Cli { + common: common_args(false, InputFormat::Auto), + max_lines: 4, + overlap: 0, + inventory: true, + chunk: Some(1), + tail: false, + paths: vec![PathBuf::from("demo.txt")], + }; + assert!(matches!( + validate_selection_mode(&cli), + Err(CliError::Usage(message)) if message.contains("--inventory") + )); + } +} diff --git a/crates/chunkcat/src/main.rs b/crates/chunkcat/src/main.rs new file mode 100644 index 0000000..268c10e --- /dev/null +++ b/crates/chunkcat/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `chunkcat`. + +fn main() { + std::process::exit(chunkcat::main_entry()); +} diff --git a/crates/chunkcat/tests/chunkcat_cli.rs b/crates/chunkcat/tests/chunkcat_cli.rs new file mode 100644 index 0000000..8c1ce70 --- /dev/null +++ b/crates/chunkcat/tests/chunkcat_cli.rs @@ -0,0 +1,196 @@ +//! Integration tests for the `chunkcat` command. + +use std::fs; +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::{TempDir, tempdir}; + +const SAMPLE_RS: &str = "reading/sample.rs"; + +fn cargo_command() -> Command { + Command::cargo_bin("chunkcat").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path) +} + +fn temp_file(name: &str, contents: impl AsRef<[u8]>) -> (TempDir, PathBuf) { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join(name); + fs::write(&path, contents).expect("fixture"); + (dir, path) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +fn display_path(path: &Path) -> String { + path.display().to_string() +} + +#[test] +fn lists_chunk_inventory_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture(SAMPLE_RS)) + .arg("--max-lines") + .arg("8") + .arg("--overlap") + .arg("2") + .arg("--inventory") + .assert() + .success() + .stdout(predicate::str::contains("chunks=6")) + .stdout(predicate::str::contains("0 lines=1:8")) + .stdout(predicate::str::contains("5 lines=31:35")); +} + +#[test] +fn renders_selected_chunk_with_line_numbers() { + let mut command = cargo_command(); + command + .arg(fixture(SAMPLE_RS)) + .arg("--max-lines") + .arg("8") + .arg("--overlap") + .arg("2") + .arg("--chunk") + .arg("2") + .assert() + .success() + .stdout(predicate::str::contains("chunk=2 lines=13:20")) + .stdout(predicate::str::contains("13: pub enum Mode {")) + .stdout(predicate::str::contains("20: Mode::Safe")); +} + +#[test] +fn emits_json_inventory_and_selected_chunk_payloads() { + let mut inventory = cargo_command(); + let inventory_output = inventory + .arg("--json") + .arg(fixture(SAMPLE_RS)) + .arg("--max-lines") + .arg("8") + .arg("--overlap") + .arg("2") + .assert() + .success() + .get_output() + .stdout + .clone(); + let inventory_json = serde_json::from_slice::(&inventory_output).expect("inventory"); + assert_eq!(inventory_json["path"], display_path(&fixture(SAMPLE_RS))); + assert_eq!(inventory_json["total_lines"], 35); + assert_eq!(inventory_json["chunks"][0]["start_line"], 1); + assert_eq!(inventory_json["chunks"][5]["end_line"], 35); + assert!(inventory_json["selected_chunk"].is_null()); + + let mut selected = cargo_command(); + let selected_output = selected + .arg("--json") + .arg(fixture(SAMPLE_RS)) + .arg("--max-lines") + .arg("8") + .arg("--overlap") + .arg("2") + .arg("--chunk") + .arg("4") + .assert() + .success() + .get_output() + .stdout + .clone(); + let selected_json = serde_json::from_slice::(&selected_output).expect("selected"); + assert_eq!(selected_json["selected_chunk"]["index"], 4); + assert_eq!(selected_json["selected_chunk"]["start_line"], 25); + assert_eq!(selected_json["selected_chunk"]["lines"][0]["number"], 25); + assert_eq!( + selected_json["selected_chunk"]["lines"][0]["text"], + " match mode {" + ); +} + +#[test] +fn supports_powershell_pipeline_for_path_input() { + let binary = assert_cmd::cargo::cargo_bin("chunkcat"); + let input = fixture(SAMPLE_RS); + let script = format!( + "{} | & {} --json --max-lines 10 --chunk 1", + ps_quote(input.display()), + ps_quote(binary.display()) + ); + + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("\"selected_chunk\"")) + .stdout(predicate::str::contains("\"index\":1")); +} + +#[test] +fn utf8_bom_file_does_not_pollute_first_chunk_line() { + let (_dir, path) = temp_file("bom.txt", "\u{feff}alpha\nbeta\n"); + + let mut command = cargo_command(); + command + .arg(&path) + .arg("--max-lines") + .arg("2") + .arg("--chunk") + .arg("0") + .assert() + .success() + .stdout(predicate::str::contains("1: alpha")) + .stdout(predicate::str::contains("\u{feff}alpha").not()); +} + +#[test] +fn invalid_utf8_file_reports_text_read_error() { + let (_dir, path) = temp_file("invalid-utf8.txt", [0x61, 0x80, 0x0A]); + + let mut command = cargo_command(); + command + .arg(&path) + .arg("--max-lines") + .arg("2") + .arg("--chunk") + .arg("0") + .assert() + .failure() + .stderr(predicate::str::contains("looks like a binary file")) + .stderr(predicate::str::contains("invalid-utf8.txt")); +} + +#[test] +fn help_includes_examples_and_pipeline_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8", + )) + .stdout(predicate::str::contains("--inventory")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("--chunk")); +} diff --git a/crates/cjson/Cargo.toml b/crates/cjson/Cargo.toml new file mode 100644 index 0000000..7ffc501 --- /dev/null +++ b/crates/cjson/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cjson" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Compact JSON and JSONL into stable single-line output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/cjson/src/lib.rs b/crates/cjson/src/lib.rs new file mode 100644 index 0000000..1db3fb4 --- /dev/null +++ b/crates/cjson/src/lib.rs @@ -0,0 +1,829 @@ +//! The `cjson` command compacts JSON and JSONL. + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + read_existing_stdin_paths, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const MAX_JSON_INPUT_BYTES: u64 = 64 * 1024 * 1024; +const MAX_SORT_DEPTH: usize = 512; + +const HELP: &str = "\ +Compact JSON and JSONL into single-line output with optional recursive key sorting. + +Usage: + cjson [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, json, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --sort-keys Sort object keys recursively before rendering + -h, --help Show this help text + -V, --version Show the command version + +Examples: + cjson .\\fixtures\\cjson\\sample.json + bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl | cjson --input-format jsonl --sort-keys + bat --style=plain --paging=never .\\fixtures\\cjson\\sample.json | cjson --input-format json + '.\\fixtures\\cjson\\sample.json' | cjson --input-format lines + cjson --sort-keys --json .\\fixtures\\cjson\\sample.json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + sort_keys: bool, + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompactFormat { + Json, + Jsonl, +} + +#[derive(Debug, Clone, PartialEq)] +struct ParsedDocuments { + format: CompactFormat, + documents: Vec, + skipped_empty: usize, + skipped_empty_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CompactJsonPayload { + format: &'static str, + documents: usize, + skipped_empty: usize, + skipped_empty_paths: Vec, + text: String, +} + +#[derive(Debug, Clone, PartialEq)] +enum LoadedInput { + Content(String), + Paths(Vec), +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("cjson {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + sort_keys: false, + path: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_cjson_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("sort-keys") => cli.sort_keys = true, + ArgValue(path) => { + if cli.path.replace(PathBuf::from(path)).is_some() { + return Err(CliError::usage("cjson accepts at most one explicit path")); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn run(cli: &Cli) -> Result { + let input = load_input(cli)?; + let parsed = match input { + LoadedInput::Content(content) => { + let input_format = + if cli.path.is_some() && cli.common.input_format == InputFormat::Lines { + InputFormat::Auto + } else { + cli.common.input_format + }; + parse_documents(&content, input_format, None)? + } + LoadedInput::Paths(paths) => parse_documents_from_paths(&paths)?, + }; + let document_count = parsed.documents.len(); + let text = compact_documents(parsed.documents, parsed.format, cli.sort_keys)?; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&CompactJsonPayload { + format: parsed.format.as_str(), + documents: document_count, + skipped_empty: parsed.skipped_empty, + skipped_empty_paths: parsed.skipped_empty_paths, + text, + })?, + RenderMode::Toon => print_structured( + &CompactJsonPayload { + format: parsed.format.as_str(), + documents: document_count, + skipped_empty: parsed.skipped_empty, + skipped_empty_paths: parsed.skipped_empty_paths, + text, + }, + RenderMode::Toon, + )?, + RenderMode::Text => { + write_text_output(&text)?; + emit_skipped_empty_note( + parsed.skipped_empty, + &parsed.skipped_empty_paths, + cli.common.quiet, + ); + } + } + + Ok(ExitCode::Success) +} + +fn load_input(cli: &Cli) -> Result { + if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) { + let buffer = read_to_string_limited(io::stdin(), MAX_JSON_INPUT_BYTES, "stdin")?; + if !buffer.is_empty() { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = + read_existing_stdin_paths(&buffer, cli.common.input_format, "cjson")? + { + return Ok(LoadedInput::Paths(paths)); + } + return Ok(LoadedInput::Content(buffer)); + } + } + + let Some(path) = &cli.path else { + return Err(CliError::usage( + "provide one JSON path or pipe JSON/JSONL into stdin", + )); + }; + + Ok(LoadedInput::Paths(common::expand_input_patterns( + std::slice::from_ref(path), + "cjson", + )?)) +} + +fn parse_documents_from_paths(paths: &[PathBuf]) -> Result { + let mut documents = Vec::new(); + let mut skipped_empty = 0_usize; + let mut skipped_empty_paths = Vec::new(); + let mut format = if paths.len() > 1 { + CompactFormat::Jsonl + } else { + CompactFormat::Json + }; + + for path in paths { + let content = read_path_to_string_limited(path, MAX_JSON_INPUT_BYTES)?; + if paths.len() > 1 && content.trim().is_empty() { + skipped_empty += 1; + skipped_empty_paths.push(path.display().to_string()); + continue; + } + let parsed = parse_documents(&content, InputFormat::Auto, Some(path))?; + if paths.len() == 1 { + format = parsed.format; + } + documents.extend(parsed.documents); + skipped_empty += parsed.skipped_empty; + skipped_empty_paths.extend(parsed.skipped_empty_paths); + } + + if documents.is_empty() { + if skipped_empty > 0 { + return Err(CliError::runtime(format!( + "all {skipped_empty} JSON input path(s) were empty" + ))); + } + return Err(CliError::runtime("JSON input is empty")); + } + + Ok(ParsedDocuments { + format, + documents, + skipped_empty, + skipped_empty_paths, + }) +} + +fn read_path_to_string_limited(path: &Path, max_bytes: u64) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + if metadata.len() > max_bytes { + return Err(CliError::runtime(format!( + "{} is {} byte(s), above the cjson input limit of {max_bytes} byte(s)", + path.display(), + metadata.len() + ))); + } + fs::read_to_string(path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn read_to_string_limited( + reader: R, + max_bytes: u64, + label: &str, +) -> Result { + let mut limited = reader.take(max_bytes.saturating_add(1)); + let mut buffer = String::new(); + limited + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read {label}: {error}")))?; + if buffer.len() as u64 > max_bytes { + return Err(CliError::runtime(format!( + "{label} exceeds the cjson input limit of {max_bytes} byte(s)" + ))); + } + Ok(buffer) +} + +fn parse_documents( + input: &str, + input_format: InputFormat, + source_path: Option<&std::path::Path>, +) -> Result { + if input.trim().is_empty() { + return Err(empty_input_error("JSON", source_path)); + } + + match input_format { + InputFormat::Auto => parse_auto_documents(input, source_path), + InputFormat::Jsonl => parse_jsonl_documents(input, source_path), + InputFormat::Lines => Err(CliError::usage( + "cjson does not support --input-format lines; use auto or jsonl", + )), + } +} + +fn parse_cjson_input_format(value: &str) -> Result { + if value.eq_ignore_ascii_case("json") { + Ok(InputFormat::Auto) + } else { + parse_input_format(value) + } +} + +fn parse_auto_documents( + input: &str, + source_path: Option<&Path>, +) -> Result { + if source_path.is_some_and(has_jsonl_extension) { + return parse_jsonl_documents(input, source_path); + } + + let trimmed = input.trim(); + match serde_json::from_str::(trimmed) { + Ok(document) => Ok(ParsedDocuments { + format: CompactFormat::Json, + documents: vec![document], + skipped_empty: 0, + skipped_empty_paths: Vec::new(), + }), + Err(json_error) => { + if source_path.is_some_and(has_json_extension) { + return Err(invalid_json_input_error(&json_error)); + } + + let mut non_empty_lines = input.lines().map(str::trim).filter(|line| !line.is_empty()); + if non_empty_lines.next().is_some() && non_empty_lines.next().is_some() { + match parse_jsonl_documents(input, source_path) { + Ok(parsed) => return Ok(parsed), + Err(line_stream_error) => { + if looks_like_json_document(trimmed) { + return Err(invalid_json_input_error(&json_error)); + } + return Err(line_stream_error); + } + } + } + + if looks_like_json_document(trimmed) { + return Err(invalid_json_input_error(&json_error)); + } + + parse_jsonl_documents(input, source_path) + } + } +} + +fn parse_jsonl_documents( + input: &str, + source_path: Option<&Path>, +) -> Result { + let mut documents = Vec::new(); + + for (index, line) in input.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let document = serde_json::from_str::(trimmed).map_err(|error| { + CliError::runtime(format!("invalid JSONL at line {}: {error}", index + 1)) + })?; + documents.push(document); + } + + if documents.is_empty() { + return Err(empty_input_error("JSONL", source_path)); + } + + Ok(ParsedDocuments { + format: CompactFormat::Jsonl, + documents, + skipped_empty: 0, + skipped_empty_paths: Vec::new(), + }) +} + +fn empty_input_error(kind: &str, source_path: Option<&Path>) -> CliError { + source_path.map_or_else( + || CliError::runtime(format!("{kind} input is empty")), + |path| CliError::runtime(format!("{kind} input is empty: {}", path.display())), + ) +} + +fn compact_documents( + documents: Vec, + format: CompactFormat, + sort_keys: bool, +) -> Result { + match format { + CompactFormat::Json => compact_single_document(documents, sort_keys), + CompactFormat::Jsonl => compact_jsonl_documents(documents, sort_keys), + } +} + +fn compact_single_document(documents: Vec, sort_keys: bool) -> Result { + let mut iter = documents.into_iter(); + let Some(document) = iter.next() else { + return Err(CliError::runtime( + "internal error: missing JSON document for compaction", + )); + }; + + if iter.next().is_some() { + return Err(CliError::runtime( + "internal error: JSON compaction received multiple documents", + )); + } + + serialize_document(document, sort_keys) +} + +fn compact_jsonl_documents(documents: Vec, sort_keys: bool) -> Result { + let mut rendered = String::with_capacity(documents.len().saturating_mul(96)); + for (index, document) in documents.into_iter().enumerate() { + if index > 0 { + rendered.push('\n'); + } + rendered.push_str(&serialize_document(document, sort_keys)?); + } + Ok(rendered) +} + +fn serialize_document(mut document: Value, sort_keys: bool) -> Result { + if sort_keys { + sort_value(&mut document, 0)?; + } + + serde_json::to_string(&document) + .map_err(|error| CliError::runtime(format!("failed to render JSON: {error}"))) +} + +fn sort_value(value: &mut Value, depth: usize) -> Result<(), CliError> { + if depth > MAX_SORT_DEPTH { + return Err(CliError::runtime(format!( + "JSON nesting exceeds cjson --sort-keys limit of {MAX_SORT_DEPTH}" + ))); + } + + match value { + Value::Object(map) => { + let mut entries = std::mem::take(map).into_iter().collect::>(); + entries.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + for (key, mut child) in entries { + sort_value(&mut child, depth + 1)?; + let _ = map.insert(key, child); + } + } + Value::Array(items) => { + for item in items { + sort_value(item, depth + 1)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + Ok(()) +} + +fn write_text_output(text: &str) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + stdout + .write_all(text.as_bytes()) + .map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))?; + stdout + .write_all(b"\n") + .map_err(|error| CliError::runtime(format!("failed to write stdout: {error}"))) +} + +fn emit_skipped_empty_note(skipped_empty: usize, skipped_empty_paths: &[String], quiet: bool) { + if quiet || skipped_empty == 0 { + return; + } + + let preview = skipped_empty_paths + .iter() + .take(3) + .cloned() + .collect::>() + .join(", "); + let suffix = if skipped_empty_paths.len() > 3 { + format!(" (+{} more)", skipped_empty_paths.len() - 3) + } else { + String::new() + }; + eprintln!("note: skipped {skipped_empty} empty JSON input path(s): {preview}{suffix}"); +} + +impl CompactFormat { + const fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Jsonl => "jsonl", + } + } +} + +fn has_json_extension(path: &Path) -> bool { + has_extension(path, "json") +} + +fn has_jsonl_extension(path: &Path) -> bool { + has_extension(path, "jsonl") || has_extension(path, "ndjson") +} + +fn has_extension(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn looks_like_json_document(input: &str) -> bool { + input.starts_with('{') + || input.starts_with('[') + || input.starts_with('"') + || matches!(input.as_bytes().first(), Some(b'-' | b'0'..=b'9')) + || input == "true" + || input == "false" + || input == "null" +} + +fn invalid_json_input_error(error: &serde_json::Error) -> CliError { + CliError::runtime(format!("invalid JSON input: {error}")) +} + +#[cfg(test)] +mod tests { + use common::{ColorChoice, InputFormat}; + use serde_json::json; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_documents_supports_auto_json_and_jsonl() { + let single = parse_documents( + "{\n \"z\": 3,\n \"a\": {\"y\": 2, \"x\": 1}\n}\n", + InputFormat::Auto, + None, + ) + .expect("single JSON document"); + assert_eq!( + single, + ParsedDocuments { + format: CompactFormat::Json, + documents: vec![json!({"z": 3, "a": {"y": 2, "x": 1}})], + skipped_empty: 0, + skipped_empty_paths: Vec::new(), + } + ); + + let stream = parse_documents( + "{\"ok\":true,\"event\":\"login\"}\n{\"ok\":false,\"event\":\"logout\"}\n", + InputFormat::Jsonl, + None, + ) + .expect("jsonl documents"); + assert_eq!(stream.format, CompactFormat::Jsonl); + assert_eq!( + stream.documents, + vec![ + json!({"ok": true, "event": "login"}), + json!({"ok": false, "event": "logout"}), + ] + ); + } + + #[test] + fn parse_auto_jsonl_preserves_invalid_json_precedence() { + let stream = parse_documents( + "{\"ok\":true,\"event\":\"login\"}\n\n{\"ok\":false,\"event\":\"logout\"}\n", + InputFormat::Auto, + None, + ) + .expect("auto jsonl documents"); + assert_eq!(stream.format, CompactFormat::Jsonl); + assert_eq!( + stream.documents, + vec![ + json!({"ok": true, "event": "login"}), + json!({"ok": false, "event": "logout"}), + ] + ); + + let invalid_json = parse_documents("{\"ok\": true}\nnot-json\n", InputFormat::Auto, None) + .expect_err("json-looking input should prefer JSON error"); + assert!(matches!( + invalid_json, + CliError::Runtime(message) + if message.contains("invalid JSON input") + && !message.contains("JSONL") + )); + + let invalid_jsonl = parse_documents("not-json\n{\"ok\":true}\n", InputFormat::Auto, None) + .expect_err("non-json-looking input should report JSONL line error"); + assert!(matches!( + invalid_jsonl, + CliError::Runtime(message) + if message.contains("invalid JSONL at line 1") + )); + } + + #[test] + fn parse_documents_rejects_lines_mode_and_empty_input() { + let lines_error = + parse_documents("{\"ok\":true}\n", InputFormat::Lines, None).expect_err("lines mode"); + assert!(matches!( + lines_error, + CliError::Usage(message) + if message.contains("does not support --input-format lines") + )); + + let empty_error = + parse_documents(" \n\t", InputFormat::Auto, None).expect_err("empty input"); + assert!(matches!( + empty_error, + CliError::Runtime(message) + if message.contains("JSON input is empty") + )); + + let invalid_json = parse_documents("{\"ok\": true,,}\n", InputFormat::Auto, None) + .expect_err("invalid json should fail"); + assert!(matches!( + invalid_json, + CliError::Runtime(message) + if message.contains("invalid JSON input") + && !message.contains("JSONL") + )); + } + + #[test] + fn cjson_input_format_accepts_json_alias() { + assert_eq!( + parse_cjson_input_format("json").expect("json alias"), + InputFormat::Auto + ); + assert_eq!( + parse_cjson_input_format("jsonl").expect("jsonl"), + InputFormat::Jsonl + ); + } + + #[test] + fn input_readers_reject_payloads_above_size_limit() { + let error = read_to_string_limited(std::io::Cursor::new("abcd"), 3, "stdin") + .expect_err("oversize stdin rejected"); + assert!(error.to_string().contains("cjson input limit")); + + let temp = std::env::temp_dir().join(format!( + "cjson-large-input-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::write(&temp, "abcd").expect("fixture"); + let error = read_path_to_string_limited(&temp, 3).expect_err("oversize file rejected"); + assert!(error.to_string().contains("cjson input limit")); + std::fs::remove_file(temp).expect("cleanup"); + } + + #[test] + fn sort_keys_rejects_extreme_json_nesting() { + let mut value = json!(true); + for _ in 0..(MAX_SORT_DEPTH + 2) { + value = json!({ "child": value }); + } + + let error = serialize_document(value, true).expect_err("deep sort rejected"); + + assert!(error.to_string().contains("sort-keys limit")); + } + + #[test] + fn compaction_can_sort_keys_recursively() { + let rendered = compact_documents( + vec![json!({ + "z": 3, + "a": {"y": 2, "x": 1}, + "items": [{"b": 2, "a": 1}], + "name": "Ada", + })], + CompactFormat::Json, + true, + ) + .expect("sorted compaction"); + + assert_eq!( + rendered, + "{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}" + ); + } + + #[test] + fn run_supports_text_and_json_wrapper_modes() { + let text_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + sort_keys: false, + path: Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("cjson") + .join("sample.json"), + ), + }) + .expect("text run"); + assert_eq!(text_exit, ExitCode::Success); + + let json_exit = run(&Cli { + common: common_args(true, InputFormat::Jsonl), + sort_keys: true, + path: Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("cjson") + .join("records.jsonl"), + ), + }) + .expect("json run"); + assert_eq!(json_exit, ExitCode::Success); + } + + #[test] + fn load_input_accepts_multiple_stdin_paths() { + let temp = std::env::temp_dir().join(format!( + "cjson-stdin-paths-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&temp).expect("tempdir"); + let sample = temp.join("sample.json"); + let second = temp.join("second.json"); + std::fs::write(&sample, "{\"ok\":true}\n").expect("sample"); + std::fs::write(&second, "{\"ok\":false}\n").expect("second"); + + let loaded_input = load_input_from_buffer( + &Cli { + common: common_args(false, InputFormat::Lines), + sort_keys: false, + path: None, + }, + &format!("{}\n{}\n", sample.display(), second.display()), + ) + .expect("stdin paths"); + let LoadedInput::Paths(paths) = loaded_input else { + panic!("expected path stream input"); + }; + let loaded = parse_documents_from_paths(&paths).expect("parsed"); + assert_eq!(loaded.format, CompactFormat::Jsonl); + assert_eq!(loaded.documents.len(), 2); + + std::fs::remove_dir_all(temp).expect("cleanup"); + } + + fn load_input_from_buffer(cli: &Cli, buffer: &str) -> Result { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = + read_existing_stdin_paths(buffer, cli.common.input_format, "cjson")? + { + return Ok(LoadedInput::Paths(paths)); + } + Ok(LoadedInput::Content(buffer.to_string())) + } +} diff --git a/crates/cjson/src/main.rs b/crates/cjson/src/main.rs new file mode 100644 index 0000000..d70757a --- /dev/null +++ b/crates/cjson/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `cjson`. + +fn main() { + std::process::exit(cjson::main_entry()); +} diff --git a/crates/cjson/tests/cjson_cli.rs b/crates/cjson/tests/cjson_cli.rs new file mode 100644 index 0000000..b572a56 --- /dev/null +++ b/crates/cjson/tests/cjson_cli.rs @@ -0,0 +1,247 @@ +//! Integration tests for the `cjson` command. + +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; + +fn cargo_command() -> Command { + Command::cargo_bin("cjson").expect("binary") +} + +fn cargo_binary() -> PathBuf { + assert_cmd::cargo::cargo_bin("cjson") +} + +fn powershell_command(script: String) -> Command { + let mut command = Command::new("pwsh"); + command.args(["-NoProfile", "-Command"]).arg(script); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") +} + +fn fixture(path: &str) -> PathBuf { + let fixture = workspace_root().join("fixtures").join(path); + assert!( + fixture.exists(), + "missing fixture `{path}` at {}", + fixture.display() + ); + fixture +} + +fn json_stdout(output: &[u8]) -> Value { + serde_json::from_slice(output).unwrap_or_else(|error| { + panic!( + "stdout should be valid JSON: {error}\n{}", + String::from_utf8_lossy(output) + ) + }) +} + +struct TempTestDir { + path: PathBuf, +} + +impl TempTestDir { + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for TempTestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn temp_test_dir(name: &str) -> TempTestDir { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + loop { + let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("cjson-{}-{name}-{unique}", std::process::id())); + match fs::create_dir(&path) { + Ok(()) => return TempTestDir { path }, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => panic!("temp test dir: {error}"), + } + } +} + +#[test] +fn compacts_json_file_in_text_mode() { + let output = cargo_command() + .arg(fixture("cjson/sample.json")) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let compacted: Value = json_stdout(&output); + assert_eq!( + compacted["name"], + "Ada", + "stdout={}", + String::from_utf8_lossy(&output) + ); + assert_eq!( + compacted["a"]["x"], + 1, + "stdout={}", + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn sort_keys_reorders_objects_recursively() { + cargo_command() + .arg("--sort-keys") + .write_stdin( + "{\"z\":3,\"a\":{\"y\":2,\"x\":1},\"items\":[{\"b\":2,\"a\":1}],\"name\":\"Ada\"}", + ) + .assert() + .success() + .stdout( + "{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}\n", + ); +} +#[test] +fn explicit_path_wins_over_piped_stdin_noise() { + cargo_command() + .arg(fixture("cjson/sample.json")) + .write_stdin("not json from upstream pipeline") + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"Ada\"")); +} + +#[test] +fn json_wrapper_reports_jsonl_documents() { + let output = cargo_command() + .arg("--input-format") + .arg("jsonl") + .arg("--sort-keys") + .arg("--json") + .arg(fixture("cjson/records.jsonl")) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let payload = json_stdout(&output); + assert_eq!( + payload["format"], + "jsonl", + "stdout={}", + String::from_utf8_lossy(&output) + ); + assert_eq!( + payload["documents"], + 2, + "stdout={}", + String::from_utf8_lossy(&output) + ); + assert!( + payload["text"] + .as_str() + .is_some_and(|text| text.contains("\"event\":\"login\",\"ok\":true")), + "stdout={}", + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn auto_mode_treats_explicit_jsonl_and_ndjson_paths_as_line_streams() { + let temp = temp_test_dir("auto-jsonl-paths"); + let jsonl = temp.path().join("single.jsonl"); + let ndjson = temp.path().join("single.ndjson"); + fs::write(&jsonl, "{\"ok\":true}\n").expect("jsonl fixture"); + fs::write(&ndjson, "{\"ok\":true}\n").expect("ndjson fixture"); + + for path in [&jsonl, &ndjson] { + let mut command = cargo_command(); + command + .arg("--json") + .arg(path) + .assert() + .success() + .stdout(predicate::str::contains("\"format\":\"jsonl\"")) + .stdout(predicate::str::contains("\"documents\":1")); + } +} + +#[test] +fn supports_powershell_pipeline() { + let binary = cargo_binary(); + let input = fixture("cjson/records.jsonl"); + let script = format!( + "[System.IO.File]::ReadLines({}) | & {} --input-format jsonl --sort-keys", + ps_quote(input.display()), + ps_quote(binary.display()) + ); + + powershell_command(script) + .assert() + .success() + .stdout(predicate::str::contains( + "{\"event\":\"login\",\"ok\":true}", + )) + .stdout(predicate::str::contains( + "{\"event\":\"logout\",\"ok\":false}", + )); +} + +#[test] +fn accepts_single_stdin_path_stream_in_lines_mode() { + cargo_command() + .args(["--input-format", "lines"]) + .write_stdin(format!("{}\n", fixture("cjson/sample.json").display())) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"Ada\"")); +} + +#[test] +fn lines_mode_accepts_windows_paths_with_quotes_and_spaces() { + let temp = temp_test_dir("quoted path"); + let path = temp.path().join("Ada's sample.json"); + fs::write(&path, "{\"name\":\"Ada\",\"ok\":true}\n").expect("quoted path fixture"); + + cargo_command() + .args(["--input-format", "lines"]) + .write_stdin(format!("{}\n", path.display())) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"Ada\"")); +} + +#[test] +fn help_includes_examples_and_sort_keys_flag() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--sort-keys")) + .stdout(predicate::str::contains( + "bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl", + )) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} diff --git a/crates/codeindex/Cargo.toml b/crates/codeindex/Cargo.toml new file mode 100644 index 0000000..f7431b4 --- /dev/null +++ b/crates/codeindex/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "codeindex" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared tree-sitter-based code indexing for Mercury Toolbox." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[features] +default = ["all-languages"] +all-languages = [ + "lang-rust", + "lang-csharp", + "lang-powershell", + "lang-python", + "lang-go", + "lang-java", + "lang-javascript", + "lang-typescript", +] +lang-rust = ["dep:tree-sitter-rust"] +lang-csharp = ["dep:tree-sitter-c-sharp"] +lang-powershell = ["dep:tree-sitter-powershell"] +lang-python = ["dep:tree-sitter-python"] +lang-go = ["dep:tree-sitter-go"] +lang-java = ["dep:tree-sitter-java"] +lang-javascript = ["dep:tree-sitter-javascript"] +lang-typescript = ["dep:tree-sitter-typescript"] + +[dependencies] +common = { path = "../common", default-features = false } +serde.workspace = true +tree-sitter.workspace = true +tree-sitter-c-sharp = { workspace = true, optional = true } +tree-sitter-go = { workspace = true, optional = true } +tree-sitter-javascript = { workspace = true, optional = true } +tree-sitter-java = { workspace = true, optional = true } +tree-sitter-powershell = { workspace = true, optional = true } +tree-sitter-python = { workspace = true, optional = true } +tree-sitter-rust = { workspace = true, optional = true } +tree-sitter-typescript = { workspace = true, optional = true } diff --git a/crates/codeindex/src/lib.rs b/crates/codeindex/src/lib.rs new file mode 100644 index 0000000..5a81ac0 --- /dev/null +++ b/crates/codeindex/src/lib.rs @@ -0,0 +1,1560 @@ +//! Shared tree-sitter-based indexing helpers for Mercury Toolbox. + +use common::CliError; +use serde::Serialize; +use std::path::Path; +use tree_sitter::{Node, Parser, Tree}; + +/// Stable engine label attached to AST-backed symbol output. +pub const ENGINE_NAME: &str = "codeindex"; + +/// Supported code languages for AST-backed indexing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CodeLanguage { + /// Rust source. + Rust, + /// C# source. + Csharp, + /// `PowerShell` source and data files. + Powershell, + /// Python source. + Python, + /// Go source. + Go, + /// Java source. + Java, + /// JavaScript source. + Javascript, + /// TypeScript or TSX source. + Typescript, +} + +/// Stable supported-language list for CLI help and validation. +pub const SUPPORTED_LANGUAGE_LIST: &str = + "rust,csharp,powershell,python,go,java,javascript,typescript"; + +/// All supported code languages in CLI display order. +pub const SUPPORTED_LANGUAGES: &[CodeLanguage] = &[ + CodeLanguage::Rust, + CodeLanguage::Csharp, + CodeLanguage::Powershell, + CodeLanguage::Python, + CodeLanguage::Go, + CodeLanguage::Java, + CodeLanguage::Javascript, + CodeLanguage::Typescript, +]; + +impl CodeLanguage { + /// Returns the stable CLI label for a language. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Rust => "rust", + Self::Csharp => "csharp", + Self::Powershell => "powershell", + Self::Python => "python", + Self::Go => "go", + Self::Java => "java", + Self::Javascript => "javascript", + Self::Typescript => "typescript", + } + } +} + +/// Parses a stable CLI language label. +#[must_use] +pub fn parse_language_label(value: &str) -> Option { + match value { + "rust" | "rs" => Some(CodeLanguage::Rust), + "csharp" | "c#" | "cs" => Some(CodeLanguage::Csharp), + "powershell" | "pwsh" | "ps1" => Some(CodeLanguage::Powershell), + "python" | "py" => Some(CodeLanguage::Python), + "go" | "golang" => Some(CodeLanguage::Go), + "java" => Some(CodeLanguage::Java), + "javascript" | "js" | "jsx" => Some(CodeLanguage::Javascript), + "typescript" | "ts" | "tsx" => Some(CodeLanguage::Typescript), + _ => None, + } +} + +/// Supported symbol kinds emitted by the indexer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolKind { + /// Module-like declaration. + Module, + /// Namespace declaration. + Namespace, + /// Class declaration. + Class, + /// Struct declaration. + Struct, + /// Enum declaration. + Enum, + /// Interface declaration. + Interface, + /// Record declaration. + Record, + /// Trait declaration. + Trait, + /// Implementation block. + Impl, + /// Type alias declaration. + TypeAlias, + /// Function or free function. + Function, + /// Method or member function. + Method, + /// Constructor declaration. + Constructor, + /// Constant declaration. + Const, + /// Static declaration. + Static, +} + +/// Normalized symbol entry emitted by the shared code index. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IndexedSymbol { + /// Shared indexing engine label. + pub engine: &'static str, + /// Source file path as rendered text. + pub path: String, + /// Detected code language. + pub language: CodeLanguage, + /// Symbol kind. + pub kind: SymbolKind, + /// Local symbol name. + pub name: String, + /// Qualified symbol name joined with `::`. + pub qualified_name: String, + /// Compact signature line. + pub signature: String, + /// Parent chain when known. + pub parents: Vec, + /// Zero-based nesting depth. + pub depth: usize, + /// One-based start line. + pub start_line: usize, + /// One-based end line. + pub end_line: usize, + /// Full definition text. + pub text: String, +} + +/// A single reference-like occurrence found in source code. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReferenceMatch { + /// Shared indexing engine label. + pub engine: &'static str, + /// Source file path as rendered text. + pub path: String, + /// Detected code language. + pub language: CodeLanguage, + /// Matched symbol name. + pub name: String, + /// One-based line number for the occurrence. + pub line: usize, + /// One-based column number for the occurrence. + pub column: usize, + /// Full source line that contains the occurrence. + pub line_text: String, + /// Enclosing symbol kind when one was found. + pub enclosing_kind: Option, + /// Enclosing local symbol name when one was found. + pub enclosing_name: Option, + /// Enclosing qualified symbol name when one was found. + pub enclosing_qualified_name: Option, + /// Enclosing compact signature when one was found. + pub enclosing_signature: Option, + /// Enclosing start line when one was found. + pub enclosing_start_line: Option, + /// Enclosing end line when one was found. + pub enclosing_end_line: Option, +} + +/// Mutable parser cache used to reuse tree-sitter parser instances. +#[derive(Default)] +pub struct CodeIndexer { + rust: Option, + csharp: Option, + powershell: Option, + python: Option, + go: Option, + java: Option, + javascript: Option, + typescript: Option, + tsx: Option, +} + +/// Parsed and symbol-indexed view of one source file. +pub struct IndexedFile<'source> { + path_text: String, + language: CodeLanguage, + tree: Tree, + source: &'source str, + symbols: Vec, + line_starts: Vec, +} + +impl std::fmt::Debug for CodeIndexer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("CodeIndexer") + } +} + +impl std::fmt::Debug for IndexedFile<'_> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IndexedFile") + .field("path", &self.path_text) + .field("language", &self.language) + .field("symbols", &self.symbols.len()) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NodeDescriptor { + kind: SymbolKind, + name: String, + parent_name: Option, + container_name: Option, +} + +#[derive(Debug)] +struct ReferenceSearch<'source> { + language: CodeLanguage, + path: &'source str, + source: &'source str, + target_name: &'source str, + line_starts: &'source [usize], + symbols: &'source [IndexedSymbol], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SymbolTextMode { + Full, + Summary, +} + +/// Detects the supported code language for a path. +#[must_use] +pub fn detect_language(path: &Path) -> Option { + match path.extension().and_then(|value| value.to_str()) { + Some("rs") => Some(CodeLanguage::Rust), + Some("cs") => Some(CodeLanguage::Csharp), + Some("ps1" | "psm1" | "psd1") => Some(CodeLanguage::Powershell), + Some("py") => Some(CodeLanguage::Python), + Some("go") => Some(CodeLanguage::Go), + Some("java") => Some(CodeLanguage::Java), + Some("js" | "mjs" | "cjs" | "jsx") => Some(CodeLanguage::Javascript), + Some("ts" | "tsx") => Some(CodeLanguage::Typescript), + _ => None, + } +} + +/// Resolves the smallest enclosing symbol from a prebuilt symbol index. +#[must_use] +pub fn enclosing_symbol( + symbols: &[IndexedSymbol], + line: usize, + column: Option, +) -> Option<&IndexedSymbol> { + select_enclosing_symbol(symbols, line, column) +} + +impl CodeIndexer { + /// Creates a new reusable code indexer. + #[must_use] + pub const fn new() -> Self { + Self { + rust: None, + csharp: None, + powershell: None, + python: None, + go: None, + java: None, + javascript: None, + typescript: None, + tsx: None, + } + } + + /// Indexes a source string for the given path. + /// + /// # Errors + /// + /// Returns an error when the language is unsupported or parsing fails. + pub fn index_source( + &mut self, + path: &Path, + source: &str, + ) -> Result, CliError> { + self.index_source_with_text_mode(path, source, SymbolTextMode::Full) + } + + /// Indexes a source string without retaining full definition bodies. + /// + /// Use this for summary tools that only need names, hierarchy, signatures, and line ranges. + /// + /// # Errors + /// + /// Returns an error when the language is unsupported or parsing fails. + pub fn index_source_summary( + &mut self, + path: &Path, + source: &str, + ) -> Result, CliError> { + self.index_source_with_text_mode(path, source, SymbolTextMode::Summary) + } + + /// Parses and indexes a source string with full definition text for repeated queries. + /// + /// # Errors + /// + /// Returns an error when the language is unsupported or parsing fails. + pub fn index_file<'source>( + &mut self, + path: &Path, + source: &'source str, + ) -> Result, CliError> { + self.index_file_with_text_mode(path, source, SymbolTextMode::Full) + } + + /// Parses and indexes a source string without retaining full definition bodies. + /// + /// Use this for repeated lookups that need symbol ranges, signatures, or references, + /// but do not need to render definition text. + /// + /// # Errors + /// + /// Returns an error when the language is unsupported or parsing fails. + pub fn index_file_summary<'source>( + &mut self, + path: &Path, + source: &'source str, + ) -> Result, CliError> { + self.index_file_with_text_mode(path, source, SymbolTextMode::Summary) + } + + fn index_source_with_text_mode( + &mut self, + path: &Path, + source: &str, + text_mode: SymbolTextMode, + ) -> Result, CliError> { + Ok(self + .index_file_with_text_mode(path, source, text_mode)? + .symbols) + } + + fn index_file_with_text_mode<'source>( + &mut self, + path: &Path, + source: &'source str, + text_mode: SymbolTextMode, + ) -> Result, CliError> { + let (language, tree) = self.parse_tree(path, source)?; + let mut symbols = Vec::new(); + let mut parents = Vec::new(); + let path_text = path.display().to_string(); + collect_root_symbols( + tree.root_node(), + language, + &path_text, + source, + &mut parents, + &mut symbols, + text_mode, + ); + let line_starts = collect_line_starts(source); + Ok(IndexedFile { + path_text, + language, + tree, + source, + symbols, + line_starts, + }) + } + + /// Resolves the smallest enclosing symbol for a one-based line and optional column. + /// + /// # Errors + /// + /// Returns an error when the language is unsupported or parsing fails. + pub fn find_enclosing_symbol( + &mut self, + path: &Path, + source: &str, + line: usize, + column: Option, + ) -> Result, CliError> { + Ok(self + .index_file(path, source)? + .find_enclosing_symbol(line, column) + .cloned()) + } + + /// Finds identifier-like references to an exact symbol name inside one source file. + /// + /// # Errors + /// + /// Returns an error when the language is unsupported or parsing fails. + pub fn find_references_in_source( + &mut self, + path: &Path, + source: &str, + target_name: &str, + ) -> Result, CliError> { + Ok(self + .index_file_summary(path, source)? + .find_references(target_name)) + } + + fn parse_tree(&mut self, path: &Path, source: &str) -> Result<(CodeLanguage, Tree), CliError> { + let language = detect_language(path).ok_or_else(|| { + CliError::usage(format!("unsupported code language for {}", path.display())) + })?; + let parser = self.parser_for(path, language)?; + let tree = parser + .parse(source, None) + .ok_or_else(|| CliError::runtime(format!("failed to parse {}", path.display())))?; + Ok((language, tree)) + } + + fn parser_for(&mut self, path: &Path, language: CodeLanguage) -> Result<&mut Parser, CliError> { + match language_slot(path, language) { + LanguageSlot::Rust => { + let language = parser_language_rust(path)?; + ensure_parser(&mut self.rust, &language, "rust") + } + LanguageSlot::Csharp => { + let language = parser_language_csharp(path)?; + ensure_parser(&mut self.csharp, &language, "csharp") + } + LanguageSlot::Powershell => { + let language = parser_language_powershell(path)?; + ensure_parser(&mut self.powershell, &language, "powershell") + } + LanguageSlot::Python => { + let language = parser_language_python(path)?; + ensure_parser(&mut self.python, &language, "python") + } + LanguageSlot::Go => { + let language = parser_language_go(path)?; + ensure_parser(&mut self.go, &language, "go") + } + LanguageSlot::Java => { + let language = parser_language_java(path)?; + ensure_parser(&mut self.java, &language, "java") + } + LanguageSlot::Javascript => { + let language = parser_language_javascript(path)?; + ensure_parser(&mut self.javascript, &language, "javascript") + } + LanguageSlot::Typescript => { + let language = parser_language_typescript(path)?; + ensure_parser(&mut self.typescript, &language, "typescript") + } + LanguageSlot::Tsx => { + let language = parser_language_tsx(path)?; + ensure_parser(&mut self.tsx, &language, "tsx") + } + } + } +} + +impl IndexedFile<'_> { + /// Source file path as rendered text. + #[must_use] + pub fn path(&self) -> &str { + &self.path_text + } + + /// Detected code language. + #[must_use] + pub const fn language(&self) -> CodeLanguage { + self.language + } + + /// Indexed symbols for this file. + #[must_use] + pub fn symbols(&self) -> &[IndexedSymbol] { + &self.symbols + } + + /// Resolves the smallest enclosing symbol for a one-based line and optional column. + #[must_use] + pub fn find_enclosing_symbol( + &self, + line: usize, + column: Option, + ) -> Option<&IndexedSymbol> { + enclosing_symbol(&self.symbols, line, column) + } + + /// Finds identifier-like references to an exact symbol name inside this source file. + #[must_use] + pub fn find_references(&self, target_name: &str) -> Vec { + let mut matches = Vec::new(); + let search = ReferenceSearch { + language: self.language, + path: &self.path_text, + source: self.source, + target_name, + line_starts: &self.line_starts, + symbols: &self.symbols, + }; + collect_reference_matches(self.tree.root_node(), &search, &mut matches); + matches + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LanguageSlot { + Rust, + Csharp, + Powershell, + Python, + Go, + Java, + Javascript, + Typescript, + Tsx, +} + +fn language_slot(path: &Path, language: CodeLanguage) -> LanguageSlot { + match language { + CodeLanguage::Rust => LanguageSlot::Rust, + CodeLanguage::Csharp => LanguageSlot::Csharp, + CodeLanguage::Powershell => LanguageSlot::Powershell, + CodeLanguage::Python => LanguageSlot::Python, + CodeLanguage::Go => LanguageSlot::Go, + CodeLanguage::Java => LanguageSlot::Java, + CodeLanguage::Javascript => LanguageSlot::Javascript, + CodeLanguage::Typescript => { + if path.extension().and_then(|value| value.to_str()) == Some("tsx") { + LanguageSlot::Tsx + } else { + LanguageSlot::Typescript + } + } + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_rust(path: &Path) -> Result { + #[cfg(feature = "lang-rust")] + { + let _ = path; + Ok(tree_sitter::Language::from(tree_sitter_rust::LANGUAGE)) + } + #[cfg(not(feature = "lang-rust"))] + { + unsupported_language_feature(path, "rust") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_csharp(path: &Path) -> Result { + #[cfg(feature = "lang-csharp")] + { + let _ = path; + Ok(tree_sitter::Language::from(tree_sitter_c_sharp::LANGUAGE)) + } + #[cfg(not(feature = "lang-csharp"))] + { + unsupported_language_feature(path, "csharp") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_powershell(path: &Path) -> Result { + #[cfg(feature = "lang-powershell")] + { + let _ = path; + Ok(tree_sitter::Language::from( + tree_sitter_powershell::LANGUAGE, + )) + } + #[cfg(not(feature = "lang-powershell"))] + { + unsupported_language_feature(path, "powershell") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_python(path: &Path) -> Result { + #[cfg(feature = "lang-python")] + { + let _ = path; + Ok(tree_sitter::Language::from(tree_sitter_python::LANGUAGE)) + } + #[cfg(not(feature = "lang-python"))] + { + unsupported_language_feature(path, "python") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_go(path: &Path) -> Result { + #[cfg(feature = "lang-go")] + { + let _ = path; + Ok(tree_sitter::Language::from(tree_sitter_go::LANGUAGE)) + } + #[cfg(not(feature = "lang-go"))] + { + unsupported_language_feature(path, "go") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_java(path: &Path) -> Result { + #[cfg(feature = "lang-java")] + { + let _ = path; + Ok(tree_sitter::Language::from(tree_sitter_java::LANGUAGE)) + } + #[cfg(not(feature = "lang-java"))] + { + unsupported_language_feature(path, "java") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_javascript(path: &Path) -> Result { + #[cfg(feature = "lang-javascript")] + { + let _ = path; + Ok(tree_sitter::Language::from( + tree_sitter_javascript::LANGUAGE, + )) + } + #[cfg(not(feature = "lang-javascript"))] + { + unsupported_language_feature(path, "javascript") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_typescript(path: &Path) -> Result { + #[cfg(feature = "lang-typescript")] + { + let _ = path; + Ok(tree_sitter::Language::from( + tree_sitter_typescript::LANGUAGE_TYPESCRIPT, + )) + } + #[cfg(not(feature = "lang-typescript"))] + { + unsupported_language_feature(path, "typescript") + } +} + +#[allow(clippy::unnecessary_wraps)] +fn parser_language_tsx(path: &Path) -> Result { + #[cfg(feature = "lang-typescript")] + { + let _ = path; + Ok(tree_sitter::Language::from( + tree_sitter_typescript::LANGUAGE_TSX, + )) + } + #[cfg(not(feature = "lang-typescript"))] + { + unsupported_language_feature(path, "tsx") + } +} + +fn ensure_parser<'a>( + slot: &'a mut Option, + language: &tree_sitter::Language, + label: &str, +) -> Result<&'a mut Parser, CliError> { + if slot.is_none() { + let mut parser = Parser::new(); + parser.set_language(language).map_err(|error| { + CliError::runtime(format!("failed to configure {label} parser: {error}")) + })?; + *slot = Some(parser); + } + Ok(slot.as_mut().expect("parser slot populated")) +} + +#[cfg(any( + not(feature = "lang-rust"), + not(feature = "lang-csharp"), + not(feature = "lang-powershell"), + not(feature = "lang-python"), + not(feature = "lang-go"), + not(feature = "lang-java"), + not(feature = "lang-javascript"), + not(feature = "lang-typescript") +))] +fn unsupported_language_feature(path: &Path, label: &str) -> Result { + Err(CliError::usage(format!( + "codeindex was built without {label} parser support for {}", + path.display() + ))) +} + +fn collect_symbols( + node: Node<'_>, + language: CodeLanguage, + path: &str, + source: &str, + parents: &mut Vec, + symbols: &mut Vec, + text_mode: SymbolTextMode, +) { + if !node.is_named() { + return; + } + + let descriptor = describe_node(node, language, source); + let pushed = descriptor + .as_ref() + .and_then(|descriptor| descriptor.container_name.clone()); + let had_push = pushed.is_some(); + + if let Some(descriptor) = descriptor { + let source_text = slice_text(source, node).trim_end(); + let signature = compact_signature(source_text); + let text = match text_mode { + SymbolTextMode::Full => source_text.to_string(), + SymbolTextMode::Summary => String::new(), + }; + let mut symbol_parents = parents.clone(); + if let Some(parent_name) = descriptor.parent_name { + symbol_parents.push(parent_name); + } + let qualified_name = if symbol_parents.is_empty() { + descriptor.name.clone() + } else { + format!("{}::{}", symbol_parents.join("::"), descriptor.name) + }; + symbols.push(IndexedSymbol { + engine: ENGINE_NAME, + path: path.to_string(), + language, + kind: descriptor.kind, + name: descriptor.name, + qualified_name, + signature, + parents: symbol_parents.clone(), + depth: symbol_parents.len(), + start_line: node.start_position().row + 1, + end_line: node.end_position().row + 1, + text, + }); + } + + if let Some(container_name) = pushed { + parents.push(container_name); + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_symbols(child, language, path, source, parents, symbols, text_mode); + } + + if had_push { + let _ = parents.pop(); + } +} + +fn collect_root_symbols( + root: Node<'_>, + language: CodeLanguage, + path: &str, + source: &str, + parents: &mut Vec, + symbols: &mut Vec, + text_mode: SymbolTextMode, +) { + if language == CodeLanguage::Csharp { + let mut cursor = root.walk(); + if let Some(namespace_node) = root + .named_children(&mut cursor) + .find(|child| child.kind() == "file_scoped_namespace_declaration") + { + if let Some(descriptor) = describe_csharp_node(namespace_node, source) { + let source_text = slice_text(source, namespace_node).trim_end(); + let signature = compact_signature(source_text); + let text = match text_mode { + SymbolTextMode::Full => source_text.to_string(), + SymbolTextMode::Summary => String::new(), + }; + symbols.push(IndexedSymbol { + engine: ENGINE_NAME, + path: path.to_string(), + language, + kind: descriptor.kind, + name: descriptor.name.clone(), + qualified_name: descriptor.name.clone(), + signature, + parents: Vec::new(), + depth: 0, + start_line: namespace_node.start_position().row + 1, + end_line: namespace_node.end_position().row + 1, + text, + }); + parents.push(descriptor.name); + let mut child_cursor = root.walk(); + for child in root.named_children(&mut child_cursor) { + if child != namespace_node { + collect_symbols(child, language, path, source, parents, symbols, text_mode); + } + } + let _ = parents.pop(); + return; + } + } + } + + collect_symbols(root, language, path, source, parents, symbols, text_mode); +} + +fn describe_node(node: Node<'_>, language: CodeLanguage, source: &str) -> Option { + match language { + CodeLanguage::Rust => describe_rust_node(node, source), + CodeLanguage::Csharp => describe_csharp_node(node, source), + CodeLanguage::Powershell => describe_powershell_node(node, source), + CodeLanguage::Python => describe_python_node(node, source), + CodeLanguage::Go => describe_go_node(node, source), + CodeLanguage::Java => describe_java_node(node, source), + CodeLanguage::Javascript => describe_javascript_node(node, source), + CodeLanguage::Typescript => describe_typescript_node(node, source), + } +} + +fn describe_rust_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "mod_item" => container_descriptor(node, source, SymbolKind::Module), + "struct_item" => container_descriptor(node, source, SymbolKind::Struct), + "enum_item" => container_descriptor(node, source, SymbolKind::Enum), + "trait_item" => container_descriptor(node, source, SymbolKind::Trait), + "type_item" => leaf_descriptor(node, source, SymbolKind::TypeAlias), + "const_item" => leaf_descriptor(node, source, SymbolKind::Const), + "static_item" => leaf_descriptor(node, source, SymbolKind::Static), + "function_item" => { + if has_ancestor(node, "impl_item") { + leaf_descriptor(node, source, SymbolKind::Method) + } else { + leaf_descriptor(node, source, SymbolKind::Function) + } + } + "impl_item" => { + let type_name = field_text(node, "type", source)?; + let impl_name = if let Some(trait_name) = field_text(node, "trait", source) { + format!("{trait_name} for {type_name}") + } else { + type_name + }; + Some(NodeDescriptor { + kind: SymbolKind::Impl, + name: impl_name.clone(), + parent_name: None, + container_name: Some(impl_name), + }) + } + _ => None, + } +} + +fn describe_csharp_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "namespace_declaration" => container_descriptor(node, source, SymbolKind::Namespace), + "file_scoped_namespace_declaration" => { + container_descriptor(node, source, SymbolKind::Namespace) + } + "class_declaration" => container_descriptor(node, source, SymbolKind::Class), + "struct_declaration" => container_descriptor(node, source, SymbolKind::Struct), + "interface_declaration" => container_descriptor(node, source, SymbolKind::Interface), + "enum_declaration" => container_descriptor(node, source, SymbolKind::Enum), + "record_declaration" => container_descriptor(node, source, SymbolKind::Record), + "method_declaration" => leaf_descriptor(node, source, SymbolKind::Method), + "constructor_declaration" => leaf_descriptor(node, source, SymbolKind::Constructor), + _ => None, + } +} + +fn describe_powershell_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "class_statement" => { + container_descriptor_by_child(node, source, SymbolKind::Class, &["simple_name"]) + } + "enum_statement" => { + container_descriptor_by_child(node, source, SymbolKind::Enum, &["simple_name"]) + } + "function_statement" => { + leaf_descriptor_by_child(node, source, SymbolKind::Function, &["function_name"]) + } + "class_method_definition" => { + leaf_descriptor_by_child(node, source, SymbolKind::Method, &["simple_name"]) + } + _ => None, + } +} + +fn describe_python_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "class_definition" => container_descriptor(node, source, SymbolKind::Class), + "function_definition" => { + if node.parent().is_some_and(|parent| { + parent.kind() == "block" + && parent + .parent() + .is_some_and(|grand| grand.kind() == "class_definition") + }) { + leaf_descriptor(node, source, SymbolKind::Method) + } else { + leaf_descriptor(node, source, SymbolKind::Function) + } + } + _ => None, + } +} + +fn describe_go_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "type_declaration" => describe_go_type_declaration(node, source), + "function_declaration" => leaf_descriptor(node, source, SymbolKind::Function), + "method_declaration" => describe_go_method_declaration(node, source), + "const_declaration" => describe_go_value_declaration(node, source, SymbolKind::Const), + "var_declaration" => describe_go_value_declaration(node, source, SymbolKind::Static), + _ => None, + } +} + +fn describe_go_type_declaration(node: Node<'_>, source: &str) -> Option { + let spec = first_named_child_by_kind(node, &["type_spec"])?; + let name = field_text(spec, "name", source)?; + let type_node = spec.child_by_field_name("type")?; + let kind = match type_node.kind() { + "struct_type" => SymbolKind::Struct, + "interface_type" => SymbolKind::Interface, + _ => SymbolKind::TypeAlias, + }; + Some(NodeDescriptor { + kind, + name: name.clone(), + parent_name: None, + container_name: matches!(kind, SymbolKind::Struct | SymbolKind::Interface).then_some(name), + }) +} + +fn describe_go_method_declaration(node: Node<'_>, source: &str) -> Option { + let name = field_text(node, "name", source)?; + let parent_name = node + .child_by_field_name("receiver") + .and_then(|receiver| go_receiver_type_name(receiver, source)); + Some(NodeDescriptor { + kind: SymbolKind::Method, + name, + parent_name, + container_name: None, + }) +} + +fn go_receiver_type_name(receiver: Node<'_>, source: &str) -> Option { + let text = slice_text(source, receiver); + let close = text.find(')')?; + let inside = text.get(1..close)?.trim(); + let type_part = inside.split_whitespace().last()?.trim_start_matches('*'); + let name = type_part.rsplit('.').next().unwrap_or(type_part); + (!name.is_empty()).then(|| name.to_string()) +} + +fn describe_go_value_declaration( + node: Node<'_>, + source: &str, + kind: SymbolKind, +) -> Option { + let spec = first_named_child_by_kind(node, &["const_spec", "var_spec"])?; + let name = first_named_child_text(spec, &["identifier"], source)?; + Some(NodeDescriptor { + kind, + name, + parent_name: None, + container_name: None, + }) +} + +fn describe_java_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "class_declaration" => container_descriptor(node, source, SymbolKind::Class), + "interface_declaration" | "annotation_type_declaration" => { + container_descriptor(node, source, SymbolKind::Interface) + } + "enum_declaration" => container_descriptor(node, source, SymbolKind::Enum), + "record_declaration" => container_descriptor(node, source, SymbolKind::Record), + "method_declaration" => leaf_descriptor(node, source, SymbolKind::Method), + "constructor_declaration" => leaf_descriptor(node, source, SymbolKind::Constructor), + _ => None, + } +} + +fn describe_javascript_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "class_declaration" => container_descriptor(node, source, SymbolKind::Class), + "function_declaration" | "generator_function_declaration" => { + leaf_descriptor(node, source, SymbolKind::Function) + } + "method_definition" => leaf_descriptor(node, source, SymbolKind::Method), + "variable_declarator" => { + function_like_variable_descriptor(node, source, SymbolKind::Function) + } + _ => None, + } +} + +fn describe_typescript_node(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "class_declaration" | "abstract_class_declaration" => { + container_descriptor(node, source, SymbolKind::Class) + } + "interface_declaration" => container_descriptor(node, source, SymbolKind::Interface), + "enum_declaration" => container_descriptor(node, source, SymbolKind::Enum), + "type_alias_declaration" => leaf_descriptor(node, source, SymbolKind::TypeAlias), + "module" | "internal_module" => container_descriptor(node, source, SymbolKind::Module), + "function_declaration" => leaf_descriptor(node, source, SymbolKind::Function), + "method_definition" => leaf_descriptor(node, source, SymbolKind::Method), + "variable_declarator" => { + function_like_variable_descriptor(node, source, SymbolKind::Function) + } + _ => None, + } +} + +fn container_descriptor(node: Node<'_>, source: &str, kind: SymbolKind) -> Option { + let name = field_text(node, "name", source)?; + Some(NodeDescriptor { + kind, + name: name.clone(), + parent_name: None, + container_name: Some(name), + }) +} + +fn container_descriptor_by_child( + node: Node<'_>, + source: &str, + kind: SymbolKind, + child_kinds: &[&str], +) -> Option { + let name = first_named_child_text(node, child_kinds, source)?; + Some(NodeDescriptor { + kind, + name: name.clone(), + parent_name: None, + container_name: Some(name), + }) +} + +fn leaf_descriptor(node: Node<'_>, source: &str, kind: SymbolKind) -> Option { + let name = field_text(node, "name", source)?; + Some(NodeDescriptor { + kind, + name, + parent_name: None, + container_name: None, + }) +} + +fn leaf_descriptor_by_child( + node: Node<'_>, + source: &str, + kind: SymbolKind, + child_kinds: &[&str], +) -> Option { + let name = first_named_child_text(node, child_kinds, source)?; + Some(NodeDescriptor { + kind, + name, + parent_name: None, + container_name: None, + }) +} + +fn function_like_variable_descriptor( + node: Node<'_>, + source: &str, + kind: SymbolKind, +) -> Option { + let value = node.child_by_field_name("value")?; + let is_function_like = matches!( + value.kind(), + "arrow_function" | "function" | "function_expression" | "generator_function" + ); + if !is_function_like { + return None; + } + leaf_descriptor(node, source, kind) +} + +fn field_text(node: Node<'_>, field_name: &str, source: &str) -> Option { + let child = node.child_by_field_name(field_name)?; + Some(slice_text(source, child).trim().to_string()) +} + +fn has_ancestor(node: Node<'_>, kind: &str) -> bool { + let mut current = node.parent(); + while let Some(parent) = current { + if parent.kind() == kind { + return true; + } + current = parent.parent(); + } + false +} + +fn first_named_child_text(node: Node<'_>, child_kinds: &[&str], source: &str) -> Option { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .find(|child| child_kinds.contains(&child.kind())) + .map(|child| slice_text(source, child).trim().to_string()) +} + +fn slice_text<'a>(source: &'a str, node: Node<'_>) -> &'a str { + let range = node.byte_range(); + source.get(range).unwrap_or_default() +} + +fn collect_line_starts(source: &str) -> Vec { + let mut starts = Vec::with_capacity(source.bytes().filter(|byte| *byte == b'\n').count() + 1); + starts.push(0); + for (index, byte) in source.bytes().enumerate() { + if byte == b'\n' { + starts.push(index + 1); + } + } + starts +} + +fn line_text_for(source: &str, line_starts: &[usize], line: usize) -> String { + let Some(&start) = line_starts.get(line.saturating_sub(1)) else { + return String::new(); + }; + let end = line_starts.get(line).copied().unwrap_or(source.len()); + source[start..end] + .trim_end_matches(['\r', '\n']) + .to_string() +} + +fn select_enclosing_symbol( + symbols: &[IndexedSymbol], + line: usize, + column: Option, +) -> Option<&IndexedSymbol> { + symbols + .iter() + .filter(|symbol| symbol_contains(symbol, line, column)) + .min_by_key(|symbol| { + ( + symbol.end_line.saturating_sub(symbol.start_line), + symbol.start_line, + symbol.start_line.abs_diff(line), + ) + }) +} + +const fn symbol_contains(symbol: &IndexedSymbol, line: usize, _column: Option) -> bool { + line >= symbol.start_line && line <= symbol.end_line +} + +fn collect_reference_matches( + node: Node<'_>, + search: &ReferenceSearch<'_>, + matches: &mut Vec, +) { + if !node.is_named() { + return; + } + + if node.named_child_count() == 0 + && is_identifier_like_leaf(node, search.source, search.target_name) + && !is_declaration_name(node, search.language) + { + let line = node.start_position().row + 1; + let column = node.start_position().column + 1; + let enclosing = select_enclosing_symbol(search.symbols, line, Some(column)); + matches.push(ReferenceMatch { + engine: ENGINE_NAME, + path: search.path.to_string(), + language: search.language, + name: search.target_name.to_string(), + line, + column, + line_text: line_text_for(search.source, search.line_starts, line), + enclosing_kind: enclosing.map(|symbol| symbol.kind), + enclosing_name: enclosing.map(|symbol| symbol.name.clone()), + enclosing_qualified_name: enclosing.map(|symbol| symbol.qualified_name.clone()), + enclosing_signature: enclosing.map(|symbol| symbol.signature.clone()), + enclosing_start_line: enclosing.map(|symbol| symbol.start_line), + enclosing_end_line: enclosing.map(|symbol| symbol.end_line), + }); + return; + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_reference_matches(child, search, matches); + } +} + +fn is_identifier_like_leaf(node: Node<'_>, source: &str, target_name: &str) -> bool { + let text = slice_text(source, node).trim(); + if text != target_name || text.is_empty() { + return false; + } + + if !text + .chars() + .all(|character| character == '_' || character.is_alphanumeric()) + { + return false; + } + + let kind = node.kind(); + !(kind.contains("comment") + || kind.contains("string") + || kind.contains("literal") + || kind.contains("escape") + || kind.contains("number")) +} + +fn is_declaration_name(node: Node<'_>, language: CodeLanguage) -> bool { + let mut current = node.parent(); + while let Some(parent) = current { + if declaration_name_node(parent, language).is_some_and(|candidate| candidate == node) { + return true; + } + current = parent.parent(); + } + false +} + +fn declaration_name_node(node: Node<'_>, language: CodeLanguage) -> Option> { + match language { + CodeLanguage::Rust => match node.kind() { + "mod_item" | "struct_item" | "enum_item" | "trait_item" | "type_item" + | "const_item" | "static_item" | "function_item" => node.child_by_field_name("name"), + _ => None, + }, + CodeLanguage::Csharp => match node.kind() { + "namespace_declaration" + | "file_scoped_namespace_declaration" + | "class_declaration" + | "struct_declaration" + | "interface_declaration" + | "enum_declaration" + | "record_declaration" + | "method_declaration" + | "constructor_declaration" => node.child_by_field_name("name"), + _ => None, + }, + CodeLanguage::Powershell => match node.kind() { + "class_statement" | "enum_statement" | "class_method_definition" => { + first_named_child_by_kind(node, &["simple_name"]) + } + "function_statement" => first_named_child_by_kind(node, &["function_name"]), + _ => None, + }, + CodeLanguage::Python => match node.kind() { + "class_definition" | "function_definition" => node.child_by_field_name("name"), + _ => None, + }, + CodeLanguage::Go => match node.kind() { + "type_declaration" => first_named_child_by_kind(node, &["type_spec"]) + .and_then(|spec| spec.child_by_field_name("name")), + "function_declaration" | "method_declaration" => node.child_by_field_name("name"), + "const_declaration" | "var_declaration" => { + first_named_child_by_kind(node, &["const_spec", "var_spec"]) + .and_then(|spec| first_named_child_by_kind(spec, &["identifier"])) + } + _ => None, + }, + CodeLanguage::Java => match node.kind() { + "class_declaration" + | "interface_declaration" + | "annotation_type_declaration" + | "enum_declaration" + | "record_declaration" + | "method_declaration" + | "constructor_declaration" => node.child_by_field_name("name"), + _ => None, + }, + CodeLanguage::Javascript => match node.kind() { + "class_declaration" + | "function_declaration" + | "generator_function_declaration" + | "method_definition" + | "variable_declarator" => node.child_by_field_name("name"), + _ => None, + }, + CodeLanguage::Typescript => match node.kind() { + "class_declaration" + | "abstract_class_declaration" + | "interface_declaration" + | "enum_declaration" + | "type_alias_declaration" + | "module" + | "internal_module" + | "function_declaration" + | "method_definition" + | "variable_declarator" => node.child_by_field_name("name"), + _ => None, + }, + } +} + +fn first_named_child_by_kind<'tree>( + node: Node<'tree>, + child_kinds: &[&str], +) -> Option> { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .find(|child| child_kinds.contains(&child.kind())) +} + +fn compact_signature(text: &str) -> String { + let candidate = text + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or_default() + .trim_end_matches('{') + .trim_end_matches(';') + .trim(); + collapse_whitespace(candidate) +} + +fn collapse_whitespace(text: &str) -> String { + let mut collapsed = String::new(); + for word in text.split_whitespace() { + if !collapsed.is_empty() { + collapsed.push(' '); + } + collapsed.push_str(word); + } + collapsed +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) + } + + #[test] + fn detect_language_covers_supported_extensions() { + assert_eq!( + detect_language(Path::new("sample.rs")), + Some(CodeLanguage::Rust) + ); + assert_eq!( + detect_language(Path::new("sample.cs")), + Some(CodeLanguage::Csharp) + ); + assert_eq!( + detect_language(Path::new("sample.ps1")), + Some(CodeLanguage::Powershell) + ); + assert_eq!( + detect_language(Path::new("sample.py")), + Some(CodeLanguage::Python) + ); + assert_eq!( + detect_language(Path::new("sample.go")), + Some(CodeLanguage::Go) + ); + assert_eq!( + detect_language(Path::new("sample.java")), + Some(CodeLanguage::Java) + ); + assert_eq!(parse_language_label("golang"), Some(CodeLanguage::Go)); + assert_eq!(parse_language_label("tsx"), Some(CodeLanguage::Typescript)); + assert!(SUPPORTED_LANGUAGES.contains(&CodeLanguage::Go)); + assert_eq!( + detect_language(Path::new("sample.js")), + Some(CodeLanguage::Javascript) + ); + assert_eq!( + detect_language(Path::new("sample.tsx")), + Some(CodeLanguage::Typescript) + ); + assert_eq!(detect_language(Path::new("sample.txt")), None); + } + + #[test] + fn index_source_extracts_nested_symbols_and_signatures() { + let mut indexer = CodeIndexer::new(); + let path = fixture_path("src/lib.rs"); + let source = fs::read_to_string(&path).expect("fixture"); + + let symbols = indexer.index_source(&path, &source).expect("symbols"); + let helper = symbols + .iter() + .find(|symbol| symbol.qualified_name == "helper") + .expect("rust helper"); + let method = symbols + .iter() + .find(|symbol| symbol.qualified_name == "nested::Widget::helper") + .expect("rust method"); + + assert_eq!(helper.language, CodeLanguage::Rust); + assert_eq!(helper.kind, SymbolKind::Function); + assert!(helper.signature.contains("pub fn helper")); + assert!(helper.text.contains("format!(\"hello, {name}\")")); + assert_eq!(method.kind, SymbolKind::Method); + assert_eq!( + method.parents, + vec!["nested".to_string(), "Widget".to_string()] + ); + assert!(method.text.contains("self.value + 1")); + } + + #[test] + fn summary_index_keeps_signatures_without_definition_text() { + let mut indexer = CodeIndexer::new(); + let path = fixture_path("src/Service.java"); + let source = fs::read_to_string(&path).expect("fixture"); + + let symbols = indexer + .index_source_summary(&path, &source) + .expect("summary symbols"); + let symbol = symbols + .iter() + .find(|symbol| symbol.qualified_name == "Service::execute") + .expect("java method"); + + assert_eq!(symbol.text, ""); + assert!(symbol.signature.contains("void execute")); + assert_eq!(symbol.kind, SymbolKind::Method); + assert!(symbol.end_line >= symbol.start_line); + } + + #[test] + fn index_source_covers_polyglot_declarations() { + let cases = [ + ( + "src/Game.cs", + "Mercury.Game::RocketBuilder::Build", + SymbolKind::Method, + ), + ( + "scripts/tools.ps1", + "TaskRunner::Format", + SymbolKind::Method, + ), + ("nested/worker.py", "Worker::build", SymbolKind::Method), + ("src/service.go", "Worker::Run", SymbolKind::Method), + ("src/Service.java", "Service::execute", SymbolKind::Method), + ("web/app.js", "makeGreeter", SymbolKind::Function), + ("web/app.ts", "Tools::open", SymbolKind::Function), + ("web/view.tsx", "Screen::render", SymbolKind::Method), + ]; + + let mut indexer = CodeIndexer::new(); + for (relative, qualified_name, kind) in cases { + let path = fixture_path(relative); + let source = fs::read_to_string(&path).expect("fixture"); + let symbols = indexer.index_source(&path, &source).expect("symbols"); + let symbol = symbols + .iter() + .find(|symbol| symbol.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("missing {qualified_name} in {}", path.display())); + assert_eq!(symbol.kind, kind); + assert!(!symbol.signature.is_empty()); + assert!(symbol.end_line >= symbol.start_line); + } + } + + #[test] + fn find_enclosing_symbol_returns_smallest_matching_definition() { + let mut indexer = CodeIndexer::new(); + let path = fixture_path("src/lib.rs"); + let source = fs::read_to_string(&path).expect("fixture"); + + let symbol = indexer + .find_enclosing_symbol(&path, &source, 26, Some(24)) + .expect("symbol lookup") + .expect("enclosing symbol"); + + assert_eq!(symbol.qualified_name, "call_helper"); + assert_eq!(symbol.kind, SymbolKind::Function); + } + + #[test] + fn find_references_in_source_skips_declaration_name_and_captures_use_sites() { + let mut indexer = CodeIndexer::new(); + let path = fixture_path("src/lib.rs"); + let source = fs::read_to_string(&path).expect("fixture"); + + let matches = indexer + .find_references_in_source(&path, &source, "helper") + .expect("references"); + + assert_eq!(matches.len(), 2); + assert!(matches.iter().any(|item| { + item.line_text.contains("helper(name)") + && item.enclosing_qualified_name.as_deref() == Some("call_helper") + })); + assert!(matches.iter().any(|item| { + item.line_text.contains("widget.helper()") + && item.enclosing_qualified_name.as_deref() == Some("call_helper") + })); + } + + #[test] + fn indexed_file_reuses_summary_index_for_repeated_queries() { + let mut indexer = CodeIndexer::new(); + let path = fixture_path("src/lib.rs"); + let source = fs::read_to_string(&path).expect("fixture"); + + let expected_references = indexer + .find_references_in_source(&path, &source, "helper") + .expect("one-shot references"); + let cached_file = indexer + .index_file_summary(&path, &source) + .expect("summary indexed file"); + let cached_references = cached_file.find_references("helper"); + let cached_enclosing = cached_file + .find_enclosing_symbol(26, Some(24)) + .expect("cached enclosing symbol"); + + assert_eq!(cached_references, expected_references); + assert!( + cached_file + .symbols() + .iter() + .all(|symbol| symbol.text.is_empty()) + ); + assert_eq!(cached_enclosing.qualified_name, "call_helper"); + assert_eq!(cached_enclosing.text, ""); + + let full = indexer + .index_file(&path, &source) + .expect("full indexed file"); + let full_enclosing = full + .find_enclosing_symbol(26, Some(24)) + .expect("full enclosing symbol"); + assert!(full_enclosing.text.contains("helper(name)")); + } +} diff --git a/crates/codeshape/Cargo.toml b/crates/codeshape/Cargo.toml new file mode 100644 index 0000000..4addfcb --- /dev/null +++ b/crates/codeshape/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codeshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Emit recursive AST-backed codebase maps with compact signatures." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/codeshape/src/lib.rs b/crates/codeshape/src/lib.rs new file mode 100644 index 0000000..0e28813 --- /dev/null +++ b/crates/codeshape/src/lib.rs @@ -0,0 +1,700 @@ +//! The `codeshape` command emits recursive AST-backed project maps. + +use codeindex::{ + CodeIndexer, CodeLanguage, ENGINE_NAME, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind, + detect_language, parse_language_label, +}; +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files, + parse_color_choice, parse_format_choice, parse_input_format, print_json, + print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024; + +const HELP: &str = "\ +Emit recursive AST-backed codebase maps with compact signatures via the shared codeindex engine. + +Usage: + codeshape [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --lang Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript + --max-files Maximum number of files to include + --max-depth Maximum symbol depth to include + --limit-per-file Maximum symbols to include per file after depth filtering + -h, --help Show this help text + -V, --version Show the command version + +Examples: + codeshape .\\fixtures\\polyglot\\repo + codeshape --max-depth 1 --limit-per-file 8 . --json | ConvertFrom-Json + '.\\fixtures\\polyglot\\repo' | codeshape --json | ConvertFrom-Json + +JSON fields: + engine, roots, files[].path, files[].language, files[].items[], totals +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + paths: Vec, + languages: Option>, + max_files: usize, + max_depth: Option, + limit_per_file: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CodeShapeItem { + kind: SymbolKind, + name: String, + qualified_name: String, + signature: String, + depth: usize, + start_line: usize, + end_line: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct FileReport { + path: String, + language: CodeLanguage, + items: Vec, + omitted_items: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Totals { + files_seen: usize, + files_indexed: usize, + files_omitted_by_limit: usize, + symbols_emitted: usize, + symbols_omitted: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CodeShapeReport { + engine: &'static str, + roots: Vec, + files: Vec, + totals: Totals, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("codeshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +#[allow( + clippy::too_many_lines, + reason = "single-pass CLI parsing keeps global and shared output flags auditable" +)] +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut paths = Vec::new(); + let mut languages = None::>; + let mut max_files = 200_usize; + let mut max_depth = None::; + let mut limit_per_file = 32_usize; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + paths, + languages, + max_files, + max_depth, + limit_per_file, + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + paths, + languages, + max_files, + max_depth, + limit_per_file, + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("lang") => { + languages = Some(parse_language_list(&parser_value_string( + &mut parser, + "--lang", + )?)?); + } + Long("max-files") => { + max_files = parse_usize_flag( + "--max-files", + &parser_value_string(&mut parser, "--max-files")?, + )?; + } + Long("max-depth") => { + max_depth = Some(parse_usize_flag( + "--max-depth", + &parser_value_string(&mut parser, "--max-depth")?, + )?); + } + Long("limit-per-file") => { + limit_per_file = parse_usize_flag( + "--limit-per-file", + &parser_value_string(&mut parser, "--limit-per-file")?, + )?; + } + ArgValue(value) => paths.push(PathBuf::from(value)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if max_files == 0 { + return Err(CliError::usage("--max-files must be greater than 0")); + } + if limit_per_file == 0 { + return Err(CliError::usage("--limit-per-file must be greater than 0")); + } + + Ok(( + ParseOutcome::Run, + Cli { + common, + paths, + languages, + max_files, + max_depth, + limit_per_file, + }, + )) +} + +fn run(cli: &Cli) -> Result { + let report = build_report(cli)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_text(&report)), + } + + Ok(if report.files.is_empty() { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn build_report(cli: &Cli) -> Result { + let roots = collect_roots(cli)?; + let discovered = discover_supported_files(&roots)?; + let mut indexer = CodeIndexer::new(); + let mut files = Vec::new(); + let mut totals = Totals { + files_seen: discovered.len(), + files_indexed: 0, + files_omitted_by_limit: 0, + symbols_emitted: 0, + symbols_omitted: 0, + }; + + for path in discovered { + if files.len() >= cli.max_files { + totals.files_omitted_by_limit += 1; + continue; + } + + let Some(language) = detect_language(&path) else { + continue; + }; + if cli + .languages + .as_ref() + .is_some_and(|languages| !languages.contains(&language)) + { + continue; + } + + if is_source_too_large(&path, MAX_SOURCE_BYTES)? { + totals.files_omitted_by_limit += 1; + continue; + } + + let source = fs::read_to_string(&path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let mut items = indexer + .index_source_summary(&path, &source)? + .into_iter() + .filter(|item| cli.max_depth.is_none_or(|depth| item.depth <= depth)) + .map(into_codeshape_item) + .collect::>(); + items.sort_unstable_by(|left, right| { + (left.depth, left.start_line, left.name.as_str()).cmp(&( + right.depth, + right.start_line, + right.name.as_str(), + )) + }); + if items.is_empty() { + continue; + } + + let omitted_items = items.len().saturating_sub(cli.limit_per_file); + items.truncate(cli.limit_per_file); + totals.files_indexed += 1; + totals.symbols_emitted += items.len(); + totals.symbols_omitted += omitted_items; + files.push(FileReport { + path: path.display().to_string(), + language, + items, + omitted_items, + }); + } + + Ok(CodeShapeReport { + engine: ENGINE_NAME, + roots: roots + .iter() + .map(|path| path.display().to_string()) + .collect::>(), + files, + totals, + }) +} + +fn is_source_too_large(path: &Path, max_bytes: u64) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + Ok(metadata.len() > max_bytes) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_language_list(value: &str) -> Result, CliError> { + let mut languages = BTreeSet::new(); + for raw in value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + let language = parse_language_label(raw).ok_or_else(|| { + CliError::usage(format!( + "invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}" + )) + })?; + let _ = languages.insert(language); + } + Ok(languages) +} + +fn collect_roots(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let roots = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !roots.is_empty() { + return Ok(roots); + } + } + + if cli.paths.is_empty() { + Ok(vec![PathBuf::from(".")]) + } else { + common::expand_input_patterns(&cli.paths, "codeshape") + } +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "codeshape")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn discover_supported_files(roots: &[PathBuf]) -> Result, CliError> { + collect_matching_files(roots, &|path| detect_language(path).is_some()) +} + +fn into_codeshape_item(symbol: IndexedSymbol) -> CodeShapeItem { + CodeShapeItem { + kind: symbol.kind, + name: symbol.name, + qualified_name: symbol.qualified_name, + signature: symbol.signature, + depth: symbol.depth, + start_line: symbol.start_line, + end_line: symbol.end_line, + } +} + +fn render_text(report: &CodeShapeReport) -> String { + let mut output = String::new(); + for file in &report.files { + let _ = writeln!(output, "{} [{}]", file.path, language_label(file.language)); + for item in &file.items { + let indent = " ".repeat(item.depth + 1); + let _ = writeln!( + output, + "{}{} {} :: {}", + indent, + kind_label(item.kind), + item.qualified_name, + item.signature, + ); + } + if file.omitted_items > 0 { + let _ = writeln!(output, " ... {} more item(s)", file.omitted_items); + } + } + let _ = writeln!( + output, + "totals: files_indexed={} symbols_emitted={} symbols_omitted={} files_omitted_by_limit={}", + report.totals.files_indexed, + report.totals.symbols_emitted, + report.totals.symbols_omitted, + report.totals.files_omitted_by_limit + ); + output +} + +const fn language_label(language: CodeLanguage) -> &'static str { + match language { + CodeLanguage::Rust + | CodeLanguage::Csharp + | CodeLanguage::Powershell + | CodeLanguage::Python + | CodeLanguage::Go + | CodeLanguage::Java + | CodeLanguage::Javascript + | CodeLanguage::Typescript => language.label(), + } +} + +const fn kind_label(kind: SymbolKind) -> &'static str { + match kind { + SymbolKind::Module => "module", + SymbolKind::Namespace => "namespace", + SymbolKind::Class => "class", + SymbolKind::Struct => "struct", + SymbolKind::Enum => "enum", + SymbolKind::Interface => "interface", + SymbolKind::Record => "record", + SymbolKind::Trait => "trait", + SymbolKind::Impl => "impl", + SymbolKind::TypeAlias => "type_alias", + SymbolKind::Function => "function", + SymbolKind::Method => "method", + SymbolKind::Constructor => "constructor", + SymbolKind::Const => "const", + SymbolKind::Static => "static", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use common::ColorChoice; + + fn fixture_repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + } + + fn cli_for(root: PathBuf) -> Cli { + Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: ColorChoice::Never, + quiet: false, + }, + paths: vec![root], + languages: None, + max_files: 200, + max_depth: None, + limit_per_file: 32, + } + } + + fn sample_symbol() -> IndexedSymbol { + IndexedSymbol { + engine: ENGINE_NAME, + path: "fixture.ts".to_string(), + language: CodeLanguage::Typescript, + kind: SymbolKind::Function, + name: "helper".to_string(), + qualified_name: "web::helper".to_string(), + signature: "export const helper = (name: string) => name.trim();".to_string(), + parents: Vec::new(), + depth: 0, + start_line: 1, + end_line: 1, + text: "export const helper = (name: string) => name.trim();".to_string(), + } + } + + #[test] + fn parse_cli_and_lists_cover_help_version_and_validation() { + assert_eq!( + parse_cli_from(["codeshape", "--help"]).expect("help").0, + ParseOutcome::Help + ); + assert_eq!( + parse_cli_from(["codeshape", "--version"]) + .expect("version") + .0, + ParseOutcome::Version + ); + + let (_, cli) = parse_cli_from([ + "codeshape", + "--json", + "--input-format", + "jsonl", + "--color", + "never", + "--lang", + "rust,java,typescript", + "--max-files", + "4", + "--max-depth", + "1", + "--limit-per-file", + "6", + "fixtures/polyglot/repo", + ]) + .expect("parsed cli"); + assert!(cli.common.json); + assert_eq!(cli.common.input_format, InputFormat::Jsonl); + assert_eq!(cli.common.color, ColorChoice::Never); + assert_eq!(cli.max_files, 4); + assert_eq!(cli.max_depth, Some(1)); + assert_eq!(cli.limit_per_file, 6); + assert!( + cli.languages + .as_ref() + .expect("languages") + .contains(&CodeLanguage::Typescript) + && cli + .languages + .as_ref() + .expect("languages") + .contains(&CodeLanguage::Java) + ); + + assert!(parse_cli_from(["codeshape", "--max-files", "0"]).is_err()); + assert!(parse_cli_from(["codeshape", "--limit-per-file", "0"]).is_err()); + assert!(parse_language_list("lua").is_err()); + assert!(parse_usize_flag("--max-files", "nope").is_err()); + } + + #[test] + fn path_parsing_and_discovery_cover_supported_inputs_and_failures() { + let source = PathBuf::from("src/lib.rs"); + let manifest = PathBuf::from("Cargo.toml"); + + let line_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Lines) + .expect("line paths"); + assert_eq!(line_paths, vec![manifest.clone(), source.clone()]); + + let json_paths = parse_paths_from_string("{\"path\":\"src/lib.rs\"}\n", InputFormat::Jsonl) + .expect("json paths"); + assert_eq!(json_paths, vec![source.clone()]); + + let auto_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Auto) + .expect("auto paths"); + assert_eq!(auto_paths, vec![manifest, source]); + + assert!( + discover_supported_files(&[fixture_repo()]) + .expect("discover") + .len() + >= 8 + ); + assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err()); + } + + #[test] + fn build_report_skips_supported_sources_above_size_limit() { + let root = std::env::temp_dir().join(format!( + "codeshape-large-source-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&root).expect("temp root"); + let large_len = usize::try_from(MAX_SOURCE_BYTES + 1).expect("test size fits usize"); + fs::write(root.join("large.rs"), vec![b' '; large_len]).expect("large source"); + + let report = build_report(&cli_for(root.clone())).expect("report"); + + assert!(report.files.is_empty()); + assert_eq!(report.totals.files_omitted_by_limit, 1); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn build_report_render_and_run_cover_truncation_and_empty_results() { + let mut cli = cli_for(fixture_repo()); + let report = build_report(&cli).expect("report"); + assert_eq!(report.engine, ENGINE_NAME); + assert!(report.files.len() >= 6); + assert!(report.totals.files_seen >= report.totals.files_indexed); + assert!( + report + .files + .iter() + .any(|file| file.path.ends_with("web\\app.ts")) + ); + + cli.languages = Some(BTreeSet::from([CodeLanguage::Rust])); + cli.max_files = 1; + cli.max_depth = Some(1); + cli.limit_per_file = 2; + let limited = build_report(&cli).expect("limited report"); + assert_eq!(limited.files.len(), 1); + assert!(limited.files[0].omitted_items <= limited.totals.symbols_omitted); + + let item = into_codeshape_item(sample_symbol()); + assert_eq!(item.name, "helper"); + let text = render_text(&CodeShapeReport { + engine: ENGINE_NAME, + roots: vec!["repo".to_string()], + files: vec![FileReport { + path: "repo/web/app.ts".to_string(), + language: CodeLanguage::Typescript, + items: vec![item], + omitted_items: 2, + }], + totals: Totals { + files_seen: 1, + files_indexed: 1, + files_omitted_by_limit: 0, + symbols_emitted: 1, + symbols_omitted: 2, + }, + }); + assert!(text.contains("repo/web/app.ts [typescript]")); + assert!(text.contains("function web::helper")); + assert!(text.contains("... 2 more item(s)")); + assert!(text.contains("totals: files_indexed=1")); + assert_eq!(language_label(CodeLanguage::Javascript), "javascript"); + assert_eq!(language_label(CodeLanguage::Java), "java"); + assert_eq!(kind_label(SymbolKind::Static), "static"); + + let success_cli = cli_for(fixture_repo()); + assert_eq!(run(&success_cli).expect("run success"), ExitCode::Success); + + let empty_cli = cli_for( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("README.md"), + ); + assert_eq!(run(&empty_cli).expect("run empty"), ExitCode::NoResults); + } +} diff --git a/crates/codeshape/src/main.rs b/crates/codeshape/src/main.rs new file mode 100644 index 0000000..9a75a37 --- /dev/null +++ b/crates/codeshape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `codeshape`. + +fn main() { + std::process::exit(codeshape::main_entry()); +} diff --git a/crates/codeshape/tests/codeshape_cli.rs b/crates/codeshape/tests/codeshape_cli.rs new file mode 100644 index 0000000..2eede8b --- /dev/null +++ b/crates/codeshape/tests/codeshape_cli.rs @@ -0,0 +1,92 @@ +//! Integration tests for the `codeshape` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("codeshape").expect("binary") +} + +fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn summarizes_polyglot_repo_as_json() { + let mut command = cargo_command(); + command + .arg("--json") + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains("\"roots\"")) + .stdout(predicate::str::contains("\"language\":\"rust\"")) + .stdout(predicate::str::contains("\"language\":\"go\"")) + .stdout(predicate::str::contains("\"language\":\"java\"")) + .stdout(predicate::str::contains( + "\"qualified_name\":\"Service::execute\"", + )) + .stdout(predicate::str::contains( + "\"signature\":\"pub fn helper(name: &str) -> String\"", + )) + .stdout(predicate::str::contains( + "\"signature\":\"function Invoke-Helper\"", + )); +} + +#[test] +fn renders_compact_tree_text() { + let mut command = cargo_command(); + command + .args(["--limit-per-file", "4"]) + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains("src\\lib.rs")) + .stdout(predicate::str::contains("function helper")) + .stdout(predicate::str::contains("namespace Mercury.Game")); +} + +#[test] +fn supports_powershell_pipeline_roots() { + let binary = assert_cmd::cargo::cargo_bin("codeshape"); + let root = fixture_path(""); + let script = format!( + "'{}' | & '{}' --json | ConvertFrom-Json | Select-Object -ExpandProperty totals | Select-Object -ExpandProperty files_indexed", + root.display(), + binary.display() + ); + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("10")); +} + +#[test] +fn help_includes_repo_map_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--max-files")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("codeshape")); +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..f430e4c --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "common" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared CLI runtime helpers for the AI-friendly CLI toolbox." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +walkdir.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/common/src/formats.rs b/crates/common/src/formats.rs new file mode 100644 index 0000000..7437a72 --- /dev/null +++ b/crates/common/src/formats.rs @@ -0,0 +1,4 @@ +pub mod ison; +pub mod tonl; +pub mod toon; +pub mod zon; diff --git a/crates/common/src/formats/ison.rs b/crates/common/src/formats/ison.rs new file mode 100644 index 0000000..e4ebbce --- /dev/null +++ b/crates/common/src/formats/ison.rs @@ -0,0 +1,365 @@ +//! Shared ISON/ISONL v1 helpers for compact JSON-family records. + +use std::fmt::Write as _; + +use serde_json::{Map, Number, Value}; + +use crate::CliError; + +const RECORD_SEGMENT_ERROR: &str = "ISONL record expects 3 pipe-delimited segments"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldType { + Int, + Float, + Bool, + Str, + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Field { + name: String, + kind: FieldType, +} + +/// Encodes JSON object records into newline-delimited ISONL text. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when any record is not an object or has no fields. +pub fn encode_records(records: &[Value], record_name: &str) -> Result { + let mut output = String::with_capacity(records.len().saturating_mul(96)); + for record in records { + output.push_str(&encode_record(record, record_name)?); + output.push('\n'); + } + Ok(output) +} + +/// Encodes one JSON object into an ISONL record line. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when `record` is not an object or has no fields. +pub fn encode_record(record: &Value, record_name: &str) -> Result { + let object = record + .as_object() + .ok_or_else(|| CliError::usage("ISONL v1 expects each record to be a JSON object"))?; + if object.is_empty() { + return Err(CliError::usage( + "ISONL v1 cannot infer fields from an empty object", + )); + } + let fields = infer_fields(object); + let mut output = + String::with_capacity(record_name.len() + fields.len().saturating_mul(24) + 16); + output.push_str("object."); + output.push_str(record_name); + output.push('|'); + push_fields(&mut output, &fields); + output.push('|'); + push_row(&mut output, object, &fields); + Ok(output) +} + +/// Decodes one ISONL record line into a JSON object. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the line is malformed. +pub fn decode_record_line(line: &str, line_number: usize) -> Result { + let (header, fields_text, row_text) = split_record_line(line, line_number)?; + parse_header(header.trim(), line_number)?; + let fields = parse_fields(fields_text.trim(), line_number)?; + let row = parse_row(row_text.trim(), line_number, &fields)?; + Ok(Value::Object(row)) +} + +/// Decodes newline-delimited ISONL records into JSON objects. +/// +/// Reuses the previous field schema when consecutive records share the same +/// header and field definition, which is the common JSONL-style stream shape. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when any line is malformed. +pub fn decode_records(input: &str) -> Result, CliError> { + let mut records = Vec::new(); + let mut cached_header = ""; + let mut cached_fields_text = ""; + let mut cached_fields = Vec::new(); + + for (index, raw_line) in input.lines().enumerate() { + let line_number = index + 1; + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + let (header, fields_text, row_text) = split_record_line(line, line_number)?; + if header != cached_header || fields_text != cached_fields_text { + parse_header(header, line_number)?; + cached_fields = parse_fields(fields_text, line_number)?; + cached_header = header; + cached_fields_text = fields_text; + } + + records.push(Value::Object(parse_row( + row_text, + line_number, + &cached_fields, + )?)); + } + + Ok(records) +} + +fn split_record_line(line: &str, line_number: usize) -> Result<(&str, &str, &str), CliError> { + let mut parts = line.splitn(3, '|'); + let Some(header) = parts.next() else { + return Err(record_segment_error(line_number)); + }; + let Some(fields_text) = parts.next() else { + return Err(record_segment_error(line_number)); + }; + let Some(row_text) = parts.next() else { + return Err(record_segment_error(line_number)); + }; + Ok((header.trim(), fields_text.trim(), row_text.trim())) +} + +fn record_segment_error(line_number: usize) -> CliError { + CliError::usage(format!("line {line_number}: {RECORD_SEGMENT_ERROR}")) +} + +fn infer_fields(object: &Map) -> Vec { + object + .iter() + .map(|(name, value)| Field { + name: name.clone(), + kind: infer_type(value), + }) + .collect() +} + +fn infer_type(value: &Value) -> FieldType { + match value { + Value::Bool(_) => FieldType::Bool, + Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int, + Value::Number(_) => FieldType::Float, + Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str, + Value::Null => FieldType::Null, + } +} + +fn push_fields(output: &mut String, fields: &[Field]) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(' '); + } + output.push_str(&field.name); + output.push(':'); + output.push_str(field.kind.as_str()); + } +} + +fn push_row(output: &mut String, object: &Map, fields: &[Field]) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(' '); + } + push_value(output, object.get(&field.name).unwrap_or(&Value::Null)); + } +} + +fn push_value(output: &mut String, value: &Value) { + match value { + Value::Null => output.push_str("null"), + Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => { + let _ = write!(output, "{value}"); + } + Value::String(value) => push_string(output, value), + Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()), + } +} + +fn push_string(output: &mut String, value: &str) { + if value.is_empty() + || value + .chars() + .any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|')) + { + output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())); + } else { + output.push_str(value); + } +} + +fn parse_header(line: &str, line_number: usize) -> Result<(), CliError> { + let Some((kind, name)) = line.split_once('.') else { + return Err(CliError::usage(format!( + "line {line_number}: expected block header kind.name" + ))); + }; + if kind != "object" || name.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: expected object. header" + ))); + } + Ok(()) +} + +fn parse_fields(line: &str, line_number: usize) -> Result, CliError> { + let fields = line + .split_whitespace() + .map(|token| parse_field(token, line_number)) + .collect::, _>>()?; + if fields.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: field definition is empty" + ))); + } + Ok(fields) +} + +fn parse_field(token: &str, line_number: usize) -> Result { + let Some((name, kind)) = token.split_once(':') else { + return Err(CliError::usage(format!( + "line {line_number}: field '{token}' must be name:type" + ))); + }; + if name.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: field name must not be empty" + ))); + } + Ok(Field { + name: name.to_string(), + kind: parse_field_type(kind, line_number)?, + }) +} + +fn parse_field_type(kind: &str, line_number: usize) -> Result { + match kind { + "int" => Ok(FieldType::Int), + "float" => Ok(FieldType::Float), + "bool" => Ok(FieldType::Bool), + "str" => Ok(FieldType::Str), + "null" => Ok(FieldType::Null), + other => Err(CliError::usage(format!( + "line {line_number}: unknown field type '{other}'" + ))), + } +} + +fn parse_row( + line: &str, + line_number: usize, + fields: &[Field], +) -> Result, CliError> { + let values = split_row(line) + .map_err(|message| CliError::usage(format!("line {line_number}: {message}")))?; + if values.len() != fields.len() { + return Err(CliError::usage(format!( + "line {line_number}: expected {} values, got {}", + fields.len(), + values.len() + ))); + } + fields + .iter() + .zip(values) + .map(|(field, raw)| { + parse_value(raw, field.kind, line_number).map(|value| (field.name.clone(), value)) + }) + .collect() +} + +fn split_row(line: &str) -> Result, String> { + let mut values = Vec::new(); + let mut token_start = None; + let mut in_string = false; + let mut escaped = false; + for (index, character) in line.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + if character == '"' { + in_string = true; + token_start.get_or_insert(index); + } else if character.is_whitespace() { + if let Some(start) = token_start.take() { + values.push(&line[start..index]); + } + } else { + token_start.get_or_insert(index); + } + } + if in_string { + return Err("unterminated quoted string".to_string()); + } + if let Some(start) = token_start { + values.push(&line[start..]); + } + Ok(values) +} + +fn parse_value(raw: &str, kind: FieldType, line_number: usize) -> Result { + match kind { + FieldType::Int => parse_number_value(raw, line_number), + FieldType::Float => raw + .parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(Value::Number) + .ok_or_else(|| CliError::usage(format!("line {line_number}: invalid float '{raw}'"))), + FieldType::Bool => raw.parse::().map(Value::Bool).map_err(|error| { + CliError::usage(format!("line {line_number}: invalid bool '{raw}': {error}")) + }), + FieldType::Str => { + if raw.starts_with('"') { + serde_json::from_str::(raw) + .map(Value::String) + .map_err(|error| { + CliError::usage(format!("line {line_number}: invalid string: {error}")) + }) + } else { + Ok(Value::String(raw.to_string())) + } + } + FieldType::Null => Ok(Value::Null), + } +} + +fn parse_number_value(raw: &str, line_number: usize) -> Result { + if let Ok(value) = raw.parse::() { + return Ok(Value::Number(Number::from(value))); + } + raw.parse::() + .map(Number::from) + .map(Value::Number) + .map_err(|error| { + CliError::usage(format!("line {line_number}: invalid int '{raw}': {error}")) + }) +} + +impl FieldType { + const fn as_str(self) -> &'static str { + match self { + Self::Int => "int", + Self::Float => "float", + Self::Bool => "bool", + Self::Str => "str", + Self::Null => "null", + } + } +} diff --git a/crates/common/src/formats/tonl.rs b/crates/common/src/formats/tonl.rs new file mode 100644 index 0000000..3149671 --- /dev/null +++ b/crates/common/src/formats/tonl.rs @@ -0,0 +1,144 @@ +//! Shared TONL v1 helpers for JSON-backed key/value documents. + +use serde_json::{Map, Value}; + +use crate::CliError; + +/// Encodes JSON records into TONL documents. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered. +pub fn encode_documents(records: &[Value]) -> Result { + let mut output = String::with_capacity(records.len().saturating_mul(96)); + for (index, record) in records.iter().enumerate() { + if records.len() > 1 { + output.push_str("---\n"); + } else if index > 0 { + output.push('\n'); + } + push_document(&mut output, record)?; + } + Ok(output) +} + +/// Decodes TONL documents into JSON records. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when a line is malformed or a value is not JSON. +pub fn decode_documents(content: &str) -> Result, CliError> { + let mut records = Vec::new(); + let mut current = Map::new(); + let mut root_value = None; + for (index, line) in content.lines().enumerate() { + let line_number = index + 1; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if trimmed == "---" { + finish_record(&mut records, &mut current, &mut root_value); + continue; + } + let (key, raw_value) = trimmed.split_once('=').ok_or_else(|| { + CliError::usage(format!( + "line {line_number}: expected 'key = '" + )) + })?; + let key = key.trim(); + if key.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: key cannot be empty" + ))); + } + let value = serde_json::from_str::(raw_value.trim()).map_err(|error| { + CliError::usage(format!( + "line {line_number}: value must be a valid JSON value: {error}" + )) + })?; + if key == "$" { + if !current.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: '$' root value cannot be mixed with object fields" + ))); + } + root_value = Some(value); + } else { + if root_value.is_some() { + return Err(CliError::usage(format!( + "line {line_number}: object fields cannot be mixed with '$' root value" + ))); + } + current.insert(key.to_string(), value); + } + } + finish_record(&mut records, &mut current, &mut root_value); + if records.is_empty() { + return Err(CliError::usage("no TONL records found")); + } + Ok(records) +} + +fn push_document(output: &mut String, record: &Value) -> Result<(), CliError> { + match record { + Value::Object(object) => { + for (key, value) in object { + output.push_str(key); + output.push_str(" = "); + output.push_str(&compact_json(value)?); + output.push('\n'); + } + } + value => { + output.push_str("$ = "); + output.push_str(&compact_json(value)?); + output.push('\n'); + } + } + Ok(()) +} + +fn finish_record( + records: &mut Vec, + current: &mut Map, + root_value: &mut Option, +) { + if let Some(value) = root_value.take() { + records.push(value); + } else if !current.is_empty() { + records.push(Value::Object(std::mem::take(current))); + } +} + +fn compact_json(value: &Value) -> Result { + serde_json::to_string(value) + .map_err(|error| CliError::runtime(format!("failed to render json: {error}"))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{decode_documents, encode_documents}; + + #[test] + fn shared_tonl_roundtrips_objects_and_root_values() { + let records = vec![json!({"id": "a", "ok": true}), json!([1, 2])]; + let encoded = encode_documents(&records).expect("encode tonl"); + + assert!(encoded.contains("---\nid = \"a\"\nok = true\n")); + assert!(encoded.contains("---\n$ = [1,2]\n")); + assert_eq!(decode_documents(&encoded).expect("decode tonl"), records); + } + + #[test] + fn shared_tonl_reports_malformed_inputs() { + assert!(decode_documents("\n# comment\n").is_err()); + assert!(decode_documents("missing separator\n").is_err()); + assert!(decode_documents(" = 1\n").is_err()); + assert!(decode_documents("id = not-json\n").is_err()); + assert!(decode_documents("id = 1\n$ = 2\n").is_err()); + assert!(decode_documents("$ = 1\nid = 2\n").is_err()); + } +} diff --git a/crates/common/src/formats/toon.rs b/crates/common/src/formats/toon.rs new file mode 100644 index 0000000..8f3567f --- /dev/null +++ b/crates/common/src/formats/toon.rs @@ -0,0 +1,1203 @@ +//! Shared TOON encoder/decoder primitives for model-facing structured output. + +use std::fmt::Write as _; + +use serde_json::{Map, Number, Value}; + +use crate::CliError; + +const MAX_TOON_DEPTH: usize = 256; + +/// Delimiter used for compact inline and tabular array forms. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Delimiter { + /// Comma-separated values. + Comma, + /// Tab-separated values. + Tab, + /// Pipe-separated values. + Pipe, +} + +/// Controls path folding/expansion helpers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SafeMode { + /// Disable folding or expansion. + Off, + /// Enable only when the operation is unambiguous. + Safe, +} + +/// Options for JSON -> TOON encoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncodeOptions { + /// Spaces per indentation level. + pub indent: usize, + /// Delimiter used for inline arrays and tables. + pub delimiter: Delimiter, + /// Whether nested keys may be folded with dotted paths. + pub key_folding: SafeMode, + /// Maximum folded path segment count. + pub flatten_depth: usize, +} + +/// Options for TOON -> JSON decoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeOptions { + /// Spaces per indentation level. + pub indent: usize, + /// Enforce length/count checks where present. + pub strict: bool, + /// Whether dotted keys may expand into nested objects. + pub expand_paths: SafeMode, +} + +impl Default for EncodeOptions { + fn default() -> Self { + Self { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + } + } +} + +impl Default for DecodeOptions { + fn default() -> Self { + Self { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + } + } +} + +impl Delimiter { + const fn as_char(self) -> char { + match self { + Self::Comma => ',', + Self::Tab => '\t', + Self::Pipe => '|', + } + } + + const fn marker(self) -> &'static str { + match self { + Self::Comma => "", + Self::Tab => "\\t", + Self::Pipe => "|", + } + } +} + +/// Encodes a JSON value as compact TOON text. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when the JSON value exceeds the explicit TOON encoding depth +/// limit. +pub fn encode_value(value: &Value, options: EncodeOptions) -> Result { + ensure_encode_depth(value)?; + let mut output = String::with_capacity(estimate_output_size(value, options)); + encode_node(value, 0, options, &mut output); + Ok(output) +} + +/// Encodes with Mercury's default shared TOON settings. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when the JSON value exceeds the explicit TOON encoding depth +/// limit. +pub fn encode_value_default(value: &Value) -> Result { + encode_value(value, EncodeOptions::default()) +} + +fn ensure_encode_depth(value: &Value) -> Result<(), CliError> { + let mut stack = vec![(value, 0_usize)]; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_TOON_DEPTH { + return Err(CliError::runtime(format!( + "maximum TOON encoding depth of {MAX_TOON_DEPTH} exceeded" + ))); + } + + match node { + Value::Array(items) => { + for item in items { + stack.push((item, depth + 1)); + } + } + Value::Object(object) => { + for child in object.values() { + stack.push((child, depth + 1)); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + } + + Ok(()) +} + +/// Decodes the shared TOON subset into JSON. +/// +/// This decoder intentionally covers the structured forms Mercury emits and the +/// fixtures used by the standalone `toon` CLI; compatibility-only parsing paths +/// are kept in the CLI while they are migrated here. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when input is malformed. +pub fn decode_str(input: &str, options: DecodeOptions) -> Result { + let lines = input + .lines() + .enumerate() + .filter_map(|(index, raw)| { + let trimmed_end = raw.trim_end(); + (!trimmed_end.trim().is_empty()).then(|| (index + 1, trimmed_end.to_string())) + }) + .collect::>(); + let mut cursor = Cursor { lines, index: 0 }; + parse_root(&mut cursor, options) +} + +fn encode_node(value: &Value, depth: usize, options: EncodeOptions, output: &mut String) { + match value { + Value::Object(object) => { + for (key, child) in object { + encode_field(key, child, depth, options, output); + } + } + Value::Array(array) => encode_array(None, array, depth, options, output), + primitive => push_line(output, |line| { + line.push_str(&indent(depth, options)); + line.push_str(&primitive_text(primitive, options)); + }), + } +} + +fn encode_field( + key: &str, + value: &Value, + depth: usize, + options: EncodeOptions, + output: &mut String, +) { + match value { + Value::Object(object) if object.is_empty() => push_line(output, |line| { + line.push_str(&indent(depth, options)); + line.push_str(&encode_key(key)); + line.push_str(": {}"); + }), + Value::Array(array) => encode_array(Some(key), array, depth, options, output), + Value::Object(_) => { + push_line(output, |line| { + line.push_str(&indent(depth, options)); + line.push_str(&encode_key(key)); + line.push(':'); + }); + encode_node(value, depth + 1, options, output); + } + primitive => push_line(output, |line| { + line.push_str(&indent(depth, options)); + line.push_str(&encode_key(key)); + line.push_str(": "); + line.push_str(&primitive_text(primitive, options)); + }), + } +} + +fn encode_array( + key: Option<&str>, + array: &[Value], + depth: usize, + options: EncodeOptions, + output: &mut String, +) { + if let Some(fields) = tabular_fields(array) { + let mut encoded_fields = String::with_capacity(fields.len().saturating_mul(12)); + push_encoded_fields(&mut encoded_fields, &fields, options.delimiter.as_char()); + push_line(output, |line| { + line.push_str(&indent(depth, options)); + if let Some(key) = key { + line.push_str(&encode_key(key)); + } + let _ = write!( + line, + "[{}{}]{{{}}}:", + array.len(), + options.delimiter.marker(), + encoded_fields + ); + }); + for row in array { + if let Some(object) = row.as_object() { + let mut values = String::with_capacity(fields.len().saturating_mul(12)); + push_tabular_values(&mut values, object, &fields, options); + push_line(output, |line| { + line.push_str(&indent(depth + 1, options)); + line.push_str(&values); + }); + } else { + encode_node(row, depth + 1, options, output); + } + } + return; + } + + if array.iter().all(is_inline_scalar) { + let values = joined_primitive_values(array, options); + push_line(output, |line| { + line.push_str(&indent(depth, options)); + if let Some(key) = key { + line.push_str(&encode_key(key)); + } + let _ = write!( + line, + "[{}{}]: {}", + array.len(), + options.delimiter.marker(), + values + ); + }); + return; + } + + if array.is_empty() { + push_line(output, |line| { + line.push_str(&indent(depth, options)); + if let Some(key) = key { + line.push_str(&encode_key(key)); + } + line.push_str(": []"); + }); + return; + } + + if let Some(key) = key { + push_line(output, |line| { + line.push_str(&indent(depth, options)); + line.push_str(&encode_key(key)); + line.push(':'); + }); + } + for value in array { + match value { + Value::Object(object) if object.is_empty() => push_line(output, |line| { + line.push_str(&indent(depth + usize::from(key.is_some()), options)); + line.push_str("- {}"); + }), + Value::Object(_) | Value::Array(_) => { + push_line(output, |line| { + line.push_str(&indent(depth + usize::from(key.is_some()), options)); + line.push('-'); + }); + encode_node( + value, + depth + 1 + usize::from(key.is_some()), + options, + output, + ); + } + primitive => push_line(output, |line| { + line.push_str(&indent(depth + usize::from(key.is_some()), options)); + line.push_str("- "); + line.push_str(&primitive_text(primitive, options)); + }), + } + } +} + +fn tabular_fields(array: &[Value]) -> Option> { + let first = array.first()?.as_object()?; + if first.is_empty() || !first.values().all(is_inline_scalar) { + return None; + } + let fields = first.keys().cloned().collect::>(); + let same_shape = array.iter().all(|row| { + row.as_object().is_some_and(|object| { + object.len() == fields.len() + && fields.iter().all(|field| object.contains_key(field)) + && object.values().all(is_inline_scalar) + }) + }); + same_shape.then_some(fields) +} + +fn push_encoded_fields(output: &mut String, fields: &[String], delimiter: char) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(delimiter); + } + output.push_str(&encode_key(field)); + } +} + +fn push_tabular_values( + output: &mut String, + object: &Map, + fields: &[String], + options: EncodeOptions, +) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(options.delimiter.as_char()); + } + if let Some(value) = object.get(field) { + output.push_str(&primitive_text(value, options)); + } + } +} + +fn joined_primitive_values(array: &[Value], options: EncodeOptions) -> String { + let mut output = String::with_capacity(array.len().saturating_mul(12)); + for (index, value) in array.iter().enumerate() { + if index > 0 { + output.push(options.delimiter.as_char()); + } + output.push_str(&primitive_text(value, options)); + } + output +} + +fn push_line(output: &mut String, append: impl FnOnce(&mut String)) { + if !output.is_empty() { + output.push('\n'); + } + append(output); +} + +fn estimate_output_size(value: &Value, options: EncodeOptions) -> usize { + estimate_line_count(value).saturating_mul(options.indent.max(1).saturating_add(24)) +} + +fn estimate_line_count(value: &Value) -> usize { + match value { + Value::Object(object) => object + .values() + .map(estimate_line_count) + .sum::() + .max(object.len()), + Value::Array(items) => items.len().saturating_add(1), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => 1, + } +} + +const fn is_inline_scalar(value: &Value) -> bool { + matches!( + value, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) +} + +fn primitive_text(value: &Value, options: EncodeOptions) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => canonical_number(value), + Value::String(value) if is_safe_unquoted(value, options.delimiter) => value.clone(), + Value::String(value) => json_string_literal(value), + Value::Array(_) | Value::Object(_) => "{}".to_string(), + } +} + +fn canonical_number(value: &Number) -> String { + let rendered = value.to_string(); + if rendered == "-0.0" { + "0".to_string() + } else { + rendered + } +} + +fn encode_key(key: &str) -> String { + if is_valid_unquoted_key(key) { + key.to_string() + } else { + json_string_literal(key) + } +} + +fn json_string_literal(value: &str) -> String { + match serde_json::to_string(value) { + Ok(rendered) => rendered, + Err(error) => { + debug_assert!(false, "serializing a Rust string to JSON failed: {error}"); + format!("\"serialization-error:{error}\"") + } + } +} + +fn indent(depth: usize, options: EncodeOptions) -> String { + " ".repeat(depth * options.indent) +} + +fn is_valid_unquoted_key(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|character| { + character == '_' + || character == '-' + || character == '.' + || character.is_ascii_alphanumeric() + }) +} + +fn is_safe_unquoted(value: &str, delimiter: Delimiter) -> bool { + !value.is_empty() + && !matches!(value, "null" | "true" | "false") + && !value.chars().any(char::is_whitespace) + && !value.contains([':', ',', '[', ']', '{', '}', '"', '#']) + && (delimiter != Delimiter::Pipe || !value.contains('|')) + && (delimiter != Delimiter::Tab || !value.contains('\t')) +} + +#[derive(Debug)] +struct Cursor { + lines: Vec<(usize, String)>, + index: usize, +} + +impl Cursor { + fn peek(&self) -> Option<(usize, &str)> { + self.lines + .get(self.index) + .map(|(line, text)| (*line, text.as_str())) + } + + fn next(&mut self) -> Option<(usize, String)> { + let item = self.lines.get(self.index).cloned()?; + self.index += 1; + Some(item) + } +} + +fn parse_root(cursor: &mut Cursor, options: DecodeOptions) -> Result { + let Some((line_number, line)) = cursor.peek() else { + return Ok(Value::Object(Map::new())); + }; + let depth = line_depth(line, options, line_number)?; + if depth != 0 { + return Err(CliError::runtime(format!( + "unexpected indentation at line {line_number}" + ))); + } + + let content = line.trim_start().to_string(); + if content.starts_with('-') { + return parse_list_array(cursor, 0, options); + } + + if let Some((raw_key, raw_value)) = content.split_once(':') { + let (key, array_header) = parse_key_header(raw_key.trim(), line_number)?; + if key.text.is_empty() { + let Some((_, _)) = cursor.next() else { + return Err(CliError::runtime( + "internal TOON parser cursor advanced past root header", + )); + }; + let Some(header) = array_header else { + return Err(CliError::runtime(format!( + "root key is missing at line {line_number}" + ))); + }; + return parse_array_value(cursor, raw_value.trim(), &header, 0, options, line_number); + } + return parse_object(cursor, 0, options).map(Value::Object); + } + + if cursor.lines.len() > 1 { + return Err(CliError::runtime(format!( + "expected key/value pair at line {line_number}" + ))); + } + let Some((_, scalar)) = cursor.next() else { + return Err(CliError::runtime( + "internal TOON parser cursor advanced past root scalar", + )); + }; + parse_scalar_or_inline_array(scalar.trim(), Delimiter::Comma) +} + +fn parse_object( + cursor: &mut Cursor, + depth: usize, + options: DecodeOptions, +) -> Result, CliError> { + let mut object = Map::new(); + while let Some((line_number, line)) = cursor.peek() { + let current_depth = line_depth(line, options, line_number)?; + if current_depth < depth { + break; + } + if current_depth > depth { + return Err(CliError::runtime(format!( + "unexpected indentation at line {line_number}" + ))); + } + + let Some((_, owned)) = cursor.next() else { + return Err(CliError::runtime( + "internal TOON parser cursor advanced past peeked line", + )); + }; + let content = owned.trim_start(); + let (raw_key, raw_value) = content.split_once(':').ok_or_else(|| { + CliError::runtime(format!("expected key/value pair at line {line_number}")) + })?; + let (key, array_header) = parse_key_header(raw_key.trim(), line_number)?; + let value = if let Some(header) = array_header { + parse_array_value( + cursor, + raw_value.trim(), + &header, + depth, + options, + line_number, + )? + } else if raw_value.trim().is_empty() { + parse_nested_block(cursor, depth + 1, options)? + } else { + parse_scalar_or_inline_array(raw_value.trim(), Delimiter::Comma)? + }; + insert_key(&mut object, &key, value, options)?; + } + Ok(object) +} + +fn parse_nested_block( + cursor: &mut Cursor, + depth: usize, + options: DecodeOptions, +) -> Result { + let Some((line_number, line)) = cursor.peek() else { + return Ok(Value::Object(Map::new())); + }; + let current_depth = line_depth(line, options, line_number)?; + if current_depth < depth { + return Ok(Value::Object(Map::new())); + } + if current_depth > depth { + return Err(CliError::runtime(format!( + "unexpected indentation at line {line_number}" + ))); + } + + let content = line.trim_start().to_string(); + if content.starts_with('-') { + return parse_list_array(cursor, depth, options); + } + if let Some((raw_key, raw_value)) = content.split_once(':') { + let (key, array_header) = parse_key_header(raw_key.trim(), line_number)?; + if key.text.is_empty() { + let Some((_, _)) = cursor.next() else { + return Err(CliError::runtime( + "internal TOON parser cursor advanced past nested header", + )); + }; + let Some(header) = array_header else { + return Err(CliError::runtime(format!( + "nested key is missing at line {line_number}" + ))); + }; + return parse_array_value( + cursor, + raw_value.trim(), + &header, + depth, + options, + line_number, + ); + } + } + + parse_object(cursor, depth, options).map(Value::Object) +} + +fn parse_list_array( + cursor: &mut Cursor, + depth: usize, + options: DecodeOptions, +) -> Result { + let mut values = Vec::new(); + while let Some((line_number, line)) = cursor.peek() { + let current_depth = line_depth(line, options, line_number)?; + if current_depth < depth { + break; + } + if current_depth > depth { + return Err(CliError::runtime(format!( + "unexpected indentation at line {line_number}" + ))); + } + let content = line.trim_start(); + if !content.starts_with('-') { + break; + } + + let Some((_, owned)) = cursor.next() else { + return Err(CliError::runtime( + "internal TOON parser cursor advanced past list item", + )); + }; + let after_hyphen = owned.trim_start()[1..].trim_start(); + let value = if after_hyphen.is_empty() { + parse_nested_block(cursor, depth + 1, options)? + } else if after_hyphen == "{}" { + Value::Object(Map::new()) + } else if let Some((raw_key, raw_value)) = after_hyphen.split_once(':') { + let (key, array_header) = parse_key_header(raw_key.trim(), line_number)?; + if key.text.is_empty() { + let Some(header) = array_header else { + return Err(CliError::runtime(format!( + "list item key is missing at line {line_number}" + ))); + }; + parse_array_value( + cursor, + raw_value.trim(), + &header, + depth, + options, + line_number, + )? + } else { + parse_scalar_or_inline_array(after_hyphen, Delimiter::Comma)? + } + } else { + parse_scalar_or_inline_array(after_hyphen, Delimiter::Comma)? + }; + values.push(value); + } + Ok(Value::Array(values)) +} + +fn parse_array_value( + cursor: &mut Cursor, + inline: &str, + header: &ArrayHeader, + depth: usize, + options: DecodeOptions, + line_number: usize, +) -> Result { + if let Some(fields) = &header.fields { + let mut rows = Vec::new(); + for _ in 0..header.len { + let Some((row_line, row_text)) = cursor.peek() else { + return Err(CliError::runtime(format!( + "expected {} table rows after line {line_number}", + header.len + ))); + }; + let row_depth = line_depth(row_text, options, row_line)?; + if row_depth != depth + 1 { + return Err(CliError::runtime(format!( + "expected table row indentation at line {row_line}" + ))); + } + let Some((_, row)) = cursor.next() else { + return Err(CliError::runtime( + "internal TOON parser cursor advanced past peeked row", + )); + }; + let values = split_delimited(row.trim(), header.delimiter)?; + if options.strict && values.len() != fields.len() { + return Err(CliError::runtime(format!( + "expected {} table columns at line {row_line}, got {}", + fields.len(), + values.len() + ))); + } + let mut object = Map::new(); + for (field, value) in fields.iter().zip(values) { + object.insert(field.clone(), parse_scalar(&value)?); + } + rows.push(Value::Object(object)); + } + return Ok(Value::Array(rows)); + } + + let values = split_delimited(inline, header.delimiter)?; + if options.strict && values.len() != header.len { + return Err(CliError::runtime(format!( + "expected {} inline array items at line {line_number}, got {}", + header.len, + values.len() + ))); + } + values + .iter() + .map(|value| parse_scalar(value)) + .collect::, _>>() + .map(Value::Array) +} + +#[derive(Debug)] +struct ArrayHeader { + len: usize, + delimiter: Delimiter, + fields: Option>, +} + +#[derive(Debug)] +struct ParsedKey { + text: String, + was_quoted: bool, +} + +fn parse_key_header( + raw: &str, + line_number: usize, +) -> Result<(ParsedKey, Option), CliError> { + let header_search_start = if raw.starts_with('"') { + let closing_quote = find_json_string_end(raw).ok_or_else(|| { + CliError::runtime(format!("unterminated quoted key at line {line_number}")) + })?; + closing_quote + 1 + } else { + 0 + }; + let Some(open) = raw[header_search_start..] + .find('[') + .map(|offset| header_search_start + offset) + else { + return Ok((parse_key(raw)?, None)); + }; + let close = raw[open + 1..] + .find(']') + .map(|offset| open + 1 + offset) + .ok_or_else(|| { + CliError::runtime(format!("unterminated array header at line {line_number}")) + })?; + let key = parse_key(raw[..open].trim())?; + let marker = &raw[open + 1..close]; + let (len_text, delimiter) = marker.strip_suffix('|').map_or_else( + || { + marker + .strip_suffix("\\t") + .map_or((marker, Delimiter::Comma), |stripped| { + (stripped, Delimiter::Tab) + }) + }, + |stripped| (stripped, Delimiter::Pipe), + ); + let len = len_text.parse::().map_err(|error| { + CliError::runtime(format!( + "invalid array length at line {line_number}: {error}" + )) + })?; + let fields = if raw[close + 1..].starts_with('{') { + let rest = &raw[close + 2..]; + let end = rest.find('}').ok_or_else(|| { + CliError::runtime(format!("unterminated table header at line {line_number}")) + })?; + Some( + split_delimited(&rest[..end], delimiter)? + .into_iter() + .map(|field| parse_key(&field).map(|key| key.text)) + .collect::, _>>()?, + ) + } else { + None + }; + Ok(( + key, + Some(ArrayHeader { + len, + delimiter, + fields, + }), + )) +} + +fn parse_scalar_or_inline_array(raw: &str, delimiter: Delimiter) -> Result { + if raw == "[]" { + return Ok(Value::Array(Vec::new())); + } + if raw.starts_with('[') && raw.ends_with(']') { + return split_delimited(&raw[1..raw.len() - 1], delimiter)? + .iter() + .map(|value| parse_scalar(value)) + .collect::, _>>() + .map(Value::Array); + } + parse_scalar(raw) +} + +fn parse_scalar(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(Value::String(String::new())); + } + if trimmed.starts_with('"') { + return serde_json::from_str::(trimmed) + .map(Value::String) + .map_err(|error| CliError::runtime(format!("invalid quoted string: {error}"))); + } + match trimmed { + "null" => Ok(Value::Null), + "true" => Ok(Value::Bool(true)), + "false" => Ok(Value::Bool(false)), + _ => trimmed + .parse::() + .map(|value| Value::Number(value.into())) + .or_else(|_| { + trimmed + .parse::() + .ok() + .and_then(Number::from_f64) + .map(Value::Number) + .ok_or(()) + }) + .or_else(|()| Ok(Value::String(trimmed.to_string()))), + } +} + +fn parse_key(raw: &str) -> Result { + if raw.starts_with('"') { + serde_json::from_str::(raw) + .map(|text| ParsedKey { + text, + was_quoted: true, + }) + .map_err(|error| CliError::runtime(format!("invalid quoted key: {error}"))) + } else { + Ok(ParsedKey { + text: raw.to_string(), + was_quoted: false, + }) + } +} + +fn find_json_string_end(raw: &str) -> Option { + let mut escaped = false; + for (index, character) in raw.char_indices().skip(1) { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if character == '"' { + return Some(index); + } + } + None +} + +fn split_delimited(raw: &str, delimiter: Delimiter) -> Result, CliError> { + if raw.is_empty() { + return Ok(Vec::new()); + } + let separator = delimiter.as_char(); + let mut values = Vec::new(); + let mut current = String::new(); + let mut chars = raw.chars(); + let mut in_quotes = false; + while let Some(character) = chars.next() { + if character == '\\' && in_quotes { + current.push(character); + if let Some(next) = chars.next() { + current.push(next); + } + continue; + } + if character == '"' { + in_quotes = !in_quotes; + current.push(character); + continue; + } + if character == separator && !in_quotes { + values.push(current.trim().to_string()); + current.clear(); + continue; + } + current.push(character); + } + if in_quotes { + return Err(CliError::runtime("unterminated quoted value")); + } + values.push(current.trim().to_string()); + Ok(values) +} + +fn line_depth(line: &str, options: DecodeOptions, line_number: usize) -> Result { + let spaces = line + .chars() + .take_while(|character| *character == ' ') + .count(); + if options.indent == 0 || spaces % options.indent != 0 { + return Err(CliError::runtime(format!( + "indentation must be a multiple of {} at line {line_number}", + options.indent + ))); + } + let depth = spaces / options.indent; + if depth > MAX_TOON_DEPTH { + return Err(CliError::runtime(format!( + "maximum TOON nesting depth of {MAX_TOON_DEPTH} exceeded at line {line_number}" + ))); + } + Ok(depth) +} + +fn insert_key( + object: &mut Map, + key: &ParsedKey, + value: Value, + options: DecodeOptions, +) -> Result<(), CliError> { + if options.expand_paths == SafeMode::Safe && !key.was_quoted && key.text.contains('.') { + let parts = key.text.split('.').collect::>(); + if parts.len() > MAX_TOON_DEPTH { + return Err(CliError::runtime(format!( + "maximum TOON path depth of {MAX_TOON_DEPTH} exceeded for '{}'", + key.text + ))); + } + insert_path(object, &parts, value, options.strict) + } else { + if options.strict && object.contains_key(&key.text) { + return Err(CliError::runtime(format!( + "duplicate or conflicting key '{}'", + key.text + ))); + } + object.insert(key.text.clone(), value); + Ok(()) + } +} + +fn insert_path( + object: &mut Map, + parts: &[&str], + value: Value, + strict: bool, +) -> Result<(), CliError> { + if let Some((head, tail)) = parts.split_first() { + if tail.is_empty() { + if strict && object.contains_key(*head) { + return Err(CliError::runtime(format!( + "duplicate or conflicting path '{}'", + parts.join(".") + ))); + } + object.insert((*head).to_string(), value); + return Ok(()); + } + let entry = object + .entry((*head).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(child) = entry { + insert_path(child, tail, value, strict) + } else if strict { + Err(CliError::runtime(format!( + "path '{}' conflicts with a scalar value", + parts.join(".") + ))) + } else { + *entry = Value::Object(Map::new()); + if let Value::Object(child) = entry { + insert_path(child, tail, value, strict) + } else { + Ok(()) + } + } + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::CliError; + use serde_json::json; + + use super::{DecodeOptions, Delimiter, EncodeOptions, SafeMode, decode_str, encode_value}; + + #[test] + fn shared_encoder_handles_nested_and_tabular_values() { + let rendered = encode_value( + &json!({ + "context": { + "task": "Shared TOON core", + "location": "common", + }, + "items": [ + {"id": 1, "name": "Ada"}, + {"id": 2, "name": "Bob"}, + ], + }), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + }, + ) + .expect("encode"); + + assert!(rendered.starts_with("context:\n")); + assert!(rendered.contains(" location: common\n")); + assert!(rendered.contains(" task: \"Shared TOON core\"\n")); + assert!(rendered.contains("items[2]{id,name}:\n 1,Ada\n 2,Bob")); + } + + #[test] + fn shared_decoder_handles_tabular_values_and_path_expansion() { + let decoded = decode_str( + "data.metadata.items[2]: a,b\nrows[2]{id,name}:\n 1,Ada\n 2,Bob\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ) + .expect("decode should succeed"); + + assert_eq!( + decoded, + json!({ + "data": { + "metadata": { + "items": ["a", "b"], + }, + }, + "rows": [ + {"id": 1, "name": "Ada"}, + {"id": 2, "name": "Bob"}, + ], + }) + ); + } + + #[test] + fn shared_decoder_roundtrips_root_arrays_and_primitives() { + for value in [ + json!([1, 2, 3]), + json!([{"id": 1}, {"id": 2}]), + json!("plain"), + json!(42), + ] { + let encoded = encode_value(&value, EncodeOptions::default()).expect("encode"); + let decoded = decode_str(&encoded, DecodeOptions::default()).expect("decode"); + assert_eq!(decoded, value); + } + } + + #[test] + fn shared_decoder_keeps_quoted_dotted_and_bracket_keys_literal() { + let decoded = decode_str( + "\"literal.path\": keep\n\"a[b]\": 1\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ) + .expect("decode should preserve quoted keys"); + + assert_eq!( + decoded, + json!({ + "literal.path": "keep", + "a[b]": 1, + }) + ); + } + + #[test] + fn shared_decoder_rejects_path_expansion_conflicts() { + let scalar_then_path = decode_str( + "a: 1\na.b: 2\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ); + assert!(scalar_then_path.is_err()); + + let path_then_scalar = decode_str( + "a.b: 1\na: 2\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ); + assert!(path_then_scalar.is_err()); + } + + #[test] + fn shared_decoder_rejects_nesting_past_explicit_depth_limit() { + let mut input = String::new(); + for depth in 0..260 { + input.push_str(&" ".repeat(depth)); + input.push_str("key:\n"); + } + + let error = decode_str(&input, DecodeOptions::default()).expect_err("depth limit"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("maximum TOON nesting depth") + )); + } + + #[test] + fn shared_decoder_rejects_path_expansion_past_explicit_depth_limit() { + let key = (0..260) + .map(|index| format!("k{index}")) + .collect::>() + .join("."); + let input = format!("{key}: 1"); + + let error = decode_str( + &input, + DecodeOptions { + expand_paths: SafeMode::Safe, + ..DecodeOptions::default() + }, + ) + .expect_err("path depth limit"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("maximum TOON path depth") + )); + } + + #[test] + fn shared_encoder_handles_empty_and_mixed_array_shapes() { + let value = json!({ + "empty_object": {}, + "empty_array": [], + "mixed": [ + {}, + {"name": "Ada"}, + [1, 2], + "tail" + ], + }); + let encoded = encode_value(&value, EncodeOptions::default()).expect("encode"); + + assert!(encoded.contains("empty_array[0]: ")); + assert!(encoded.contains("empty_object: {}")); + assert!( + encoded.contains("mixed:\n - {}\n -\n name: Ada\n -\n [2]: 1,2\n - tail") + ); + assert!(!encoded.ends_with('\n')); + assert_eq!( + encode_value(&json!([]), EncodeOptions::default()).expect("encode"), + "[0]: " + ); + } +} diff --git a/crates/common/src/formats/zon.rs b/crates/common/src/formats/zon.rs new file mode 100644 index 0000000..9e4c257 --- /dev/null +++ b/crates/common/src/formats/zon.rs @@ -0,0 +1,168 @@ +//! Shared Zero Overhead Notation (ZON) v1 helpers. + +use serde_json::{Map, Value}; + +use crate::CliError; + +/// Encodes a JSON value into the Mercury ZON v1 subset. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered. +pub fn encode_value(value: &Value) -> Result { + let mut output = String::with_capacity(estimate_capacity(value)); + match value { + Value::Object(object) => encode_object(object, &mut output)?, + primitive => { + output.push_str(&render_inline_value(primitive)?); + output.push('\n'); + } + } + Ok(output) +} + +fn estimate_capacity(value: &Value) -> usize { + match value { + Value::Object(object) => object.len().saturating_mul(64), + Value::Array(items) => items.len().saturating_mul(48), + Value::String(text) => text.len() + 8, + Value::Null | Value::Bool(_) | Value::Number(_) => 16, + } +} + +/// Decodes the Mercury ZON v1 subset into JSON. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when a line is malformed. +pub fn decode_str(input: &str) -> Result { + let mut object = Map::new(); + let mut scalar = None; + for (index, line) in input.lines().enumerate() { + let line_number = index + 1; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some((key, raw_value)) = trimmed.split_once(':') { + if key.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: key cannot be empty" + ))); + } + object.insert( + key.to_string(), + parse_inline_value(raw_value.trim(), line_number)?, + ); + } else if scalar + .replace(parse_inline_value(trimmed, line_number)?) + .is_some() + { + return Err(CliError::usage(format!( + "line {line_number}: multiple scalar ZON values are not supported" + ))); + } + } + if object.is_empty() { + scalar.ok_or_else(|| CliError::usage("ZON input is empty")) + } else { + Ok(Value::Object(object)) + } +} + +fn encode_object(object: &Map, output: &mut String) -> Result<(), CliError> { + for (key, value) in object { + output.push_str(key); + output.push(':'); + output.push_str(&render_inline_value(value)?); + output.push('\n'); + } + Ok(()) +} + +fn render_inline_value(value: &Value) -> Result { + serde_json::to_string(value) + .map_err(|error| CliError::runtime(format!("failed to render ZON inline value: {error}"))) +} + +fn parse_inline_value(raw: &str, line_number: usize) -> Result { + if raw.is_empty() { + return Ok(Value::String(String::new())); + } + if let Some(value) = parse_keyword_value(raw) { + return Ok(value); + } + if raw.starts_with(['"', '[', '{']) || looks_like_number(raw) { + return serde_json::from_str::(raw).map_err(|error| { + CliError::usage(format!( + "line {line_number}: invalid inline JSON value: {error}" + )) + }); + } + Ok(Value::String(raw.to_string())) +} + +fn parse_keyword_value(raw: &str) -> Option { + match raw { + "true" => Some(Value::Bool(true)), + "false" => Some(Value::Bool(false)), + "null" => Some(Value::Null), + _ => None, + } +} + +fn looks_like_number(value: &str) -> bool { + value + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b'-' | b'0'..=b'9')) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn encodes_and_decodes_object_and_scalar_values() { + let object = json!({ + "active": true, + "count": 3, + "name": "mercury", + "notes": "", + "tags": ["fast", "portable"], + }); + + let encoded = encode_value(&object).expect("encoded object"); + let decoded = decode_str(&encoded).expect("decoded object"); + + assert_eq!(decoded, object); + assert_eq!(decode_str("null\n").expect("null scalar"), Value::Null); + assert_eq!( + decode_str("plain-text\n").expect("bare string"), + json!("plain-text") + ); + } + + #[test] + fn reports_malformed_zon_inputs() { + let empty_key = decode_str(":true\n").expect_err("empty key"); + assert!(matches!( + empty_key, + CliError::Usage(message) if message.contains("key cannot be empty") + )); + + let multiple_scalars = decode_str("1\n2\n").expect_err("multiple scalars"); + assert!(matches!( + multiple_scalars, + CliError::Usage(message) if message.contains("multiple scalar ZON values") + )); + + let invalid_inline = decode_str("value:[1,,2]\n").expect_err("invalid inline JSON"); + assert!(matches!( + invalid_inline, + CliError::Usage(message) if message.contains("invalid inline JSON value") + )); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..1ffc366 --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,1502 @@ +//! Shared CLI policy and rendering helpers for the toolbox workspace. + +use std::fmt::Display; +use std::fs; +use std::io::{self, IsTerminal, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde::Serialize; +use serde_json::Value; +use thiserror::Error; +use walkdir::WalkDir; + +/// Shared compact structured-data formats. +pub mod formats; + +/// Describes how a command should interpret inbound stream data. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputFormat { + /// Auto-detect whether each line is JSON or plain text. + #[default] + Auto, + /// Treat each line as opaque text. + Lines, + /// Treat each line as a standalone JSON document. + Jsonl, +} + +/// Controls ANSI color behavior for command output. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColorChoice { + /// Enable color only when the output stream looks interactive. + #[default] + Auto, + /// Never emit ANSI color sequences. + Never, +} + +/// Selects the output surface for a command invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + /// Emit compact text for humans and token-sensitive AI flows. + Text, + /// Emit a single JSON document for machine consumption. + Json, + /// Emit TOON text for model-facing structured output. + Toon, +} + +/// Shared flags that every toolbox command accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CommonArgs { + /// Emit JSON instead of the default text output. + pub json: bool, + /// Explicit structured output format selected by `--format`, `--json`, or `--toon`. + pub format: Option, + /// Override stdin parsing mode. + pub input_format: InputFormat, + /// Control ANSI color output. + pub color: ColorChoice, + /// Suppress non-essential status output. + pub quiet: bool, +} + +impl CommonArgs { + /// Records an explicit output format selection. + pub fn set_render_mode(&mut self, render_mode: RenderMode) { + self.json = render_mode == RenderMode::Json; + self.format = Some(render_mode); + } + + /// Returns the render mode implied by explicit flags or `MERCURY_OUTPUT`. + #[must_use] + pub fn render_mode(&self) -> RenderMode { + if let Some(format) = self.format { + return format; + } + if self.json { + return RenderMode::Json; + } + + parse_render_mode_override(std::env::var("MERCURY_OUTPUT").ok().as_deref()) + .unwrap_or(RenderMode::Text) + } + + /// Reports whether stdin is attached to an interactive terminal. + #[must_use] + pub fn stdin_is_terminal(&self) -> bool { + io::stdin().is_terminal() + } +} + +fn parse_render_mode_override(value: Option<&str>) -> Option { + let trimmed = value.map(str::trim).filter(|item| !item.is_empty())?; + if trimmed.eq_ignore_ascii_case("json") { + Some(RenderMode::Json) + } else if trimmed.eq_ignore_ascii_case("toon") { + Some(RenderMode::Toon) + } else if trimmed.eq_ignore_ascii_case("text") { + Some(RenderMode::Text) + } else { + None + } +} + +/// Stable process exit codes shared by every command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitCode { + /// The command succeeded and produced at least one result. + Success = 0, + /// The command succeeded but produced no matching results. + NoResults = 1, + /// The invocation was rejected because the input was invalid. + UsageError = 2, + /// The command hit an operational failure at runtime. + RuntimeError = 3, +} + +impl ExitCode { + /// Converts the enum into a process exit code. + #[must_use] + pub const fn as_i32(self) -> i32 { + self as i32 + } +} + +/// Represents user-facing command failures. +#[derive(Debug, Error)] +pub enum CliError { + /// The user supplied invalid arguments or malformed input. + #[error("{0}")] + Usage(String), + /// The command failed while reading, probing, or rendering data. + #[error("{0}")] + Runtime(String), +} + +impl CliError { + /// Builds a usage error. + #[must_use] + pub fn usage(message: impl Into) -> Self { + Self::Usage(message.into()) + } + + /// Builds a runtime error. + #[must_use] + pub fn runtime(message: impl Into) -> Self { + Self::Runtime(message.into()) + } + + /// Returns the exit code associated with this error category. + #[must_use] + pub const fn exit_code(&self) -> ExitCode { + match self { + Self::Usage(_) => ExitCode::UsageError, + Self::Runtime(_) => ExitCode::RuntimeError, + } + } +} + +/// Decides whether a command should consume stdin for input records. +#[must_use] +pub const fn should_read_stdin(has_explicit_input: bool, stdin_is_terminal: bool) -> bool { + !has_explicit_input && !stdin_is_terminal +} + +/// Maps a match count into the shared success/no-result exit code contract. +#[must_use] +pub const fn map_result_count(count: usize) -> ExitCode { + if count == 0 { + ExitCode::NoResults + } else { + ExitCode::Success + } +} + +/// Resolves stdin line input into existing filesystem paths when possible. +/// +/// `auto` only recognizes plain-text path lines so JSON/JSONL payloads are left +/// alone. `lines` and `jsonl` are strict path-stream modes and return usage +/// errors when a line does not resolve to an existing path. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when explicit path-stream modes receive invalid +/// path records. +pub fn read_existing_stdin_paths( + buffer: &str, + input_format: InputFormat, + command_name: &str, +) -> Result>, CliError> { + let non_empty_lines = buffer + .lines() + .enumerate() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty()).then_some((index + 1, trimmed)) + }) + .collect::>(); + + if non_empty_lines.is_empty() { + return Ok(None); + } + + match input_format { + InputFormat::Auto => { + let mut paths = Vec::with_capacity(non_empty_lines.len()); + let mut saw_existing_path = false; + let mut first_missing_path = None; + for (line_number, line) in non_empty_lines { + if path_text_has_wildcards(line) { + saw_existing_path = true; + paths.push(PathBuf::from(line)); + continue; + } + let path = PathBuf::from(line); + if !path.exists() { + if saw_existing_path { + return Err(CliError::usage(format!( + "stdin path line {line_number} does not exist: {line}" + ))); + } + first_missing_path.get_or_insert_with(|| (line_number, line.to_owned())); + continue; + } + saw_existing_path = true; + paths.push(path); + } + if !saw_existing_path { + return Ok(None); + } + if let Some((line_number, line)) = first_missing_path { + return Err(CliError::usage(format!( + "stdin path line {line_number} does not exist: {line}" + ))); + } + expand_input_patterns(&paths, command_name).map(Some) + } + InputFormat::Lines => { + let mut paths = Vec::with_capacity(non_empty_lines.len()); + for (line_number, line) in non_empty_lines { + if path_text_has_wildcards(line) { + paths.push(PathBuf::from(line)); + } else { + let path = PathBuf::from(line); + if !path.exists() { + return Err(CliError::usage(format!( + "stdin path line {line_number} does not exist: {line}" + ))); + } + paths.push(path); + } + } + expand_input_patterns(&paths, command_name).map(Some) + } + InputFormat::Jsonl => { + let mut paths = Vec::with_capacity(non_empty_lines.len()); + for (line_number, line) in non_empty_lines { + let value = serde_json::from_str::(line).map_err(|error| { + CliError::usage(format!( + "stdin JSONL path line {line_number} is not valid JSON: {error}" + )) + })?; + let path = match value { + Value::String(path) => PathBuf::from(path), + Value::Object(object) => object + .get("path") + .and_then(Value::as_str) + .map(PathBuf::from) + .ok_or_else(|| { + CliError::usage(format!( + "stdin JSONL path line {line_number} must be a string or contain a string 'path' field" + )) + })?, + _ => { + return Err(CliError::usage(format!( + "stdin JSONL path line {line_number} must be a string or object" + ))); + } + }; + if !path_text_has_wildcards(&path.to_string_lossy()) && !path.exists() { + return Err(CliError::usage(format!( + "stdin JSONL path line {line_number} does not exist: {}", + path.display() + ))); + } + paths.push(path); + } + expand_input_patterns(&paths, command_name).map(Some) + } + } +} + +/// Resolves stdin path streams for commands whose stdin is path-only input. +/// +/// This keeps [`read_existing_stdin_paths`] conservative for tools that also +/// accept structured content on stdin, while preserving JSONL path records for +/// path/root-only commands in auto mode. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when a recognized path record does not resolve +/// or wildcard expansion fails. +pub fn read_existing_stdin_path_records( + buffer: &str, + input_format: InputFormat, + command_name: &str, +) -> Result>, CliError> { + let paths = read_existing_stdin_paths(buffer, input_format, command_name)?; + if paths.is_some() || input_format != InputFormat::Auto { + return Ok(paths); + } + + let mut json_paths = Vec::new(); + let mut saw_json_path = false; + for (line_number, line) in buffer.lines().enumerate().filter_map(|(index, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty()).then_some((index + 1, trimmed)) + }) { + let Ok(value) = serde_json::from_str::(line) else { + return Ok(None); + }; + let Some(path) = json_path_record_to_path(value) else { + return Ok(None); + }; + if !path_text_has_wildcards(&path.to_string_lossy()) && !path.exists() { + return Err(CliError::usage(format!( + "stdin JSONL path line {line_number} does not exist: {}", + path.display() + ))); + } + saw_json_path = true; + json_paths.push(path); + } + + if saw_json_path { + expand_input_patterns(&json_paths, command_name).map(Some) + } else { + Ok(None) + } +} + +/// Expands wildcard patterns in a path list while preserving literal paths. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when a wildcard pattern matches no paths or the +/// wildcard expansion encounters unreadable directories. +pub fn expand_input_patterns( + paths: &[PathBuf], + command_name: &str, +) -> Result, CliError> { + let mut expanded = Vec::new(); + for path in paths { + if path_has_wildcards(path) && !path.exists() { + let mut matches = expand_wildcard_path(path, command_name)?; + expanded.append(&mut matches); + } else { + expanded.push(path.clone()); + } + } + + expanded.sort(); + expanded.dedup(); + Ok(expanded) +} + +/// Expands wildcard patterns while keeping exact positional path order. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when a wildcard pattern matches no paths or the +/// wildcard expansion encounters unreadable directories. +pub fn expand_input_patterns_preserve_order( + paths: &[PathBuf], + command_name: &str, +) -> Result, CliError> { + let mut expanded = Vec::new(); + for path in paths { + if path_has_wildcards(path) && !path.exists() { + expanded.extend(expand_wildcard_path(path, command_name)?); + } else if !expanded.contains(path) { + expanded.push(path.clone()); + } + } + Ok(expanded) +} + +/// Ensures a path list resolves to exactly one item. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the path list is empty or expands to more +/// than one item. +pub fn require_exactly_one_input_path( + paths: &[PathBuf], + context: &str, +) -> Result { + match paths { + [path] => Ok(path.clone()), + [] => Err(CliError::usage(format!( + "{context} expects exactly one input path" + ))), + _ => Err(CliError::usage(format!( + "{context} expects exactly one input path, got {}", + paths.len() + ))), + } +} + +#[cfg(unix)] +#[test] +fn preserves_literal_wildcard_filenames() { + let temp = tempfile::tempdir().expect("tempdir"); + let literal = temp.path().join("data?.bin"); + fs::write(&literal, "x").expect("literal file"); + + let expanded = expand_input_patterns(std::slice::from_ref(&literal), "demo").expect("expanded"); + assert_eq!(expanded, vec![literal]); +} + +fn path_text_has_wildcards(text: &str) -> bool { + text.chars().any(|character| matches!(character, '*' | '?')) +} + +fn json_path_record_to_path(value: Value) -> Option { + match value { + Value::String(path) => Some(PathBuf::from(path)), + Value::Object(object) => object + .get("path") + .and_then(Value::as_str) + .map(PathBuf::from), + _ => None, + } +} + +fn path_has_wildcards(path: &Path) -> bool { + path.components().any(|component| matches!(component, Component::Normal(_) if component_has_wildcards(&component))) +} + +fn component_has_wildcards(component: &Component<'_>) -> bool { + if !matches!(component, Component::Normal(_)) { + return false; + } + let text = component.as_os_str().to_string_lossy(); + text == "**" || text.chars().any(|character| matches!(character, '*' | '?')) +} + +fn expand_wildcard_path(pattern: &Path, command_name: &str) -> Result, CliError> { + let mut candidates = vec![PathBuf::new()]; + let mut saw_wildcard = false; + + for component in pattern.components() { + if matches!(component, Component::CurDir) { + continue; + } + + if is_recursive_wildcard(&component) { + saw_wildcard = true; + candidates = expand_recursive_wildcard(candidates)?; + continue; + } + + if component_has_wildcards(&component) { + saw_wildcard = true; + candidates = expand_component_wildcard(candidates, &component, pattern, command_name)?; + continue; + } + + for candidate in &mut candidates { + candidate.push(component.as_os_str()); + } + } + + if saw_wildcard && candidates.is_empty() { + return Err(glob_no_match_error(pattern, command_name)); + } + + candidates.sort(); + candidates.dedup(); + if saw_wildcard && candidates.is_empty() { + return Err(glob_no_match_error(pattern, command_name)); + } + Ok(candidates) +} + +fn is_recursive_wildcard(component: &Component<'_>) -> bool { + matches!(component, Component::Normal(value) if value.to_string_lossy() == "**") +} + +fn expand_component_wildcard( + candidates: Vec, + component: &Component<'_>, + pattern: &Path, + command_name: &str, +) -> Result, CliError> { + let component_pattern = component.as_os_str().to_string_lossy().into_owned(); + let mut next = Vec::new(); + + for candidate in candidates { + let directory = if candidate.as_os_str().is_empty() { + Path::new(".") + } else { + candidate.as_path() + }; + if !directory.is_dir() { + continue; + } + let entries = fs::read_dir(directory).map_err(|error| { + CliError::usage(format!( + "failed to expand glob pattern '{}': cannot read {}: {error}", + pattern.display(), + directory.display() + )) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + CliError::usage(format!( + "failed to expand glob pattern '{}': {error}", + pattern.display() + )) + })?; + let file_name = entry.file_name(); + if wildcard_matches(&component_pattern, &file_name.to_string_lossy()) { + next.push(candidate.join(file_name)); + } + } + } + + if next.is_empty() { + return Err(glob_no_match_error(pattern, command_name)); + } + + Ok(next) +} + +fn expand_recursive_wildcard(candidates: Vec) -> Result, CliError> { + let mut expanded = candidates.clone(); + for candidate in candidates { + let root = if candidate.as_os_str().is_empty() { + Path::new(".") + } else { + candidate.as_path() + }; + let walker = WalkDir::new(root).min_depth(1); + for entry in walker { + let entry = entry.map_err(|error| { + CliError::usage(format!( + "failed to expand glob pattern with recursive wildcard under {}: {error}", + root.display() + )) + })?; + expanded.push(entry.path().to_path_buf()); + } + } + Ok(expanded) +} + +fn glob_no_match_error(pattern: &Path, command_name: &str) -> CliError { + CliError::usage(format!( + "glob pattern '{}' matched no paths. PowerShell passes native-command globs literally; try: fd -t f . | {command_name} --input-format lines", + pattern.display() + )) +} + +fn wildcard_matches(pattern: &str, candidate: &str) -> bool { + if cfg!(windows) { + wildcard_matches_folded( + &pattern + .chars() + .map(|ch| ch.to_ascii_lowercase()) + .collect::(), + &candidate + .chars() + .map(|ch| ch.to_ascii_lowercase()) + .collect::(), + ) + } else { + wildcard_matches_folded(pattern, candidate) + } +} + +fn wildcard_matches_folded(pattern: &str, candidate: &str) -> bool { + fn helper(pattern: &[char], candidate: &[char]) -> bool { + if pattern.is_empty() { + return candidate.is_empty(); + } + match pattern[0] { + '*' => { + helper(&pattern[1..], candidate) + || (!candidate.is_empty() && helper(pattern, &candidate[1..])) + } + '?' => !candidate.is_empty() && helper(&pattern[1..], &candidate[1..]), + literal => { + !candidate.is_empty() + && literal == candidate[0] + && helper(&pattern[1..], &candidate[1..]) + } + } + } + + let pattern_chars = pattern.chars().collect::>(); + let candidate_chars = candidate.chars().collect::>(); + helper(&pattern_chars, &candidate_chars) +} + +/// Parses the shared `--input-format` argument. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the value is not one of the supported variants. +pub fn parse_input_format(value: &str) -> Result { + match value { + "auto" => Ok(InputFormat::Auto), + "lines" => Ok(InputFormat::Lines), + "jsonl" => Ok(InputFormat::Jsonl), + other => Err(CliError::usage(format!( + "invalid --input-format value '{other}'; expected auto, lines, or jsonl" + ))), + } +} + +/// Parses the shared `--color` argument. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the value is not one of the supported variants. +pub fn parse_color_choice(value: &str) -> Result { + match value { + "auto" => Ok(ColorChoice::Auto), + "never" => Ok(ColorChoice::Never), + other => Err(CliError::usage(format!( + "invalid --color value '{other}'; expected auto or never" + ))), + } +} + +/// Parses the shared `--format` argument. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when `value` is not a supported output format. +pub fn parse_format_choice(value: &str) -> Result { + parse_render_mode_override(Some(value)).ok_or_else(|| { + CliError::usage(format!( + "invalid --format value '{value}'; expected text, json, or toon" + )) + }) +} + +/// Renders a value as a single-line JSON document plus a trailing newline. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when the value cannot be serialized to JSON. +pub fn emit_json(value: &T) -> Result +where + T: Serialize, +{ + serde_json::to_string(value) + .map(|mut output| { + output.push('\n'); + output + }) + .map_err(|error| CliError::runtime(format!("failed to render json: {error}"))) +} + +/// Renders a structured value in a shared machine-friendly format. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when serialization or format rendering fails. +pub fn emit_structured(value: &T, render_mode: RenderMode) -> Result +where + T: Serialize, +{ + match render_mode { + RenderMode::Json => emit_json(value), + RenderMode::Toon => { + let json_value = serde_json::to_value(value).map_err(|error| { + CliError::runtime(format!("failed to serialize value: {error}")) + })?; + let mut output = formats::toon::encode_value_default(&json_value)?; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + Ok(output) + } + RenderMode::Text => Err(CliError::runtime( + "structured text rendering requires command-specific text output", + )), + } +} + +/// Writes a single line of text to stdout. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when stdout cannot be written. +pub fn print_text(text: impl Display) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + ignore_broken_pipe(writeln!(stdout, "{text}")) +} + +/// Writes text to stdout without appending a newline. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when stdout cannot be written. +pub fn write_stdout(text: &str) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + ignore_broken_pipe(stdout.write_all(text.as_bytes())) +} + +/// Writes a JSON value to stdout. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when serialization or stdout writes fail. +pub fn print_json(value: &T) -> Result<(), CliError> +where + T: Serialize, +{ + let rendered = emit_json(value)?; + write_stdout(&rendered) +} + +/// Writes a structured value to stdout. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when serialization, format rendering, or stdout writes fail. +pub fn print_structured(value: &T, render_mode: RenderMode) -> Result<(), CliError> +where + T: Serialize, +{ + let rendered = emit_structured(value, render_mode)?; + write_stdout(&rendered) +} + +/// Writes a formatted error message to stderr. +pub fn print_error(error: &CliError) { + let _ = writeln!(io::stderr().lock(), "{error}"); +} + +/// Writes a compact first-page help card for usage failures. +pub fn print_quick_help_error(error: &CliError, help: &str) { + let use_color = stderr_supports_color(); + let mut stderr = io::stderr().lock(); + if use_color { + let _ = writeln!(stderr, "\x1b[31;1merror:\x1b[0m {error}"); + } else { + let _ = writeln!(stderr, "error: {error}"); + } + let _ = writeln!(stderr); + let _ = write_quick_help(&mut stderr, help, use_color); +} + +fn stderr_supports_color() -> bool { + io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +fn write_quick_help(mut writer: impl Write, help: &str, use_color: bool) -> io::Result<()> { + let summary = first_help_line(help).unwrap_or("Mercury Toolbox command"); + let command = quick_help_command(help).unwrap_or("mercury"); + let title = format!("{command} - Mercury Toolbox"); + + write_quick_heading(&mut writer, &title, use_color)?; + writeln!(writer, " {summary}")?; + writeln!(writer)?; + + write_named_section( + &mut writer, + help, + "Usage:", + &[ + "Commands:", + "Subcommands:", + "Options:", + "Shared Options:", + "Examples:", + ], + "Usage:", + 5, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Commands:", + &["Options:", "Shared Options:", "Examples:"], + "Commands:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Subcommands:", + &["Options:", "Shared Options:", "Examples:"], + "Commands:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Options:", + &["Commands:", "Subcommands:", "Examples:"], + "Common options:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Shared Options:", + &[ + "Commands:", + "Subcommands:", + "Find Options:", + "Body Options:", + "Examples:", + ], + "Common options:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Examples:", + &[], + "Examples:", + 3, + use_color, + )?; + + if use_color { + writeln!( + writer, + "Type \x1b[1m{command} --help\x1b[0m for the full command reference." + ) + } else { + writeln!( + writer, + "Type '{command} --help' for the full command reference." + ) + } +} + +fn first_help_line(help: &str) -> Option<&str> { + help.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn quick_help_command(help: &str) -> Option<&str> { + help_section( + help, + "Usage:", + &[ + "Commands:", + "Subcommands:", + "Options:", + "Shared Options:", + "Examples:", + ], + ) + .and_then(|lines| lines.into_iter().find_map(first_usage_token)) +} + +fn first_usage_token(line: &str) -> Option<&str> { + line.split_whitespace() + .next() + .filter(|token| token.chars().any(char::is_alphanumeric)) +} + +fn write_quick_heading(writer: &mut impl Write, heading: &str, use_color: bool) -> io::Result<()> { + if use_color { + writeln!(writer, "\x1b[1;36m{heading}\x1b[0m") + } else { + writeln!(writer, "{heading}") + } +} + +fn write_named_section( + writer: &mut impl Write, + help: &str, + source_heading: &str, + stop_headings: &[&str], + display_heading: &str, + limit: usize, + use_color: bool, +) -> io::Result<()> { + if let Some(lines) = help_section(help, source_heading, stop_headings) { + write_quick_heading(writer, display_heading, use_color)?; + write_limited_section(writer, lines, limit)?; + writeln!(writer)?; + } + Ok(()) +} + +fn help_section<'a>(help: &'a str, heading: &str, stop_headings: &[&str]) -> Option> { + let mut lines = help.lines(); + for line in lines.by_ref() { + if line.trim() == heading { + let mut section = Vec::new(); + for candidate in lines { + let trimmed = candidate.trim(); + if stop_headings.contains(&trimmed) || is_top_level_help_heading(candidate, trimmed) + { + break; + } + if !trimmed.is_empty() { + section.push(candidate); + } + } + return Some(section); + } + } + None +} + +fn is_top_level_help_heading(raw: &str, trimmed: &str) -> bool { + !trimmed.is_empty() && raw == trimmed && trimmed.ends_with(':') +} + +fn write_limited_section( + writer: &mut impl Write, + lines: Vec<&str>, + limit: usize, +) -> io::Result<()> { + for line in lines.into_iter().take(limit) { + writeln!(writer, "{line}")?; + } + Ok(()) +} + +/// Heuristically identifies paths that likely belong to tests. +#[must_use] +pub fn is_probable_test_path(path: &str) -> bool { + let normalized = path.replace('\\', "/"); + let lowered = normalized.to_ascii_lowercase(); + if lowered + .split('/') + .any(|segment| matches!(segment, "test" | "tests" | "__tests__" | "spec" | "specs")) + { + return true; + } + + let file_name = lowered.rsplit('/').next().unwrap_or(lowered.as_str()); + if file_name.contains(".test.") || file_name.contains(".spec.") { + return true; + } + + let stem = Path::new(file_name) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + matches!(stem, "test" | "tests" | "spec" | "specs") + || stem.ends_with("_test") + || stem.ends_with("_tests") + || stem.ends_with("_spec") + || stem.ends_with("_specs") +} + +fn ignore_broken_pipe(result: io::Result<()>) -> Result<(), CliError> { + match result { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()), + Err(error) => Err(CliError::runtime(format!( + "failed to write stdout: {error}" + ))), + } +} + +/// Collects matching files from one or more roots. +/// +/// Directory roots prefer `git ls-files -co --exclude-standard` so `.gitignore` +/// behavior stays aligned with Git when available. Non-repository paths fall back +/// to a lightweight recursive walk that skips nested `.git` directories. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when a root does not exist, a Git-backed root +/// cannot be canonicalized, or the fallback walk fails. +pub fn collect_matching_files( + roots: &[PathBuf], + matches: &dyn Fn(&Path) -> bool, +) -> Result, CliError> { + let mut files = Vec::new(); + for root in roots { + let metadata = fs::symlink_metadata(root).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + CliError::runtime(format!("path does not exist: {}", root.display())) + } else { + CliError::runtime(format!("failed to inspect {}: {error}", root.display())) + } + })?; + let file_type = metadata.file_type(); + if file_type.is_file() { + if matches(root) { + files.push(root.clone()); + } + continue; + } + if !file_type.is_dir() { + return Err(CliError::runtime(format!( + "path is not a regular file or directory: {}", + root.display() + ))); + } + + if let Some(mut git_files) = git_list_matching_files(root, matches)? { + files.append(&mut git_files); + continue; + } + + collect_matching_files_from_walk(root, matches, &mut files)?; + } + + files.sort_unstable(); + files.dedup(); + Ok(files) +} + +fn git_list_matching_files( + root: &Path, + matches: &dyn Fn(&Path) -> bool, +) -> Result>, CliError> { + if !root.join(".git").exists() { + return Ok(None); + } + + let canonical_root = fs::canonicalize(root).map_err(|error| { + CliError::runtime(format!("failed to resolve {}: {error}", root.display())) + })?; + let Some(repo_root) = git_repo_root(&canonical_root) else { + return Ok(None); + }; + let canonical_repo = fs::canonicalize(&repo_root).map_err(|error| { + CliError::runtime(format!( + "failed to resolve {}: {error}", + repo_root.display() + )) + })?; + let Ok(relative_root) = canonical_root.strip_prefix(&canonical_repo) else { + return Ok(None); + }; + if !relative_root.as_os_str().is_empty() { + return Ok(None); + } + + let mut command = Command::new("git"); + command.arg("-C").arg(&canonical_repo).args([ + "ls-files", + "-co", + "--exclude-standard", + "--full-name", + ]); + let output = match command + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + { + Ok(output) if output.status.success() => output, + Ok(_) | Err(_) => return Ok(None), + }; + + let stdout = String::from_utf8(output.stdout).map_err(|error| { + CliError::runtime(format!( + "git returned non-UTF-8 output for {}: {error}", + root.display() + )) + })?; + let mut files = Vec::new(); + for relative_path in stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + { + let path = canonical_repo.join(relative_path); + if !is_regular_file_within_root(&path, &canonical_repo) { + continue; + } + let output_path = normalize_path_for_output(path); + if matches(&output_path) { + files.push(output_path); + } + } + Ok(Some(files)) +} + +fn git_repo_root(root: &Path) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "--show-toplevel"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8(output.stdout).ok()?; + let trimmed = path.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } +} + +#[cfg(windows)] +fn normalize_path_for_output(path: PathBuf) -> PathBuf { + let rendered = path.to_string_lossy(); + if let Some(stripped) = rendered.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{stripped}")); + } + if let Some(stripped) = rendered.strip_prefix(r"\\?\") { + return PathBuf::from(stripped); + } + path +} + +#[cfg(not(windows))] +fn normalize_path_for_output(path: PathBuf) -> PathBuf { + path +} + +fn collect_matching_files_from_walk( + root: &Path, + matches: &dyn Fn(&Path) -> bool, + files: &mut Vec, +) -> Result<(), CliError> { + let canonical_root = fs::canonicalize(root).map_err(|error| { + CliError::runtime(format!("failed to resolve {}: {error}", root.display())) + })?; + let walker = WalkDir::new(root) + .into_iter() + .filter_entry(|entry| !(entry.file_type().is_dir() && entry.file_name() == ".git")); + for entry in walker { + let entry = entry.map_err(|error| { + CliError::runtime(format!("failed to walk {}: {error}", root.display())) + })?; + let path = entry.path(); + if entry.file_type().is_file() + && is_regular_file_within_root(path, &canonical_root) + && matches(path) + { + files.push(path.to_path_buf()); + } + } + Ok(()) +} + +fn is_regular_file_within_root(path: &Path, canonical_root: &Path) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + if !metadata.file_type().is_file() { + return false; + } + fs::canonicalize(path).is_ok_and(|canonical_path| canonical_path.starts_with(canonical_root)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + #[test] + fn cli_error_helpers_map_categories() { + let usage = CliError::usage("bad input"); + let runtime = CliError::runtime("disk gone"); + + assert_eq!(usage.exit_code(), ExitCode::UsageError); + assert_eq!(runtime.exit_code(), ExitCode::RuntimeError); + } + + #[test] + fn print_helpers_execute_without_error() { + assert!(print_text("jade").is_ok()); + assert!(write_stdout("jade").is_ok()); + assert!(print_json(&json!({ "ok": true })).is_ok()); + print_error(&CliError::runtime("diagnostic")); + print_quick_help_error( + &CliError::usage("missing input"), + "Example command.\n\nUsage:\n example [OPTIONS] \n\nOptions:\n --json Emit JSON\n\nExamples:\n example README.md\n", + ); + } + + #[test] + fn broken_pipe_writes_are_treated_as_success() { + assert!( + ignore_broken_pipe(Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))).is_ok() + ); + } + + #[test] + fn common_args_and_exit_codes_cover_text_mode_contract() { + let args = CommonArgs { + json: false, + format: None, + input_format: InputFormat::Lines, + color: ColorChoice::Never, + quiet: true, + }; + + assert_eq!(args.render_mode(), RenderMode::Text); + let _ = args.stdin_is_terminal(); + assert_eq!(ExitCode::Success.as_i32(), 0); + assert_eq!(ExitCode::NoResults.as_i32(), 1); + assert_eq!(ExitCode::UsageError.as_i32(), 2); + assert_eq!(ExitCode::RuntimeError.as_i32(), 3); + } + + #[test] + fn render_mode_override_accepts_json_and_text() { + assert_eq!( + parse_render_mode_override(Some("json")), + Some(RenderMode::Json) + ); + assert_eq!( + parse_render_mode_override(Some("toon")), + Some(RenderMode::Toon) + ); + assert_eq!( + parse_render_mode_override(Some("TEXT")), + Some(RenderMode::Text) + ); + assert_eq!(parse_render_mode_override(Some(" yaml ")), None); + assert_eq!(parse_render_mode_override(Some(" ")), None); + assert_eq!(parse_render_mode_override(None), None); + } + + #[test] + fn probable_test_path_detection_covers_common_layouts() { + assert!(is_probable_test_path(r"src\tests\mod.rs")); + assert!(is_probable_test_path("pkg/__tests__/widget.ts")); + assert!(is_probable_test_path("component.spec.tsx")); + assert!(is_probable_test_path("helper_test.rs")); + assert!(!is_probable_test_path("src/lib.rs")); + assert!(!is_probable_test_path("plugins/specimen.rs")); + } + + #[test] + fn collect_matching_files_walks_non_repo_roots_and_skips_git_dirs() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + fs::create_dir_all(root.join(".git")).expect("git dir"); + fs::create_dir_all(root.join("src")).expect("src dir"); + fs::write(root.join(".git").join("ignored.rs"), "fn hidden() {}\n").expect("ignored file"); + fs::write(root.join("src").join("keep.rs"), "fn keep() {}\n").expect("source file"); + fs::write(root.join("notes.txt"), "skip\n").expect("notes file"); + + let files = collect_matching_files(&[root.to_path_buf()], &|path| { + path.extension().and_then(|ext| ext.to_str()) == Some("rs") + }) + .expect("walk files"); + assert_eq!(files, vec![root.join("src").join("keep.rs")]); + } + + #[test] + fn collect_matching_files_walk_rejects_symlinked_files() { + let temp = tempdir().expect("tempdir"); + let root = temp.path().join("root"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&root).expect("root dir"); + fs::create_dir_all(&outside).expect("outside dir"); + fs::write(outside.join("secret.rs"), "fn secret() {}\n").expect("outside file"); + let link = root.join("linked.rs"); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(outside.join("secret.rs"), &link).expect("symlink"); + } + #[cfg(windows)] + { + if std::os::windows::fs::symlink_file(outside.join("secret.rs"), &link).is_err() { + return; + } + } + + let files = collect_matching_files(&[root], &|path| { + path.extension().and_then(|ext| ext.to_str()) == Some("rs") + }) + .expect("walk files"); + + assert!(files.is_empty()); + } + + #[test] + fn collect_matching_files_prefers_git_ls_files_when_available() { + if Command::new("git") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + return; + } + + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + assert!( + Command::new("git") + .arg("init") + .arg(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("git init") + .success() + ); + fs::write(root.join(".gitignore"), "ignored.rs\n").expect("gitignore"); + fs::write(root.join("tracked.rs"), "fn tracked() {}\n").expect("tracked file"); + fs::write(root.join("ignored.rs"), "fn ignored() {}\n").expect("ignored file"); + fs::write(root.join("untracked.rs"), "fn untracked() {}\n").expect("untracked file"); + assert!( + Command::new("git") + .arg("-C") + .arg(root) + .args(["add", ".gitignore", "tracked.rs"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("git add") + .success() + ); + + let files = collect_matching_files(&[root.to_path_buf()], &|path| { + path.extension().and_then(|ext| ext.to_str()) == Some("rs") + }) + .expect("git-aware files"); + assert_eq!( + files, + vec![root.join("tracked.rs"), root.join("untracked.rs")] + ); + } + + #[test] + fn read_existing_stdin_paths_covers_auto_lines_and_jsonl_modes() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("first.json"); + let second = temp.path().join("second.json"); + let nested = temp.path().join("nested"); + let nested_file = nested.join("nested.json"); + fs::write(&first, "{}\n").expect("first"); + fs::write(&second, "{}\n").expect("second"); + fs::create_dir_all(&nested).expect("nested"); + fs::write(&nested_file, "{}\n").expect("nested file"); + + let auto = read_existing_stdin_paths( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Auto, + "demo", + ) + .expect("auto") + .expect("paths"); + assert_eq!(auto, vec![first.clone(), second.clone()]); + + let lines = read_existing_stdin_paths( + &format!("{}\n", first.display()), + InputFormat::Lines, + "demo", + ) + .expect("lines") + .expect("paths"); + assert_eq!(lines, vec![first.clone()]); + + let jsonl = read_existing_stdin_paths( + &format!( + "{{\"path\":\"{}\"}}\n\"{}\"\n", + first.display().to_string().replace('\\', "\\\\"), + second.display().to_string().replace('\\', "\\\\") + ), + InputFormat::Jsonl, + "demo", + ) + .expect("jsonl") + .expect("paths"); + assert_eq!(jsonl, vec![first.clone(), second.clone()]); + + assert_eq!( + read_existing_stdin_paths("not-a-real-path\n", InputFormat::Auto, "demo") + .expect("fallback"), + None + ); + assert!(matches!( + read_existing_stdin_paths( + &format!("{}\nmissing.json\n", first.display()), + InputFormat::Auto, + "demo" + ), + Err(CliError::Usage(message)) + if message.contains("stdin path line 2 does not exist") + )); + assert!(matches!( + read_existing_stdin_paths("not-a-real-path\n", InputFormat::Lines, "demo"), + Err(CliError::Usage(message)) + if message.contains("stdin path line 1 does not exist") + )); + assert!(matches!( + read_existing_stdin_paths("{\"root\":\"missing\"}\n", InputFormat::Jsonl, "demo"), + Err(CliError::Usage(message)) + if message.contains("must be a string or contain a string 'path' field") + )); + + let globbed = read_existing_stdin_paths( + &format!("{}\n", temp.path().join("**").join("*.json").display()), + InputFormat::Lines, + "demo", + ) + .expect("globbed") + .expect("paths"); + assert!(globbed.contains(&first)); + assert!(globbed.contains(&second)); + assert!(globbed.contains(&nested_file)); + } + + #[test] + fn read_existing_stdin_path_records_accepts_auto_jsonl_paths() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("first.json"); + let second = temp.path().join("second.json"); + fs::write(&first, "{}\n").expect("first"); + fs::write(&second, "{}\n").expect("second"); + + let paths = read_existing_stdin_path_records( + &format!( + "{{\"path\":{}}}\n{}\n", + serde_json::to_string(&first.display().to_string()).expect("first path"), + serde_json::to_string(&second.display().to_string()).expect("second path"), + ), + InputFormat::Auto, + "demo", + ) + .expect("auto jsonl paths") + .expect("paths"); + assert_eq!(paths, vec![first, second]); + + assert_eq!( + read_existing_stdin_paths( + "{\"path\":\"not-content-path.json\",\"kind\":\"record\"}\n", + InputFormat::Auto, + "demo", + ) + .expect("content auto fallback"), + None + ); + assert_eq!( + read_existing_stdin_path_records("{\"kind\":\"record\"}\n", InputFormat::Auto, "demo",) + .expect("non-path json fallback"), + None + ); + } + + #[test] + fn expands_input_patterns_and_requires_exactly_one_path() { + let temp = tempdir().expect("tempdir"); + let one = temp.path().join("one.txt"); + let two = temp.path().join("two.txt"); + let nested = temp.path().join("nested"); + let nested_file = nested.join("three.txt"); + fs::write(&one, "1").expect("one"); + fs::write(&two, "2").expect("two"); + fs::create_dir_all(&nested).expect("nested"); + fs::write(&nested_file, "3").expect("nested file"); + + let expanded = expand_input_patterns( + &[ + temp.path().join("*.txt"), + temp.path().join("nested").join("**").join("*.txt"), + ], + "demo", + ) + .expect("expanded"); + assert_eq!(expanded, vec![nested_file, one.clone(), two.clone()]); + + let ordered = expand_input_patterns_preserve_order(&[two.clone(), one.clone()], "demo") + .expect("ordered"); + assert_eq!(ordered, vec![two, one.clone()]); + + let single = + require_exactly_one_input_path(std::slice::from_ref(&one), "demo").expect("single"); + assert_eq!(single, one); + + assert!(matches!( + require_exactly_one_input_path(&[], "demo"), + Err(CliError::Usage(message)) if message.contains("expects exactly one input path") + )); + assert!(matches!( + expand_input_patterns(&[temp.path().join("*.missing")], "demo"), + Err(CliError::Usage(message)) if message.contains("glob pattern") + )); + } +} diff --git a/crates/common/tests/ai_prompt.rs b/crates/common/tests/ai_prompt.rs new file mode 100644 index 0000000..7ef2a13 --- /dev/null +++ b/crates/common/tests/ai_prompt.rs @@ -0,0 +1,276 @@ +//! Tests for the generated Mercury Toolbox AI assets. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn workspace_path(root: &Path, relative_path: &[&str]) -> PathBuf { + relative_path + .iter() + .fold(root.to_path_buf(), |path, component| path.join(component)) +} + +fn read_workspace_text(root: &Path, relative_path: &[&str], label: &str) -> String { + fs::read_to_string(workspace_path(root, relative_path)) + .unwrap_or_else(|error| panic!("failed to read {label}: {error}")) +} + +fn toolbox_commands(root: &Path) -> Vec { + let commands = read_workspace_text( + root, + &["scripts", "toolbox-commands.ps1"], + "scripts/toolbox-commands.ps1", + ); + let mut in_command_list = false; + let mut consumed_command_list = false; + let mut names = commands + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed == "return @(" && !in_command_list && !consumed_command_list { + in_command_list = true; + return None; + } + if in_command_list && trimmed == ")" { + in_command_list = false; + consumed_command_list = true; + return None; + } + if !in_command_list { + return None; + } + trimmed + .strip_prefix('\'') + .and_then(|rest| rest.split_once('\'')) + .map(|(name, _)| name.to_string()) + }) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +fn assert_lf_only(root: &Path, relative_path: &[&str]) { + let path = workspace_path(root, relative_path); + let bytes = fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + assert!( + !bytes.windows(2).any(|window| window == b"\r\n"), + "{} should use LF line endings", + path.display() + ); +} + +#[test] +fn ai_prompt_assets_exist_and_cover_every_tool() { + let root = workspace_root(); + let prompt = read_workspace_text( + &root, + &["docs", "ai", "mercury-toolbox-ai-prompt.md"], + "generated AI prompt", + ); + let notes = read_workspace_text( + &root, + &["docs", "ai", "toolbox-ai-prompt-notes.json"], + "AI prompt notes", + ); + let commands = toolbox_commands(&root); + + assert!(prompt.contains("Mercury Toolbox")); + assert!(prompt.contains("PowerShell")); + assert!(prompt.contains("`--json`")); + assert!(prompt.contains("`--toon`")); + assert!(prompt.contains("Available tools:")); + assert!(prompt.contains("Rules:")); + assert!(prompt.contains("Pipe external JSON into `toon`")); + assert!(prompt.contains("Every tool has guided triage metadata")); + assert!(prompt.contains("Guided: answer=")); + assert!(prompt.contains("`report_quality`")); + assert!(prompt.contains("`next_actions`")); + assert!(!prompt.contains("toon --from json --to toon")); + assert!(prompt.contains("Usage: `")); + assert!(prompt.contains("Example:")); + assert!(prompt.contains("msudo:")); + assert!(prompt.contains("Top-level high-risk command")); + assert!(prompt.contains( + "msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated" + )); + assert!(!prompt.contains("## Tool Catalog")); + assert!(!prompt.contains("### `")); + assert!(!prompt.contains("$Fence")); + assert!( + prompt.lines().count() <= 120, + "prompt should stay compact for AI consumption" + ); + + assert_eq!(commands.len(), 59, "toolbox command inventory changed"); + let notes_json = serde_json::from_str::(¬es).expect("AI prompt notes JSON"); + let tools = notes_json["tools"].as_object().expect("notes tools object"); + + for command in commands { + assert!( + prompt.contains(&format!("{command}:")), + "prompt should contain {command}" + ); + assert!( + notes.contains(&format!("\"{command}\"")), + "notes should contain {command}" + ); + let guided = tools + .get(&command) + .and_then(|tool| tool.get("guided_triage")) + .unwrap_or_else(|| panic!("notes should contain guided_triage for {command}")); + assert!( + guided["answer"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "guided_triage.answer should be non-empty for {command}" + ); + assert!( + guided["trust"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "guided_triage.trust should be non-empty for {command}" + ); + assert!( + guided["next_actions"] + .as_array() + .is_some_and(|items| items.len() >= 2 + && items + .iter() + .all(|item| item.as_str().is_some_and(|value| !value.is_empty()))), + "guided_triage.next_actions should list at least two actions for {command}" + ); + } + + assert!( + notes.contains("msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"), + "notes should document the stable msudo status discovery fields" + ); +} + +#[test] +fn ai_skill_assets_exist_and_cover_every_tool() { + let root = workspace_root(); + let skill = read_workspace_text( + &root, + &["skills", "mercury-toolbox", "SKILL.md"], + "generated skill", + ); + let catalog = read_workspace_text( + &root, + &[ + "skills", + "mercury-toolbox", + "references", + "command-catalog.md", + ], + "generated skill catalog", + ); + let openai_yaml = read_workspace_text( + &root, + &["skills", "mercury-toolbox", "agents", "openai.yaml"], + "generated openai.yaml", + ); + + assert!(skill.contains("Mercury Toolbox")); + assert!(skill.contains("Prefer Mercury readers over `Get-Content`")); + assert!(skill.contains("## Modern Pairings")); + assert!(skill.contains("## Job Routing")); + assert!(skill.contains("Windows driver: start with `drvshape `")); + assert!(skill.contains("`report_quality` and `next_actions`")); + assert!(skill.contains("Use `rg` over recursive `grep`")); + assert!(skill.contains("Treat `msudo` as the top-level high-risk toolbox command")); + assert!(skill.contains("`msudo status --json`")); + assert!(skill.contains("references/command-catalog.md")); + assert!(skill.contains("switch to `--toon` or `--format toon`")); + assert!(!skill.contains("toon --from json --to toon")); + assert!(skill.lines().count() <= 95, "skill should stay concise"); + + assert!(catalog.contains("# Mercury Toolbox Command Catalog")); + assert!(catalog.contains("Keep output compact")); + assert!(catalog.contains("TOON example:")); + assert!(catalog.contains("Guided answer:")); + assert!(catalog.contains("Trust basis:")); + assert!(catalog.contains("Next actions:")); + assert!(catalog.contains("--toon")); + assert!(!catalog.contains("toon --from json --to toon")); + assert!(catalog.contains("### `msudo`")); + assert!(catalog.contains("Top-level high-risk command")); + assert!(catalog.contains( + "msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated" + )); + assert!( + catalog.lines().count() <= 520, + "catalog should stay compact" + ); + + assert!(openai_yaml.contains("display_name: \"Mercury Toolbox\"")); + assert!(openai_yaml.contains("icon_small: \"./assets/logo.png\"")); + assert!(openai_yaml.contains("icon_large: \"./assets/logo.png\"")); + assert!(openai_yaml.contains("brand_color: \"#35C2FF\"")); + assert!(openai_yaml.contains("default_prompt: \"Use $mercury-toolbox first")); + assert!( + root.join("skills") + .join("mercury-toolbox") + .join("assets") + .join("logo.png") + .is_file(), + "generated skill should include its logo asset" + ); + + for command in toolbox_commands(&root) { + assert!( + catalog.contains(&format!("### `{command}`")), + "catalog should contain {command}" + ); + } +} + +#[test] +fn generated_ai_markdown_assets_use_lf_line_endings() { + let root = workspace_root(); + assert_lf_only(&root, &["docs", "ai", "mercury-toolbox-ai-prompt.md"]); + assert_lf_only(&root, &["skills", "mercury-toolbox", "SKILL.md"]); + assert_lf_only( + &root, + &[ + "skills", + "mercury-toolbox", + "references", + "command-catalog.md", + ], + ); +} + +#[test] +fn readme_tool_map_covers_every_toolbox_command() { + let root = workspace_root(); + let readme = read_workspace_text(&root, &["README.md"], "README.md"); + + assert!(readme.contains("### Tool Map")); + assert!(readme.contains("## Which Tool First")); + assert!(readme.contains("Safe starter commands")); + assert!(readme.contains("Every command has guided triage notes")); + assert!(readme.contains("`report_quality` and `next_actions`")); + assert!(readme.contains("Every command supports `--help`")); + for command in toolbox_commands(&root) { + assert!( + readme.contains(&format!("| `{command}` |")), + "README tool map should contain {command}" + ); + } + + assert!(readme.contains("### `asmflow`")); + assert!(readme.contains("### `unityasset`")); + assert!(readme.contains("### `unityprobe`")); + assert!(readme.contains("### `unitydiag`")); +} diff --git a/crates/common/tests/contract.rs b/crates/common/tests/contract.rs new file mode 100644 index 0000000..ec5f546 --- /dev/null +++ b/crates/common/tests/contract.rs @@ -0,0 +1,150 @@ +//! Contract tests for shared CLI helpers. + +use common::{ + CliError, ColorChoice, CommonArgs, ExitCode, InputFormat, RenderMode, emit_json, + emit_structured, formats, map_result_count, parse_color_choice, parse_format_choice, + parse_input_format, should_read_stdin, +}; +use serde::Serialize; +use serde_json::{Map, Value, json}; + +#[test] +fn parses_shared_choice_values() { + let parsed = CommonArgs { + json: true, + format: None, + input_format: parse_input_format("jsonl").expect("input format"), + color: parse_color_choice("never").expect("color choice"), + quiet: false, + }; + + assert!(parsed.json); + assert_eq!(parsed.input_format, InputFormat::Jsonl); + assert_eq!(parsed.color, ColorChoice::Never); + assert_eq!(parsed.render_mode(), RenderMode::Json); + assert_eq!( + parse_format_choice("toon").expect("format choice"), + RenderMode::Toon + ); + + let input_error = parse_input_format("yaml").expect_err("invalid input format"); + let color_error = parse_color_choice("always").expect_err("invalid color choice"); + let format_error = parse_format_choice("yaml").expect_err("invalid format choice"); + assert!(matches!(input_error, CliError::Usage(_))); + assert!(matches!(color_error, CliError::Usage(_))); + assert!(matches!(format_error, CliError::Usage(_))); +} + +#[test] +fn auto_stdin_reads_only_without_explicit_input_and_when_not_terminal() { + assert!(should_read_stdin(false, false)); + assert!(!should_read_stdin(false, true)); + assert!(!should_read_stdin(true, false)); +} + +#[test] +fn maps_result_count_to_exit_code() { + assert_eq!(map_result_count(1), ExitCode::Success); + assert_eq!(map_result_count(0), ExitCode::NoResults); +} + +#[test] +fn emits_single_json_document() { + #[derive(Serialize)] + struct Demo<'a> { + name: &'a str, + } + + let rendered = emit_json(&Demo { name: "jsonlgrep" }).expect("json output"); + + assert_eq!(rendered, "{\"name\":\"jsonlgrep\"}\n"); +} + +#[test] +fn emits_structured_toon_document() { + #[derive(Serialize)] + struct Demo<'a> { + name: &'a str, + count: u8, + } + + let rendered = emit_structured( + &Demo { + name: "jsonlgrep", + count: 2, + }, + RenderMode::Toon, + ) + .expect("toon output"); + + let lines = rendered.lines().collect::>(); + assert_eq!(lines.len(), 2); + assert!(lines.contains(&"name: jsonlgrep")); + assert!(lines.contains(&"count: 2")); + assert!(rendered.ends_with('\n')); +} + +#[test] +fn structured_toon_render_rejects_values_past_explicit_depth_limit() { + let error = emit_structured(&deeply_nested_value(260), RenderMode::Toon) + .expect_err("deep TOON encode should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("maximum TOON encoding depth") + )); +} + +#[test] +fn exposes_json_family_format_modules() { + let records = vec![json!({"id": "user-1", "active": true})]; + + let ison = formats::ison::encode_records(&records, "record").expect("ison records"); + assert!(ison.contains("object.record|")); + assert!(ison.contains("id:str")); + assert!(ison.contains("active:bool")); + assert_eq!( + formats::ison::decode_record_line(&ison, 1).expect("ison decode"), + records[0] + ); + + let zon = formats::zon::encode_value(&records[0]).expect("zon"); + assert!(zon.contains("id:\"user-1\"")); + assert_eq!( + formats::zon::decode_str(&zon).expect("zon decode"), + records[0] + ); + + let tonl = formats::tonl::encode_documents(&records).expect("tonl"); + assert!(tonl.contains("id = \"user-1\"")); + assert_eq!( + formats::tonl::decode_documents(&tonl).expect("tonl decode"), + records + ); +} + +fn deeply_nested_value(depth: usize) -> Value { + let mut value = json!(1); + for index in 0..depth { + let mut object = Map::new(); + object.insert(format!("k{index}"), value); + value = Value::Object(object); + } + value +} + +#[test] +fn isonl_roundtrips_pipe_inside_quoted_string() { + let records = vec![json!({"message": "left|right", "ok": true})]; + + let ison = formats::ison::encode_records(&records, "record").expect("ison records"); + + assert_eq!( + formats::ison::decode_record_line(ison.trim_end(), 1).expect("ison decode"), + records[0] + ); + assert_eq!( + formats::ison::decode_records(&ison).expect("ison batch decode"), + records + ); +} diff --git a/crates/common/tests/jade_workspace.rs b/crates/common/tests/jade_workspace.rs new file mode 100644 index 0000000..fb1b750 --- /dev/null +++ b/crates/common/tests/jade_workspace.rs @@ -0,0 +1,484 @@ +//! Workspace policy tests for Jade Discipline. + +use std::fs; +use std::path::PathBuf; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn workspace_path(components: &[&str]) -> PathBuf { + components + .iter() + .fold(workspace_root(), |path, component| path.join(component)) +} + +fn script_path(script_name: &str) -> PathBuf { + workspace_path(&["scripts", script_name]) +} + +fn read_workspace_text(components: &[&str], label: &str) -> String { + fs::read_to_string(workspace_path(components)) + .unwrap_or_else(|error| panic!("failed to read {label}: {error}")) +} + +#[test] +fn workspace_cargo_toml_publishes_jade_lints_and_profiles() { + let cargo_toml = read_workspace_text(&["Cargo.toml"], "Cargo.toml"); + + for required_line in [ + "rust-version = \"1.86\"", + "missing_docs = \"deny\"", + "pedantic = { level = \"deny\", priority = -3 }", + "nursery = { level = \"deny\", priority = -2 }", + "[profile.release-fast]", + "lto = \"fat\"", + "[profile.release-size]", + "opt-level = \"z\"", + ] { + assert!( + cargo_toml.contains(required_line), + "Cargo.toml should contain {required_line:?}" + ); + } + + for forbidden_line in [ + "missing_copy_implementations", + "implicit_return", + "missing_const_for_fn", + "module_name_repetitions", + "multiple_crate_versions", + "must_use_candidate", + "needless_pass_by_value", + ] { + assert!( + !cargo_toml.contains(forbidden_line), + "Cargo.toml should not contain {forbidden_line:?}" + ); + } +} + +#[test] +fn cargo_config_and_jade_docs_exist() { + let config = read_workspace_text(&[".cargo", "config.toml"], ".cargo/config.toml"); + assert!(config.contains("git-fetch-with-cli = true")); + assert!(config.contains("rustflags = [\"-Dwarnings\"]")); + assert!(config.contains("frequency = \"always\"")); + assert!(workspace_path(&["justfile"]).is_file(), "missing justfile"); + assert!( + workspace_path(&["bacon.toml"]).is_file(), + "missing bacon.toml" + ); + + let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs"); + let maintainer_notes = + read_workspace_text(&["docs", "maintainer-notes.md"], "maintainer notes"); + assert!(docs.contains("Jade Discipline")); + assert!(docs.contains("cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery")); + assert!(docs.contains("Miri, fuzzing, sanitizer, no-panic, and Loom checks are Jade gates")); + assert!(docs.contains( + "Missing tools, missing harnesses, or platform discomfort are failures by default" + )); + assert!(docs.contains("Global `allow` is reserved for two cases only")); + assert!(docs.contains("smallest code-local scope")); + assert!(docs.contains("Install")); + assert!(docs.contains("just")); + assert!(docs.contains("bacon")); + assert!(maintainer_notes.contains("Jade has no optional safety tier")); + assert!(maintainer_notes.contains("Every JSON-capable Mercury tool")); + assert!(maintainer_notes.contains("is the shared AST/indexing engine")); +} + +#[test] +fn release_packaging_scripts_and_docs_exist() { + for script_name in [ + "package-toolbox.ps1", + "install-package-toolbox.ps1", + "uninstall-package-toolbox.ps1", + "generate-ai-skill.ps1", + "check-ai-skill.ps1", + ] { + assert!( + script_path(script_name).is_file(), + "missing packaging script {script_name}" + ); + } + + let readme = read_workspace_text(&["README.md"], "README.md"); + assert!(readme.contains("just")); + assert!(readme.contains("bacon")); + assert!(readme.contains("## Portable Package")); + assert!(readme.contains(r".\scripts\package-toolbox.ps1")); + assert!(readme.contains("install-package-toolbox.ps1")); + assert!(readme.contains("mercury-toolbox-package.json")); + assert!(readme.contains("SHA256SUMS.txt")); + assert!(readme.contains("generate-ai-skill.ps1")); + assert!(readme.contains(r".\skills\mercury-toolbox\")); + assert!(readme.contains("### `msudo`")); + assert!(readme.contains("HIGH RISK")); + assert!(readme.contains("top-level high-risk toolbox command")); + assert!(readme.contains("msudo status --json")); + assert!(readme.contains("Select-Object ok,host,supports_runas,is_elevated")); + assert!(readme.contains("msudo --help")); + assert!(readme.contains("msudo run --help")); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn powershell_gate_assets_and_docs_exist() { + let root = workspace_root(); + assert!( + script_path("check-powershell.ps1").is_file(), + "missing PowerShell gate script" + ); + assert!( + script_path("cargo-flamegraph-windows.ps1").is_file(), + "missing Windows flamegraph wrapper script" + ); + assert!( + workspace_path(&["PSScriptAnalyzerSettings.psd1"]).is_file(), + "missing PowerShell analyzer settings" + ); + + let check_jade = read_workspace_text(&["scripts", "check-jade.ps1"], "scripts/check-jade.ps1"); + assert!(check_jade.contains("check-powershell.ps1")); + assert!(check_jade.contains("check-ai-skill.ps1")); + assert!(check_jade.contains("check-jade-hardening.ps1")); + assert!( + check_jade.contains("Invoke-TimedNativeWithEnvironment"), + "Jade coverage gate should be able to isolate cargo-llvm-cov environment" + ); + assert!( + check_jade.contains("CARGO_INCREMENTAL") && check_jade.contains("RUSTC_WRAPPER"), + "Jade coverage gate should disable incremental and rustc-wrapper for cargo-llvm-cov" + ); + assert!( + check_jade.contains("CARGO_TARGET_DIR") + && check_jade.contains("mercury-jade-llvm-cov") + && check_jade.contains("cargo llvm-cov clean") + && check_jade + .contains("Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov clean'"), + "Jade coverage gate should use a per-run isolated cargo target dir for clean and nextest" + ); + assert!( + check_jade.contains("MERCURY_JADE_COVERAGE_ROOT") + && check_jade.contains("C:\\tmp") + && check_jade.contains("'mtcov'") + && check_jade.contains(".Substring(0, 8)"), + "Jade coverage target dir should stay short enough for Windows llvm-cov object argv" + ); + assert!( + check_jade.contains( + "'llvm-cov',\n '--jobs',\n '1',\n 'nextest'" + ), + "Jade coverage gate should limit cargo-llvm-cov build jobs before the nextest subcommand" + ); + + assert_justfile_test_recipes(&root); + + let ecosystem = read_workspace_text( + &["scripts", "check-ecosystem.ps1"], + "ecosystem check script", + ); + assert!( + ecosystem.contains("toolbox:all-binaries-report-version") + && ecosystem.contains("Test-ToolboxBinaryVersions"), + "ecosystem gate should prove every toolbox binary reports --version" + ); + assert!( + ecosystem.contains("toolbox:all-binaries-no-args-contract") + && ecosystem.contains("Test-ToolboxNoArgsContracts"), + "ecosystem gate should prove every toolbox binary has bounded no-args behavior" + ); + assert!( + ecosystem.contains("toolbox:all-binaries-invalid-flag-contract") + && ecosystem.contains("Test-ToolboxInvalidFlagContracts"), + "ecosystem gate should prove every toolbox binary has bounded invalid-flag diagnostics" + ); + assert!( + ecosystem.contains("toolbox:all-binaries-structured-output-help") + && ecosystem.contains("Test-ToolboxStructuredOutputHelpContracts"), + "ecosystem gate should prove every toolbox binary exposes structured output help" + ); + assert!( + ecosystem.contains("toolbox:malformed-jsonl-stdin-contract") + && ecosystem.contains("Test-ToolboxMalformedJsonlStdinContracts"), + "ecosystem gate should prove malformed JSONL stdin is bounded for input-format commands" + ); + assert!( + ecosystem.contains("toolbox:valid-jsonl-path-stream-smokes"), + "ecosystem gate should prove representative positive JSONL path-stream behavior" + ); + assert!( + ecosystem.contains("toolbox:functional-toon-smokes"), + "ecosystem gate should prove representative functional TOON output smokes" + ); + + let check_hardening = read_workspace_text( + &["scripts", "check-jade-hardening.ps1"], + "scripts/check-jade-hardening.ps1", + ); + for required_gate in [ + "cargo miri setup", + "NightlyToolchain", + "'fuzz'", + "'run'", + "json_family_decode", + "-Zsanitizer=address", + "check-no-panic.ps1", + "loom_capture", + "Mode 'hardening'", + "ValidateRange(1, 3600)", + "Only = 'All'", + ] { + assert!( + check_hardening.contains(required_gate), + "hardening script should contain {required_gate:?}" + ); + } + assert!(check_hardening.contains("exemption requires a non-empty reason")); + + let jade_install = read_workspace_text( + &["scripts", "install-jade-tooling.ps1"], + "scripts/install-jade-tooling.ps1", + ); + assert!(jade_install.contains("PSScriptAnalyzer")); + assert!(jade_install.contains("cargo-binstall")); + assert!(jade_install.contains("\"just\", \"bacon\"")); + assert!(jade_install.contains("\"component\", \"add\", \"miri\"")); + assert!(jade_install.contains("\"cargo-udeps\", \"cargo-llvm-cov\"")); + assert!(jade_install.contains("\"install\", \"cargo-fuzz\"")); + + let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs"); + assert!(docs.contains("PowerShell Gate")); + assert!(docs.contains("check-powershell.ps1")); + assert!(docs.contains("check-ai-skill.ps1")); + assert!(docs.contains("PSScriptAnalyzer")); + assert!(docs.contains("cargo-flamegraph-windows.ps1")); + + let readme = read_workspace_text(&["README.md"], "README.md"); + assert!(readme.contains("cargo-flamegraph-windows.ps1")); +} + +fn assert_justfile_test_recipes(root: &std::path::Path) { + let justfile = fs::read_to_string(root.join("justfile")).expect("justfile"); + assert!( + justfile.contains("coverage:\n cargo llvm-cov nextest --all-features --summary-only"), + "just coverage should keep a fast local coverage path without forcing Jade's serial coverage gate" + ); + assert!( + !justfile.contains("coverage:\n cargo llvm-cov clean --workspace"), + "just coverage should not pre-clean coverage artifacts on every local iteration" + ); + assert!( + justfile.contains("test:\n cargo nextest run --all-features"), + "just test should keep the fast incremental nextest path for local iteration" + ); + assert!( + justfile.contains("stable-test:") + && justfile.contains( + "CARGO_INCREMENTAL = '0'; cargo nextest run --all-features --run-ignored all", + ), + "stable-test should keep the non-incremental Windows cleanup-race path and slow integration coverage" + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn repo_temp_paths_are_audited_and_use_exclusive_writes() { + let root = workspace_root(); + let production_temp_dir_hits = production_source_hits( + &root, + &[ + "std::env::temp_dir()", + "env::temp_dir()", + "tempfile::", + "NamedTempFile", + "TempDir::new", + "tempdir()", + ], + ); + assert_eq!( + production_temp_dir_hits, + [ + "crates\\argv\\src\\lib.rs:std::env::temp_dir().join(format!(", + "crates\\envdiff\\src\\lib.rs:std::env::temp_dir().join(format!(", + "crates\\msudo\\src\\lib.rs:let temp_dir = std::env::temp_dir();", + "crates\\runtimekit\\src\\lib.rs:std::env::temp_dir().join(format!(\"{prefix}-{unique}.{extension}\"))", + ], + "production temp root use must stay explicitly audited" + ); + + for (path, required) in [ + ( + "crates/runtimekit/src/lib.rs", + &[ + "fn write_shell_wrapper_file", + ".create_new(true)", + "refusing to replace existing shell wrapper", + ][..], + ), + ( + "crates/argv/src/lib.rs", + &[ + "fn write_cmd_wrapper_file", + ".create_new(true)", + "refusing to replace existing cmd inspect wrapper", + ][..], + ), + ( + "crates/envdiff/src/lib.rs", + &[ + "fn write_temp_file_exclusive", + ".create_new(true)", + "fn create_temp_dir_exclusive", + "fs::create_dir(path)", + ][..], + ), + ( + "crates/msudo/src/lib.rs", + &[ + "fn write_relay_exit_status", + ".create_new(true)", + "failed to create relay exit status", + ][..], + ), + ( + "crates/runprobe/src/lib.rs", + &[ + "fn write_log_file_exclusive", + ".create_new(true)", + "refusing to replace existing runprobe log", + ][..], + ), + ( + "crates/windowsupport/src/sudo.rs", + &[ + "fn create_relay_output_file", + ".create_new(true)", + "FILE_FLAG_OPEN_REPARSE_POINT", + ][..], + ), + ] { + let body = fs::read_to_string(root.join(path)).unwrap_or_else(|error| { + panic!("failed to read {path}: {error}"); + }); + for needle in required { + assert!( + body.contains(needle), + "{path} should keep temp/log output guard {needle:?}" + ); + } + } +} + +fn production_source_hits(root: &std::path::Path, needles: &[&str]) -> Vec { + let mut hits = Vec::new(); + collect_production_source_hits(&root.join("crates"), root, needles, &mut hits); + hits.sort(); + hits +} + +fn collect_production_source_hits( + directory: &std::path::Path, + root: &std::path::Path, + needles: &[&str], + hits: &mut Vec, +) { + for entry in fs::read_dir(directory).unwrap_or_else(|error| { + panic!("failed to read {}: {error}", directory.display()); + }) { + let path = entry.expect("directory entry").path(); + if path.is_dir() { + if path.file_name().and_then(|name| name.to_str()) != Some("tests") { + collect_production_source_hits(&path, root, needles, hits); + } + continue; + } + if path.extension().and_then(|extension| extension.to_str()) != Some("rs") { + continue; + } + collect_file_hits(&path, root, needles, hits); + } +} + +fn collect_file_hits( + path: &std::path::Path, + root: &std::path::Path, + needles: &[&str], + hits: &mut Vec, +) { + let body = fs::read_to_string(path).unwrap_or_else(|error| { + panic!("failed to read {}: {error}", path.display()); + }); + let mut in_test_region = path.components().any(|component| { + component + .as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case("tests") + }); + for line in body.lines() { + let trimmed = line.trim(); + if trimmed == "#[cfg(test)]" || trimmed.starts_with("#[test]") { + in_test_region = true; + } + if !in_test_region && needles.iter().any(|needle| trimmed.contains(needle)) { + let relative = path.strip_prefix(root).unwrap_or(path); + hits.push(format!("{}:{}", relative.display(), trimmed)); + } + } +} + +#[test] +fn deny_advisory_ignores_carry_review_evidence() { + let deny = read_workspace_text(&["deny.toml"], "deny.toml"); + let advisory = "RUSTSEC-2024-0436"; + let offset = deny + .find(advisory) + .unwrap_or_else(|| panic!("deny.toml should mention {advisory}")); + let context_start = deny[..offset] + .rfind("[advisories]") + .expect("advisories section"); + let context = &deny[context_start..offset]; + + for required in [ + "Package:", + "Reachability:", + "Reviewed:", + "Upgrade/follow-up:", + ] { + assert!( + context.contains(required), + "advisory ignore {advisory} should document {required}" + ); + } +} + +#[test] +fn ai_asset_checks_self_heal_generated_drift_before_failing() { + for script_name in ["check-ai-prompt.ps1", "check-ai-skill.ps1"] { + let script = read_workspace_text(&["scripts", script_name], script_name); + assert!( + script.contains("Invoke-GeneratorCheck"), + "{script_name} should use the shared check/regenerate/recheck helper" + ); + assert!( + script.contains("Invoke-GeneratorWrite"), + "{script_name} should regenerate stale generated assets automatically" + ); + assert!( + script.contains("still out of date after regeneration"), + "{script_name} should only ask for manual intervention after regeneration fails" + ); + } + + let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade docs"); + assert!( + docs.contains("self-heal generated asset drift"), + "Jade docs should describe the AI asset gate's self-healing behavior" + ); +} diff --git a/crates/common/tests/miri_json_family.rs b/crates/common/tests/miri_json_family.rs new file mode 100644 index 0000000..93f3131 --- /dev/null +++ b/crates/common/tests/miri_json_family.rs @@ -0,0 +1,63 @@ +//! Miri regression coverage for compact JSON-family parsers. + +use common::formats::toon::{DecodeOptions, EncodeOptions}; +use common::formats::{ison, tonl, toon, zon}; +use serde_json::json; + +#[test] +fn toon_roundtrip_exercises_nested_and_tabular_paths() { + for value in [ + json!({ + "meta": { + "ok": true, + "count": 2 + }, + "items": [ + {"id": 1, "name": "Ada"}, + {"id": 2, "name": "Bob"} + ], + "literal.path": "quoted when encoded" + }), + json!([1, 2, 3]), + json!([{"id": 1}, {"id": 2}]), + json!("plain"), + ] { + let encoded = toon::encode_value(&value, EncodeOptions::default()).expect("TOON encode"); + let decoded = toon::decode_str(&encoded, DecodeOptions::default()); + + assert_eq!(decoded.expect("TOON decode"), value); + } +} + +#[test] +fn toon_decoder_rejects_malformed_counts_and_path_conflicts() { + let bad_count = toon::decode_str("[3]: a,b\n", DecodeOptions::default()); + assert!(bad_count.is_err()); + + let path_conflict = toon::decode_str( + "a: 1\na.b: 2\n", + DecodeOptions { + expand_paths: common::formats::toon::SafeMode::Safe, + ..DecodeOptions::default() + }, + ); + assert!(path_conflict.is_err()); +} + +#[test] +fn record_formats_decode_without_panicking() { + let records = vec![json!({"id": 1, "name": "Ada", "active": true})]; + let isonl = ison::encode_records(&records, "user").expect("ISONL encode"); + assert_eq!(ison::decode_records(&isonl).expect("ISONL decode"), records); + + assert_eq!( + zon::decode_str("id:1\nname:\"Ada\"\nactive:true\n").expect("ZON decode"), + json!({"id": 1, "name": "Ada", "active": true}) + ); + + let tonl = tonl::encode_documents(&[json!({"id": 1, "name": "Ada"})]).expect("TONL encode"); + assert_eq!( + tonl::decode_documents(&tonl).expect("TONL decode"), + vec![json!({"id": 1, "name": "Ada"})] + ); +} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml new file mode 100644 index 0000000..bf56621 --- /dev/null +++ b/crates/config/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "config" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Summarize common config file formats into compact AI-friendly key paths." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +configsupport = { path = "../configsupport" } +common = { path = "../common" } +lexopt.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } +serde_yaml = "0.9.34" +toml = "0.8.23" + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 0000000..01a3f12 --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,2540 @@ +//! The `config` command reads and edits structured configuration files. +#![allow(clippy::multiple_crate_versions)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + InputFormat, expand_input_patterns, read_existing_stdin_paths, require_exactly_one_input_path, +}; +use configsupport::{ + CliError, CommonArgs, ExitCode, RenderMode, map_result_count, parse_color_choice, print_json, + print_quick_help_error, print_structured, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const HELP: &str = "\ +Read and edit config values with slash-pointer paths. + +Usage: + config [OPTIONS] [PATH] + config [OPTIONS] get [PATH] [POINTER] + config [OPTIONS] inspect [PATH] [POINTER] + config [OPTIONS] ls [PATH] [POINTER] + config [OPTIONS] set + config [OPTIONS] delete + +Options: + --json Emit structured JSON output + --toon Emit structured TOON output + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --format Input file format: auto, json, yaml, toml, env, ini + --value-type Set value type: auto, string, number, bool, null, json + -h, --help Show this help text + -V, --version Show the command version + +Slash-pointer examples: + /package/version + /features/0 + +Examples: + config get .\\package.json /version + config ls .\\settings.yaml /plugins + config inspect .\\settings.toml /logging --format toml + config set .\\settings.yaml /plugins/metrics/enabled false --value-type bool + config delete .\\package.json /workspace/cache + '.\\fixtures\\reading\\config.toml' | config ls /logging + bat --style=plain --paging=never .\\fixtures\\reading\\config.toml | config inspect --format toml /logging + config ls .\\appsettings.json /services --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + format: ConfigFormat, + value_type: ValueType, + command: CommandMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandMode { + Get { + path: Option, + pointer: String, + }, + Ls { + path: Option, + pointer: String, + }, + Set { + path: PathBuf, + pointer: String, + raw_value: String, + }, + Delete { + path: PathBuf, + pointer: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ConfigFormat { + Auto, + Json, + Yaml, + Toml, + Env, + Ini, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ValueType { + Auto, + String, + Number, + Bool, + Null, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ValueKind { + Object, + Array, + String, + Number, + Bool, + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct GetOutput { + path: Option, + format: ConfigFormat, + pointer: String, + kind: ValueKind, + value: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ListOutput { + path: Option, + format: ConfigFormat, + pointer: String, + kind: ValueKind, + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ListEntry { + key: String, + pointer: String, + kind: ValueKind, + value: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct MutationOutput { + path: String, + format: ConfigFormat, + operation: String, + pointer: String, + changed: bool, + previous: Option, + value: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("config {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut format = ConfigFormat::Auto; + let mut value_type = ValueType::Auto; + let mut subcommand = None::; + let mut positional = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + format, + value_type, + command: CommandMode::Ls { + path: None, + pointer: "/".to_string(), + }, + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + format, + value_type, + command: CommandMode::Ls { + path: None, + pointer: "/".to_string(), + }, + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("format") => { + format = parse_config_format(&parser_value_string(&mut parser, "--format")?)?; + } + Long("value-type") => { + value_type = parse_value_type(&parser_value_string(&mut parser, "--value-type")?)?; + } + ArgValue(value) => { + if subcommand.is_none() { + let token = os_to_string(value.clone(), "subcommand")?; + if matches!(token.as_str(), "get" | "inspect" | "ls" | "set" | "delete") { + subcommand = Some(token); + } else { + subcommand = Some("ls".to_string()); + positional.push(value); + } + } else { + positional.push(value); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let command_name = subcommand + .ok_or_else(|| CliError::usage("provide a subcommand: get, set, delete, or ls"))?; + let command = parse_command_mode(&command_name, positional)?; + if !matches!(command, CommandMode::Set { .. }) && value_type != ValueType::Auto { + return Err(CliError::usage("--value-type is only valid with 'set'")); + } + + Ok(( + ParseOutcome::Run, + Cli { + common, + format, + value_type, + command, + }, + )) +} + +fn parse_command_mode(name: &str, positional: Vec) -> Result { + match name { + "get" => { + let (path, pointer) = parse_read_args(positional, "get")?; + Ok(CommandMode::Get { path, pointer }) + } + "inspect" => { + let (path, pointer) = parse_read_args(positional, "inspect")?; + Ok(CommandMode::Ls { path, pointer }) + } + "ls" => { + let (path, pointer) = parse_read_args(positional, "ls")?; + Ok(CommandMode::Ls { path, pointer }) + } + "set" => parse_set_args(positional), + "delete" => parse_delete_args(positional), + _ => Err(CliError::usage( + "unknown subcommand; expected get, inspect, ls, set, or delete", + )), + } +} + +fn parse_read_args( + positional: Vec, + subcommand: &str, +) -> Result<(Option, String), CliError> { + let values = positional + .into_iter() + .map(|value| os_to_string(value, subcommand)) + .collect::, _>>()?; + match values.as_slice() { + [] => Ok((None, "/".to_string())), + [single] if is_pointer(single) => Ok((None, single.clone())), + [single] => Ok((Some(PathBuf::from(single)), "/".to_string())), + [path, pointer] => Ok((Some(PathBuf::from(path)), pointer.clone())), + _ => Err(CliError::usage(format!( + "{subcommand} expects [PATH] [POINTER]" + ))), + } +} + +fn parse_set_args(positional: Vec) -> Result { + let values = positional + .into_iter() + .map(|value| os_to_string(value, "set argument")) + .collect::, _>>()?; + match values.as_slice() { + [path, pointer, raw_value] => Ok(CommandMode::Set { + path: PathBuf::from(path), + pointer: pointer.clone(), + raw_value: raw_value.clone(), + }), + _ => Err(CliError::usage("set expects ")), + } +} + +fn parse_delete_args(positional: Vec) -> Result { + let values = positional + .into_iter() + .map(|value| os_to_string(value, "delete argument")) + .collect::, _>>()?; + match values.as_slice() { + [path, pointer] => Ok(CommandMode::Delete { + path: PathBuf::from(path), + pointer: pointer.clone(), + }), + _ => Err(CliError::usage("delete expects ")), + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_to_string(value, flag) +} + +fn os_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_config_format(value: &str) -> Result { + match value { + "auto" => Ok(ConfigFormat::Auto), + "json" => Ok(ConfigFormat::Json), + "yaml" => Ok(ConfigFormat::Yaml), + "toml" => Ok(ConfigFormat::Toml), + "env" => Ok(ConfigFormat::Env), + "ini" => Ok(ConfigFormat::Ini), + other => Err(CliError::usage(format!( + "invalid --format value '{other}'; expected auto, json, yaml, toml, env, or ini" + ))), + } +} + +fn parse_value_type(value: &str) -> Result { + match value { + "auto" => Ok(ValueType::Auto), + "string" => Ok(ValueType::String), + "number" => Ok(ValueType::Number), + "bool" => Ok(ValueType::Bool), + "null" => Ok(ValueType::Null), + "json" => Ok(ValueType::Json), + other => Err(CliError::usage(format!( + "invalid --value-type value '{other}'; expected auto, string, number, bool, null, or json" + ))), + } +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + CommandMode::Get { path, pointer } => run_get(cli, path.as_ref(), pointer), + CommandMode::Ls { path, pointer } => run_ls(cli, path.as_ref(), pointer), + CommandMode::Set { + path, + pointer, + raw_value, + } => run_set(cli, path, pointer, raw_value), + CommandMode::Delete { path, pointer } => run_delete(cli, path, pointer), + } +} + +fn run_get(cli: &Cli, path: Option<&PathBuf>, pointer: &str) -> Result { + let document = load_document(path, cli.common.stdin_is_terminal())?; + let format = resolve_format(document.path.as_deref(), cli.format, &document.content)?; + let value = parse_value(&document.content, format)?; + let segments = resolve_pointer_segments_for_read(format, pointer, &value)?; + let target = resolve_pointer(&value, &segments) + .cloned() + .ok_or_else(|| build_missing_pointer_error(pointer, &value, &segments))?; + + let payload = GetOutput { + path: Some(display_input_path(document.path.as_deref())), + format, + pointer: pointer.to_string(), + kind: kind_for_value(&target), + value: target, + }; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&payload)?, + RenderMode::Toon => print_structured(&payload, RenderMode::Toon)?, + RenderMode::Text => { + print!("{}", render_get_output(&payload)); + } + } + Ok(ExitCode::Success) +} + +fn run_ls(cli: &Cli, path: Option<&PathBuf>, pointer: &str) -> Result { + let document = load_document(path, cli.common.stdin_is_terminal())?; + let format = resolve_format(document.path.as_deref(), cli.format, &document.content)?; + let value = parse_value(&document.content, format)?; + let segments = resolve_pointer_segments_for_read(format, pointer, &value)?; + let target = resolve_pointer(&value, &segments) + .ok_or_else(|| build_missing_pointer_error(pointer, &value, &segments))?; + let entries = list_entries(target, pointer); + + let payload = ListOutput { + path: Some(display_input_path(document.path.as_deref())), + format, + pointer: pointer.to_string(), + kind: kind_for_value(target), + entries, + }; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&payload)?, + RenderMode::Toon => print_structured(&payload, RenderMode::Toon)?, + RenderMode::Text => { + print!("{}", render_list_output(&payload)); + } + } + + Ok(map_result_count(payload.entries.len())) +} + +fn build_missing_pointer_error(pointer: &str, root: &Value, segments: &[String]) -> CliError { + let (discovery_pointer, discovery_target) = nearest_existing_container(root, segments); + let mut message = format!("pointer not found: {pointer}; "); + if discovery_pointer == "/" { + message.push_str("try 'config ls /'"); + } else { + let _ = write!( + &mut message, + "try 'config ls {discovery_pointer}' (or 'config ls /')" + ); + } + + let nearby = list_entries(discovery_target, &discovery_pointer) + .into_iter() + .map(|entry| entry.pointer) + .take(3) + .collect::>(); + if !nearby.is_empty() { + let _ = write!(&mut message, "; nearby: {}", nearby.join(", ")); + } + + CliError::runtime(message) +} + +fn run_set(cli: &Cli, path: &Path, pointer: &str, raw_value: &str) -> Result { + let content = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let format = resolve_format(Some(path), cli.format, &content)?; + let mut document = parse_value(&content, format)?; + let segments = resolve_pointer_segments_for_write(format, pointer)?; + let value = parse_set_value(raw_value, cli.value_type)?; + let previous = resolve_pointer(&document, &segments).cloned(); + let changed = apply_set_by_format(format, &mut document, &segments, value.clone())?; + + let serialized = serialize_value(&document, format)?; + fs::write(path, serialized).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + + let payload = MutationOutput { + path: path.display().to_string(), + format, + operation: "set".to_string(), + pointer: pointer.to_string(), + changed, + previous, + value: Some(value), + }; + match cli.common.render_mode() { + RenderMode::Json => print_json(&payload)?, + RenderMode::Toon => print_structured(&payload, RenderMode::Toon)?, + RenderMode::Text => { + print!("{}", render_mutation_output(&payload)); + } + } + + Ok(map_result_count(usize::from(changed))) +} + +fn run_delete(cli: &Cli, path: &Path, pointer: &str) -> Result { + let content = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let format = resolve_format(Some(path), cli.format, &content)?; + let mut document = parse_value(&content, format)?; + let segments = resolve_pointer_segments_for_write(format, pointer)?; + let previous = resolve_pointer(&document, &segments).cloned(); + let changed = apply_delete_by_format(format, &mut document, &segments)?; + let serialized = serialize_value(&document, format)?; + fs::write(path, serialized).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + + let payload = MutationOutput { + path: path.display().to_string(), + format, + operation: "delete".to_string(), + pointer: pointer.to_string(), + changed, + previous, + value: None, + }; + match cli.common.render_mode() { + RenderMode::Json => print_json(&payload)?, + RenderMode::Toon => print_structured(&payload, RenderMode::Toon)?, + RenderMode::Text => { + print!("{}", render_mutation_output(&payload)); + } + } + + Ok(map_result_count(usize::from(changed))) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InputDocument { + path: Option, + content: String, +} + +const STDIN_PATH_LABEL: &str = ""; + +fn load_document( + path: Option<&PathBuf>, + stdin_is_terminal: bool, +) -> Result { + if let Some(explicit_path) = path { + let path = require_exactly_one_input_path( + &expand_input_patterns(std::slice::from_ref(explicit_path), "config") + .map_err(|error| CliError::usage(error.to_string()))?, + "config", + ) + .map_err(|error| CliError::usage(error.to_string()))?; + let content = fs::read_to_string(&path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + return Ok(InputDocument { + path: Some(path), + content, + }); + } + + if stdin_is_terminal { + return Err(CliError::usage( + "provide a config path or pipe config content into stdin", + )); + } + + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if buffer.trim().is_empty() { + return Err(CliError::usage("stdin was empty")); + } + if let Some(paths) = read_existing_stdin_paths(&buffer, InputFormat::Auto, "config") + .map_err(|error| CliError::usage(error.to_string()))? + { + let path = require_exactly_one_input_path(&paths, "config") + .map_err(|error| CliError::usage(error.to_string()))?; + let content = fs::read_to_string(&path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + return Ok(InputDocument { + path: Some(path), + content, + }); + } + Ok(InputDocument { + path: None, + content: buffer, + }) +} + +fn display_input_path(path: Option<&Path>) -> String { + path.map_or_else( + || STDIN_PATH_LABEL.to_string(), + |item| item.display().to_string(), + ) +} + +fn resolve_format( + path: Option<&Path>, + requested: ConfigFormat, + content: &str, +) -> Result { + if requested != ConfigFormat::Auto { + return Ok(requested); + } + + if let Some(extension) = path + .and_then(Path::extension) + .and_then(|item| item.to_str()) + .map(str::to_ascii_lowercase) + { + return match extension.as_str() { + "json" => Ok(ConfigFormat::Json), + "yaml" | "yml" => Ok(ConfigFormat::Yaml), + "toml" => Ok(ConfigFormat::Toml), + "env" | "dotenv" => Ok(ConfigFormat::Env), + "ini" => Ok(ConfigFormat::Ini), + _ => detect_format_from_content(content), + }; + } + + detect_format_from_content(content) +} + +fn detect_format_from_content(content: &str) -> Result { + let trimmed = content.trim(); + if trimmed.is_empty() { + return Ok(ConfigFormat::Json); + } + if serde_json::from_str::(trimmed).is_ok() { + return Ok(ConfigFormat::Json); + } + if looks_like_env(content) && parse_env(content).is_ok() { + return Ok(ConfigFormat::Env); + } + if looks_like_ini(content) && parse_ini(content).is_ok() { + return Ok(ConfigFormat::Ini); + } + if toml::from_str::(content).is_ok() { + return Ok(ConfigFormat::Toml); + } + if serde_yaml::from_str::(content).is_ok() { + return Ok(ConfigFormat::Yaml); + } + Err(CliError::usage( + "unable to detect config format; use --format auto|json|yaml|toml|env|ini", + )) +} + +fn looks_like_env(content: &str) -> bool { + let mut saw_content = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') { + continue; + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + return false; + } + if !trimmed.contains('=') { + return false; + } + saw_content = true; + } + saw_content +} + +fn looks_like_ini(content: &str) -> bool { + let mut saw_content = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') { + continue; + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + return true; + } + if !trimmed.contains('=') { + return false; + } + saw_content = true; + } + saw_content +} + +fn parse_value(content: &str, format: ConfigFormat) -> Result { + match format { + ConfigFormat::Auto => Err(CliError::usage( + "internal error: auto format must be resolved", + )), + ConfigFormat::Json => serde_json::from_str(content) + .map_err(|error| CliError::runtime(format!("failed to parse JSON: {error}"))), + ConfigFormat::Yaml => { + let value = serde_yaml::from_str::(content) + .map_err(|error| CliError::runtime(format!("failed to parse YAML: {error}")))?; + serde_json::to_value(value) + .map_err(|error| CliError::runtime(format!("failed to convert YAML: {error}"))) + } + ConfigFormat::Toml => { + let value = toml::from_str::(content) + .map_err(|error| CliError::runtime(format!("failed to parse TOML: {error}")))?; + serde_json::to_value(value) + .map_err(|error| CliError::runtime(format!("failed to convert TOML: {error}"))) + } + ConfigFormat::Env => parse_env(content), + ConfigFormat::Ini => parse_ini(content), + } +} + +fn parse_env(content: &str) -> Result { + let mut object = serde_json::Map::new(); + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') { + continue; + } + let normalized = trimmed.strip_prefix("export ").unwrap_or(trimmed); + let Some((key, raw_value)) = normalized.split_once('=') else { + return Err(CliError::runtime(format!( + "invalid env line {}: expected KEY=VALUE", + index + 1 + ))); + }; + let key = key.trim(); + if key.is_empty() { + return Err(CliError::runtime(format!( + "invalid env line {}: key cannot be empty", + index + 1 + ))); + } + object.insert( + key.to_string(), + Value::String(unquote_scalar(raw_value.trim())), + ); + } + Ok(Value::Object(object)) +} + +fn parse_ini(content: &str) -> Result { + let mut root = serde_json::Map::new(); + let mut sections = BTreeMap::>::new(); + let mut current_section = None::; + + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') { + continue; + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let name = trimmed + .strip_prefix('[') + .and_then(|item| item.strip_suffix(']')) + .map(str::trim) + .filter(|item| !item.is_empty()) + .ok_or_else(|| { + CliError::runtime(format!( + "invalid ini section line {}: expected [SECTION]", + index + 1 + )) + })?; + current_section = Some(name.to_string()); + let _ = sections.entry(name.to_string()).or_default(); + continue; + } + + let Some((key, raw_value)) = trimmed.split_once('=') else { + return Err(CliError::runtime(format!( + "invalid ini line {}: expected KEY=VALUE", + index + 1 + ))); + }; + let key = key.trim(); + if key.is_empty() { + return Err(CliError::runtime(format!( + "invalid ini line {}: key cannot be empty", + index + 1 + ))); + } + let value = Value::String(unquote_scalar(raw_value.trim())); + if let Some(section) = ¤t_section { + if let Some(table) = sections.get_mut(section) { + table.insert(key.to_string(), value); + } + } else { + root.insert(key.to_string(), value); + } + } + + for (name, table) in sections { + if root.contains_key(&name) { + return Err(CliError::runtime(format!( + "ini conflict: '{name}' exists as both key and section" + ))); + } + root.insert(name, Value::Object(table)); + } + Ok(Value::Object(root)) +} + +fn unquote_scalar(raw: &str) -> String { + let double_quoted = raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2; + let single_quoted = raw.starts_with('\'') && raw.ends_with('\'') && raw.len() >= 2; + if double_quoted || single_quoted { + raw[1..raw.len() - 1].to_string() + } else { + raw.to_string() + } +} + +fn parse_pointer(pointer: &str) -> Result, CliError> { + if pointer == "/" { + return Ok(Vec::new()); + } + if !is_pointer(pointer) { + return Err(CliError::usage(format!( + "invalid pointer '{pointer}'; expected slash syntax like /key/path" + ))); + } + + pointer + .split('/') + .skip(1) + .map(unescape_pointer_token) + .collect() +} + +fn nearest_existing_container<'a>(root: &'a Value, segments: &[String]) -> (String, &'a Value) { + let mut best_len = 0usize; + for prefix_len in 0..=segments.len() { + let Some(candidate) = resolve_pointer(root, &segments[..prefix_len]) else { + break; + }; + if matches!(candidate, Value::Object(_) | Value::Array(_)) { + best_len = prefix_len; + } + } + + let pointer = pointer_from_segments(&segments[..best_len]); + let target = resolve_pointer(root, &segments[..best_len]).unwrap_or(root); + (pointer, target) +} + +fn resolve_pointer_segments_for_read( + format: ConfigFormat, + pointer: &str, + value: &Value, +) -> Result, CliError> { + let segments = parse_pointer(pointer)?; + if format != ConfigFormat::Ini { + return Ok(segments); + } + + for candidate in ini_pointer_candidates(&segments) { + if resolve_pointer(value, &candidate).is_some() { + return Ok(candidate); + } + } + Ok(segments) +} + +fn resolve_pointer_segments_for_write( + format: ConfigFormat, + pointer: &str, +) -> Result, CliError> { + let segments = parse_pointer(pointer)?; + if format == ConfigFormat::Ini && segments.len() > 2 { + return Ok(collapse_ini_section_key(&segments)); + } + Ok(segments) +} + +fn ini_pointer_candidates(segments: &[String]) -> Vec> { + let mut candidates = vec![segments.to_vec()]; + if segments.len() > 1 { + let section_key = collapse_ini_section_key(segments); + if section_key != segments { + candidates.push(section_key); + } + + let section = vec![segments.join(".")]; + if !candidates.iter().any(|candidate| candidate == §ion) { + candidates.push(section); + } + } + candidates +} + +fn collapse_ini_section_key(segments: &[String]) -> Vec { + if segments.len() <= 2 { + return segments.to_vec(); + } + + vec![ + segments[..segments.len() - 1].join("."), + segments[segments.len() - 1].clone(), + ] +} + +fn is_pointer(value: &str) -> bool { + value.starts_with('/') +} + +fn unescape_pointer_token(token: &str) -> Result { + let mut output = String::new(); + let mut chars = token.chars(); + while let Some(character) = chars.next() { + if character != '~' { + output.push(character); + continue; + } + let Some(next) = chars.next() else { + return Err(CliError::usage("invalid pointer escape; expected ~0 or ~1")); + }; + match next { + '0' => output.push('~'), + '1' => output.push('/'), + _ => { + return Err(CliError::usage("invalid pointer escape; expected ~0 or ~1")); + } + } + } + Ok(output) +} + +fn resolve_pointer<'a>(value: &'a Value, segments: &[String]) -> Option<&'a Value> { + let mut current = value; + for segment in segments { + match current { + Value::Object(map) => { + current = map.get(segment)?; + } + Value::Array(items) => { + let index = parse_array_index(segment).ok()?; + current = items.get(index)?; + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => return None, + } + } + Some(current) +} + +fn resolve_pointer_mut<'a>(value: &'a mut Value, segments: &[String]) -> Option<&'a mut Value> { + let mut current = value; + for segment in segments { + match current { + Value::Object(map) => { + current = map.get_mut(segment)?; + } + Value::Array(items) => { + let index = parse_array_index(segment).ok()?; + current = items.get_mut(index)?; + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => return None, + } + } + Some(current) +} + +fn parse_array_index(segment: &str) -> Result { + segment.parse::().map_err(|error| { + CliError::runtime(format!( + "expected array index segment, got '{segment}': {error}" + )) + }) +} + +fn list_entries(target: &Value, pointer: &str) -> Vec { + match target { + Value::Object(map) => { + let keys = map.keys().cloned().collect::>(); + let mut entries = Vec::new(); + for key in keys { + if let Some(value) = map.get(&key) { + entries.push(ListEntry { + key: key.clone(), + pointer: join_pointer(pointer, &key), + kind: kind_for_value(value), + value: value.clone(), + }); + } + } + entries + } + Value::Array(items) => items + .iter() + .enumerate() + .map(|(index, value)| ListEntry { + key: index.to_string(), + pointer: join_pointer(pointer, &index.to_string()), + kind: kind_for_value(value), + value: value.clone(), + }) + .collect(), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => Vec::new(), + } +} + +fn join_pointer(base: &str, token: &str) -> String { + let escaped = token.replace('~', "~0").replace('/', "~1"); + if base == "/" { + format!("/{escaped}") + } else { + format!("{base}/{escaped}") + } +} + +fn pointer_from_segments(segments: &[String]) -> String { + segments.iter().fold("/".to_string(), |pointer, segment| { + join_pointer(&pointer, segment) + }) +} + +fn parse_set_value(raw: &str, value_type: ValueType) -> Result { + match value_type { + ValueType::Auto => parse_auto_value(raw), + ValueType::String => Ok(Value::String(raw.to_string())), + ValueType::Number => { + let number = raw.parse::().map_err(|error| { + CliError::usage(format!("invalid number value '{raw}': {error}")) + })?; + Ok(Value::Number(number)) + } + ValueType::Bool => match raw { + "true" => Ok(Value::Bool(true)), + "false" => Ok(Value::Bool(false)), + _ => Err(CliError::usage( + "invalid bool value; expected 'true' or 'false'", + )), + }, + ValueType::Null => { + if raw == "null" { + Ok(Value::Null) + } else { + Err(CliError::usage( + "invalid null value; expected literal 'null'", + )) + } + } + ValueType::Json => serde_json::from_str(raw) + .map_err(|error| CliError::usage(format!("invalid JSON value '{raw}': {error}"))), + } +} + +fn parse_auto_value(raw: &str) -> Result { + if raw == "true" { + return Ok(Value::Bool(true)); + } + if raw == "false" { + return Ok(Value::Bool(false)); + } + if raw == "null" { + return Ok(Value::Null); + } + if let Ok(number) = raw.parse::() { + return Ok(Value::Number(number)); + } + if raw.starts_with('{') || raw.starts_with('[') { + return serde_json::from_str(raw) + .map_err(|error| CliError::usage(format!("invalid JSON value '{raw}': {error}"))); + } + Ok(Value::String(raw.to_string())) +} + +fn apply_set_by_format( + format: ConfigFormat, + document: &mut Value, + segments: &[String], + value: Value, +) -> Result { + match format { + ConfigFormat::Json | ConfigFormat::Yaml | ConfigFormat::Toml => { + apply_set_general(document, segments, value) + } + ConfigFormat::Env => apply_set_env(document, segments, &value), + ConfigFormat::Ini => apply_set_ini(document, segments, &value), + ConfigFormat::Auto => Err(CliError::usage( + "internal error: auto format must be resolved before setting", + )), + } +} + +fn apply_delete_by_format( + format: ConfigFormat, + document: &mut Value, + segments: &[String], +) -> Result { + match format { + ConfigFormat::Json | ConfigFormat::Yaml | ConfigFormat::Toml => { + apply_delete_general(document, segments) + } + ConfigFormat::Env => apply_delete_env(document, segments), + ConfigFormat::Ini => apply_delete_ini(document, segments), + ConfigFormat::Auto => Err(CliError::usage( + "internal error: auto format must be resolved before deleting", + )), + } +} + +fn apply_set_general( + document: &mut Value, + segments: &[String], + value: Value, +) -> Result { + if segments.is_empty() { + let changed = *document != value; + *document = value; + return Ok(changed); + } + let (leaf, parents) = split_pointer(segments)?; + let parent = ensure_path_mut(document, parents)?; + set_in_container(parent, leaf, value) +} + +fn apply_delete_general(document: &mut Value, segments: &[String]) -> Result { + if segments.is_empty() { + return Err(CliError::usage("delete cannot target root pointer '/'")); + } + let (leaf, parents) = split_pointer(segments)?; + let Some(parent) = resolve_pointer_mut(document, parents) else { + return Ok(false); + }; + delete_from_container(parent, leaf) +} + +fn split_pointer(segments: &[String]) -> Result<(&str, &[String]), CliError> { + let (leaf, parents) = segments + .split_last() + .ok_or_else(|| CliError::usage("pointer cannot be empty"))?; + Ok((leaf.as_str(), parents)) +} + +fn ensure_path_mut<'a>( + current: &'a mut Value, + segments: &[String], +) -> Result<&'a mut Value, CliError> { + let mut node = current; + for (index, segment) in segments.iter().enumerate() { + let next = segments.get(index + 1); + match node { + Value::Object(map) => { + if !map.contains_key(segment) { + map.insert(segment.clone(), default_container(next)); + } + node = map.get_mut(segment).ok_or_else(|| { + CliError::runtime(format!("failed to access object key '{segment}'")) + })?; + } + Value::Array(items) => { + let offset = parse_array_index(segment)?; + if offset > items.len() { + return Err(CliError::runtime(format!( + "array index {offset} is out of bounds for length {}", + items.len() + ))); + } + if offset == items.len() { + items.push(default_container(next)); + } + node = items.get_mut(offset).ok_or_else(|| { + CliError::runtime(format!("failed to access array index {offset}")) + })?; + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + return Err(CliError::runtime(format!( + "pointer traverses through non-container at segment '{segment}'" + ))); + } + } + } + Ok(node) +} + +fn default_container(next: Option<&String>) -> Value { + if next.is_some_and(|segment| segment.parse::().is_ok()) { + Value::Array(Vec::new()) + } else { + Value::Object(serde_json::Map::new()) + } +} + +fn set_in_container(container: &mut Value, leaf: &str, value: Value) -> Result { + match container { + Value::Object(map) => { + if map.get(leaf).is_some_and(|existing| existing == &value) { + Ok(false) + } else { + map.insert(leaf.to_string(), value); + Ok(true) + } + } + Value::Array(items) => { + let index = parse_array_index(leaf)?; + if index > items.len() { + return Err(CliError::runtime(format!( + "array index {index} is out of bounds for length {}", + items.len() + ))); + } + if index == items.len() { + items.push(value); + Ok(true) + } else if items.get(index).is_some_and(|existing| existing == &value) { + Ok(false) + } else if let Some(slot) = items.get_mut(index) { + *slot = value; + Ok(true) + } else { + Err(CliError::runtime(format!( + "failed to access array index {index}" + ))) + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => Err( + CliError::runtime("cannot set child pointer on a scalar value"), + ), + } +} + +fn delete_from_container(container: &mut Value, leaf: &str) -> Result { + match container { + Value::Object(map) => Ok(map.remove(leaf).is_some()), + Value::Array(items) => { + let index = parse_array_index(leaf)?; + if index < items.len() { + let _ = items.remove(index); + Ok(true) + } else { + Ok(false) + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => Err( + CliError::runtime("cannot delete child pointer on a scalar value"), + ), + } +} + +fn apply_set_env( + document: &mut Value, + segments: &[String], + value: &Value, +) -> Result { + if segments.len() != 1 { + return Err(CliError::runtime( + "env write operations only support root keys like /NAME", + )); + } + let text = scalar_to_env_text_ref(value)?; + apply_set_general(document, segments, Value::String(text)) +} + +fn apply_delete_env(document: &mut Value, segments: &[String]) -> Result { + if segments.len() != 1 { + return Err(CliError::runtime( + "env write operations only support root keys like /NAME", + )); + } + apply_delete_general(document, segments) +} + +fn apply_set_ini( + document: &mut Value, + segments: &[String], + value: &Value, +) -> Result { + if segments.is_empty() || segments.len() > 2 { + return Err(CliError::runtime( + "ini write operations only support /KEY or /SECTION/KEY pointers", + )); + } + let text = scalar_to_env_text_ref(value)?; + apply_set_general(document, segments, Value::String(text)) +} + +fn apply_delete_ini(document: &mut Value, segments: &[String]) -> Result { + if segments.is_empty() || segments.len() > 2 { + return Err(CliError::runtime( + "ini delete operations only support /KEY or /SECTION/KEY pointers", + )); + } + apply_delete_general(document, segments) +} + +fn scalar_to_env_text_ref(value: &Value) -> Result { + match value { + Value::String(text) => Ok(text.clone()), + Value::Number(number) => Ok(number.to_string()), + Value::Bool(flag) => Ok(flag.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => Err(CliError::runtime( + "env/ini writes require scalar string|number|bool values", + )), + } +} + +fn serialize_value(value: &Value, format: ConfigFormat) -> Result { + match format { + ConfigFormat::Json => serde_json::to_string_pretty(value) + .map(|text| format!("{text}\n")) + .map_err(|error| CliError::runtime(format!("failed to serialize JSON: {error}"))), + ConfigFormat::Yaml => serde_yaml::to_string(value) + .map_err(|error| CliError::runtime(format!("failed to serialize YAML: {error}"))), + ConfigFormat::Toml => toml::to_string_pretty(value) + .map(|text| format!("{text}\n")) + .map_err(|error| CliError::runtime(format!("failed to serialize TOML: {error}"))), + ConfigFormat::Env => serialize_env(value), + ConfigFormat::Ini => serialize_ini(value), + ConfigFormat::Auto => Err(CliError::usage( + "internal error: auto format must be resolved before serialization", + )), + } +} + +fn serialize_env(value: &Value) -> Result { + let Value::Object(map) = value else { + return Err(CliError::runtime( + "env serialization requires an object root", + )); + }; + let mut sorted = BTreeMap::::new(); + for (key, item) in map { + let text = scalar_to_env_text_ref(item)?; + sorted.insert(key.clone(), text); + } + + let mut output = String::new(); + for (key, text) in sorted { + output.push_str(&key); + output.push('='); + output.push_str(&text); + output.push('\n'); + } + Ok(output) +} + +fn serialize_ini(value: &Value) -> Result { + let Value::Object(map) = value else { + return Err(CliError::runtime( + "ini serialization requires an object root", + )); + }; + + let mut root = BTreeMap::::new(); + let mut sections = BTreeMap::>::new(); + for (key, item) in map { + match item { + Value::Object(table) => { + let mut section = BTreeMap::::new(); + for (child_key, child_value) in table { + section.insert(child_key.clone(), scalar_to_env_text_ref(child_value)?); + } + sections.insert(key.clone(), section); + } + Value::Array(_) => { + return Err(CliError::runtime( + "ini serialization does not support array values", + )); + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + root.insert(key.clone(), scalar_to_env_text_ref(item)?); + } + } + } + + let mut output = String::new(); + for (key, text) in root { + output.push_str(&key); + output.push('='); + output.push_str(&text); + output.push('\n'); + } + if !sections.is_empty() && !output.is_empty() { + output.push('\n'); + } + let mut first = true; + for (name, section) in sections { + if !first { + output.push('\n'); + } + first = false; + output.push('['); + output.push_str(&name); + output.push_str("]\n"); + for (key, text) in section { + output.push_str(&key); + output.push('='); + output.push_str(&text); + output.push('\n'); + } + } + Ok(output) +} + +const fn kind_for_value(value: &Value) -> ValueKind { + match value { + Value::Object(_) => ValueKind::Object, + Value::Array(_) => ValueKind::Array, + Value::String(_) => ValueKind::String, + Value::Number(_) => ValueKind::Number, + Value::Bool(_) => ValueKind::Bool, + Value::Null => ValueKind::Null, + } +} + +fn render_get_output(payload: &GetOutput) -> String { + format!( + "path={} format={:?} pointer={} kind={} value={}\n", + payload.path.as_deref().unwrap_or("-"), + payload.format, + payload.pointer, + value_kind_name(payload.kind), + compact_json(&payload.value) + ) +} + +fn render_list_output(payload: &ListOutput) -> String { + let mut output = format!( + "path={} format={:?} pointer={} kind={} entries={}\n", + payload.path.as_deref().unwrap_or("-"), + payload.format, + payload.pointer, + value_kind_name(payload.kind), + payload.entries.len() + ); + for entry in &payload.entries { + let _ = writeln!( + output, + "key={} pointer={} kind={} value={}", + entry.key, + entry.pointer, + value_kind_name(entry.kind), + compact_list_value(&entry.value) + ); + } + output +} + +fn render_mutation_output(payload: &MutationOutput) -> String { + let previous = payload + .previous + .as_ref() + .map_or_else(|| "-".to_string(), compact_json); + let value = payload + .value + .as_ref() + .map_or_else(|| "-".to_string(), compact_json); + format!( + "path={} format={:?} op={} pointer={} changed={} previous={} value={}\n", + payload.path, + payload.format, + payload.operation, + payload.pointer, + payload.changed, + previous, + value + ) +} + +fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) +} + +fn compact_list_value(value: &Value) -> String { + match value { + Value::Object(map) => format!("object(keys={})", map.len()), + Value::Array(items) => format!("array(len={})", items.len()), + _ => compact_json(value), + } +} + +const fn value_kind_name(kind: ValueKind) -> &'static str { + match kind { + ValueKind::Object => "object", + ValueKind::Array => "array", + ValueKind::String => "string", + ValueKind::Number => "number", + ValueKind::Bool => "bool", + ValueKind::Null => "null", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use tempfile::tempdir; + + fn assert_usage_error(error: CliError, needle: &str) { + assert_eq!(error.exit_code(), ExitCode::UsageError); + match error { + CliError::Usage(message) => { + assert!( + message.contains(needle), + "expected usage error containing '{needle}', got '{message}'" + ); + } + other @ CliError::Runtime(_) => panic!("expected usage error, got '{other}'"), + } + } + + fn assert_runtime_error(error: CliError, needle: &str) { + assert_eq!(error.exit_code(), ExitCode::RuntimeError); + match error { + CliError::Runtime(message) => { + assert!( + message.contains(needle), + "expected runtime error containing '{needle}', got '{message}'" + ); + } + other @ CliError::Usage(_) => panic!("expected runtime error, got '{other}'"), + } + } + + #[test] + fn parse_cli_accepts_subcommands_and_flags() { + let (_, cli) = parse_cli_from([ + "config", + "--json", + "--format", + "yaml", + "set", + "demo.yaml", + "/retry", + "3", + "--value-type", + "number", + ]) + .expect("cli"); + + assert!(cli.common.json); + assert_eq!(cli.format, ConfigFormat::Yaml); + assert_eq!(cli.value_type, ValueType::Number); + assert!(matches!(cli.command, CommandMode::Set { .. })); + } + + #[test] + fn parse_env_and_ini_cover_common_forms() { + let env = parse_env("# c\nexport API_KEY=secret\nEMPTY=\n").expect("env"); + assert_eq!(env, serde_json::json!({"API_KEY": "secret", "EMPTY": ""})); + + let ini = parse_ini("mode=dev\n[server]\nport=8080\n").expect("ini"); + assert_eq!( + ini, + serde_json::json!({"mode": "dev", "server": {"port": "8080"}}) + ); + } + + #[test] + fn pointer_round_trip_and_general_set_work() { + let pointer = parse_pointer("/pkg/version").expect("pointer"); + let mut document = serde_json::json!({"pkg": {"version": "1.0.0"}}); + let changed = + apply_set_general(&mut document, &pointer, Value::String("1.1.0".to_string())) + .expect("set"); + + assert!(changed); + assert_eq!( + resolve_pointer(&document, &pointer), + Some(&Value::String("1.1.0".to_string())) + ); + assert_eq!(join_pointer("/pkg", "version"), "/pkg/version"); + } + + #[test] + fn auto_value_parsing_prefers_scalars_and_json() { + assert_eq!(parse_auto_value("true").expect("bool"), Value::Bool(true)); + assert_eq!( + parse_auto_value("12").expect("number"), + Value::Number(serde_json::Number::from(12)) + ); + assert_eq!( + parse_auto_value("{\"ok\":true}").expect("json"), + serde_json::json!({"ok": true}) + ); + assert_eq!( + parse_auto_value("literal").expect("string"), + Value::String("literal".to_string()) + ); + } + + #[test] + fn format_helpers_cover_extension_and_content_detection() { + assert_eq!( + resolve_format( + Some(Path::new("settings.YML")), + ConfigFormat::Auto, + "ignored" + ) + .expect("yaml extension"), + ConfigFormat::Yaml + ); + assert_eq!( + resolve_format( + Some(Path::new("settings.cfg")), + ConfigFormat::Auto, + "KEY=value\n" + ) + .expect("env fallback"), + ConfigFormat::Env + ); + assert_eq!( + detect_format_from_content("- alpha\n- beta\n").expect("yaml"), + ConfigFormat::Yaml + ); + assert_eq!( + detect_format_from_content("mode=dev\n[server]\nport=8080\n").expect("ini"), + ConfigFormat::Ini + ); + assert_eq!( + detect_format_from_content(" \n").expect("empty defaults to json"), + ConfigFormat::Json + ); + assert_usage_error( + parse_config_format("xml").expect_err("invalid format"), + "invalid --format value", + ); + assert_usage_error( + parse_value_type("blob").expect_err("invalid value type"), + "invalid --value-type value", + ); + } + + #[test] + fn env_and_ini_parsers_cover_quoted_values_and_failures() { + let env = parse_env("export NAME=\"toolbox\"\nKEY='secret'\n").expect("env parse"); + assert_eq!(env, serde_json::json!({"NAME": "toolbox", "KEY": "secret"})); + assert_runtime_error( + parse_env("BROKEN_LINE\n").expect_err("invalid env"), + "invalid env line 1", + ); + assert_runtime_error( + parse_env(" =value\n").expect_err("empty key"), + "key cannot be empty", + ); + + let ini = parse_ini("name='toolbox'\n[server]\nport=\"8080\"\n").expect("ini parse"); + assert_eq!( + ini, + serde_json::json!({"name": "toolbox", "server": {"port": "8080"}}) + ); + assert_runtime_error( + parse_ini("[ ]\n").expect_err("invalid section"), + "expected [SECTION]", + ); + assert_runtime_error( + parse_ini("mode=dev\n[mode]\nvalue=prod\n").expect_err("section conflict"), + "exists as both key and section", + ); + } + + #[test] + fn pointer_helpers_cover_escape_rules_and_array_indexes() { + assert!(parse_pointer("/").expect("root pointer").is_empty()); + assert_eq!( + parse_pointer("/a~1b/~0c").expect("escaped pointer"), + vec!["a/b".to_string(), "~c".to_string()] + ); + assert_usage_error( + parse_pointer("plain").expect_err("missing slash"), + "invalid pointer", + ); + assert_usage_error( + parse_pointer("/bad~").expect_err("unterminated escape"), + "expected ~0 or ~1", + ); + assert_runtime_error( + parse_array_index("x").expect_err("invalid index"), + "expected array index segment", + ); + } + + #[test] + fn ini_pointer_aliases_support_slash_split_section_names() { + let document = serde_json::json!({ + "Logging.Disk": { + "Enabled": "true" + } + }); + + let read_segments = resolve_pointer_segments_for_read( + ConfigFormat::Ini, + "/Logging/Disk/Enabled", + &document, + ) + .expect("read segments"); + assert_eq!( + read_segments, + vec!["Logging.Disk".to_string(), "Enabled".to_string()] + ); + assert_eq!( + resolve_pointer(&document, &read_segments), + Some(&Value::String("true".to_string())) + ); + + let write_segments = + resolve_pointer_segments_for_write(ConfigFormat::Ini, "/Logging/Disk/Enabled") + .expect("write segments"); + assert_eq!( + write_segments, + vec!["Logging.Disk".to_string(), "Enabled".to_string()] + ); + } + + #[test] + fn set_general_creates_nested_containers_and_detects_noop_updates() { + let mut document = serde_json::json!({}); + let pointer = parse_pointer("/pkg/deps/0/name").expect("pointer"); + assert!( + apply_set_general(&mut document, &pointer, Value::String("serde".to_string())) + .expect("set value") + ); + assert_eq!( + document, + serde_json::json!({"pkg": {"deps": [{"name": "serde"}]}}) + ); + assert!( + !apply_set_general(&mut document, &pointer, Value::String("serde".to_string())) + .expect("set same value") + ); + + assert!( + apply_set_general(&mut document, &[], Value::String("root".to_string())) + .expect("replace root") + ); + assert_eq!(document, Value::String("root".to_string())); + assert!( + !apply_set_general(&mut document, &[], Value::String("root".to_string())) + .expect("same root") + ); + } + + #[test] + fn set_general_reports_non_container_and_sparse_array_errors() { + let mut sparse = serde_json::json!({"items": []}); + let sparse_pointer = parse_pointer("/items/2/name").expect("pointer"); + assert_runtime_error( + apply_set_general(&mut sparse, &sparse_pointer, Value::String("x".to_string())) + .expect_err("sparse array index"), + "out of bounds", + ); + + let mut document = serde_json::json!({"a": 1}); + let nested_scalar = parse_pointer("/a/b/c").expect("pointer"); + assert_runtime_error( + apply_set_general( + &mut document, + &nested_scalar, + Value::String("x".to_string()), + ) + .expect_err("non-container traversal"), + "pointer traverses through non-container", + ); + + let shallow_scalar = parse_pointer("/a/b").expect("pointer"); + assert_runtime_error( + apply_set_general( + &mut document, + &shallow_scalar, + Value::String("x".to_string()), + ) + .expect_err("scalar leaf set"), + "cannot set child pointer on a scalar value", + ); + } + + #[test] + fn delete_general_covers_root_missing_and_scalar_paths() { + let mut document = serde_json::json!({ + "arr": [10, 20], + "obj": {"keep": true}, + "scalar": "v" + }); + assert_usage_error( + apply_delete_general(&mut document, &[]).expect_err("root delete"), + "cannot target root pointer", + ); + assert!( + !apply_delete_general( + &mut document, + &parse_pointer("/missing/key").expect("missing parent") + ) + .expect("delete missing parent") + ); + assert!( + apply_delete_general( + &mut document, + &parse_pointer("/arr/1").expect("existing index") + ) + .expect("delete existing array item") + ); + assert_eq!(document["arr"], serde_json::json!([10])); + assert!( + !apply_delete_general( + &mut document, + &parse_pointer("/arr/9").expect("missing index") + ) + .expect("delete missing array index") + ); + assert_runtime_error( + apply_delete_general( + &mut document, + &parse_pointer("/scalar/child").expect("scalar child"), + ) + .expect_err("scalar delete"), + "cannot delete child pointer on a scalar value", + ); + } + + #[test] + fn env_and_ini_mutation_helpers_enforce_pointer_shapes_and_scalars() { + let mut env_doc = serde_json::json!({"KEEP": "yes"}); + assert!( + apply_set_env( + &mut env_doc, + &parse_pointer("/PORT").expect("env pointer"), + &Value::Number(serde_json::Number::from(8080)) + ) + .expect("set env key") + ); + assert_eq!(env_doc["PORT"], Value::String("8080".to_string())); + assert!( + !apply_delete_env( + &mut env_doc, + &parse_pointer("/MISSING").expect("env pointer") + ) + .expect("delete missing env key") + ); + assert_runtime_error( + apply_set_env( + &mut env_doc, + &parse_pointer("/A/B").expect("nested pointer"), + &Value::String("x".to_string()), + ) + .expect_err("nested env pointer"), + "only support root keys", + ); + assert_runtime_error( + apply_set_env( + &mut env_doc, + &parse_pointer("/BAD").expect("pointer"), + &serde_json::json!({"nested": true}), + ) + .expect_err("non-scalar env value"), + "require scalar string|number|bool", + ); + + let mut ini_doc = serde_json::json!({"mode": "dev", "server": {"port": "8080"}}); + assert!( + apply_set_ini( + &mut ini_doc, + &parse_pointer("/server/host").expect("ini pointer"), + &Value::Bool(true) + ) + .expect("set ini key") + ); + assert_eq!(ini_doc["server"]["host"], Value::String("true".to_string())); + assert_runtime_error( + apply_set_ini( + &mut ini_doc, + &parse_pointer("/").expect("root pointer"), + &Value::String("x".to_string()), + ) + .expect_err("root ini set"), + "only support /KEY or /SECTION/KEY pointers", + ); + assert_runtime_error( + apply_delete_ini( + &mut ini_doc, + &parse_pointer("/a/b/c").expect("deep pointer"), + ) + .expect_err("deep ini delete"), + "only support /KEY or /SECTION/KEY pointers", + ); + } + + #[test] + fn parse_set_value_and_dispatch_helpers_cover_error_paths() { + assert_usage_error( + parse_set_value("abc", ValueType::Number).expect_err("invalid number"), + "invalid number value", + ); + assert_usage_error( + parse_set_value("yes", ValueType::Bool).expect_err("invalid bool"), + "invalid bool value", + ); + assert_usage_error( + parse_set_value("none", ValueType::Null).expect_err("invalid null"), + "invalid null value", + ); + assert_usage_error( + parse_set_value("{", ValueType::Json).expect_err("invalid json"), + "invalid JSON value", + ); + assert_usage_error( + parse_auto_value("{bad").expect_err("invalid auto json"), + "invalid JSON value", + ); + + let mut document = serde_json::json!({}); + let segments = parse_pointer("/x").expect("pointer"); + assert_usage_error( + apply_set_by_format( + ConfigFormat::Auto, + &mut document, + &segments, + Value::String("x".to_string()), + ) + .expect_err("auto set"), + "auto format must be resolved before setting", + ); + assert_usage_error( + apply_delete_by_format(ConfigFormat::Auto, &mut document, &segments) + .expect_err("auto delete"), + "auto format must be resolved before deleting", + ); + assert_usage_error( + parse_value("{}", ConfigFormat::Auto).expect_err("auto parse"), + "auto format must be resolved", + ); + } + + #[test] + fn serialize_and_render_helpers_cover_ordering_and_empty_outputs() { + let env_text = serialize_env(&serde_json::json!({"B": "2", "A": 1, "C": true})) + .expect("serialize env"); + assert_eq!(env_text, "A=1\nB=2\nC=true\n"); + assert_runtime_error( + serialize_env(&serde_json::json!("not-object")).expect_err("env root"), + "requires an object root", + ); + assert_runtime_error( + serialize_env(&serde_json::json!({"BAD": {"nested": true}})).expect_err("env scalar"), + "require scalar string|number|bool", + ); + + let ini_text = + serialize_ini(&serde_json::json!({"z": "9", "a": "1", "sec": {"b": "2", "a": "1"}})) + .expect("serialize ini"); + assert_eq!(ini_text, "a=1\nz=9\n\n[sec]\na=1\nb=2\n"); + assert_runtime_error( + serialize_ini(&serde_json::json!({"sec": [1]})).expect_err("ini array"), + "does not support array values", + ); + assert_runtime_error( + serialize_ini(&serde_json::json!(1)).expect_err("ini root"), + "requires an object root", + ); + + let entries = list_entries(&serde_json::json!({"b": 1, "a/b~c": 2}), "/"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].key, "a/b~c"); + assert_eq!(entries[0].pointer, "/a~1b~0c"); + assert_eq!(entries[1].key, "b"); + assert!(list_entries(&Value::Bool(true), "/flag").is_empty()); + + let list_text = render_list_output(&ListOutput { + path: None, + format: ConfigFormat::Json, + pointer: "/flag".to_string(), + kind: ValueKind::Bool, + entries: Vec::new(), + }); + assert!(list_text.contains("entries=0")); + + let mutation_text = render_mutation_output(&MutationOutput { + path: "settings.json".to_string(), + format: ConfigFormat::Json, + operation: "delete".to_string(), + pointer: "/missing".to_string(), + changed: false, + previous: None, + value: None, + }); + assert!(mutation_text.contains("changed=false")); + assert!(mutation_text.contains("previous=-")); + assert!(mutation_text.contains("value=-")); + } + + #[test] + fn cli_parsing_covers_help_version_and_error_paths() { + let (outcome, _) = parse_cli_from(["config", "--help"]).expect("help"); + assert_eq!(outcome, ParseOutcome::Help); + + let (outcome, _) = parse_cli_from(["config", "--version"]).expect("version"); + assert_eq!(outcome, ParseOutcome::Version); + + let (_, cli) = + parse_cli_from(["config", "--color", "never", "--format", "json", "get", "/"]) + .expect("parse with color and format"); + assert_eq!(cli.format, ConfigFormat::Json); + + assert_usage_error( + parse_cli_from(["config", "--format"]).expect_err("missing --format value"), + "missing argument", + ); + assert_usage_error( + parse_cli_from(["config", "--bogus"]).expect_err("unsupported flag"), + "unsupported argument", + ); + assert_usage_error( + parse_cli_from(["config"]).expect_err("missing subcommand"), + "provide a subcommand", + ); + let (_, inspect_cli) = + parse_cli_from(["config", "inspect", "settings.ini"]).expect("inspect alias"); + assert!(matches!( + inspect_cli.command, + CommandMode::Ls { + path: Some(ref path), + ref pointer, + } if path == std::path::Path::new("settings.ini") && pointer == "/" + )); + let (_, default_ls_cli) = parse_cli_from(["config", "settings.ini"]).expect("implicit ls"); + assert!(matches!( + default_ls_cli.command, + CommandMode::Ls { + path: Some(ref path), + ref pointer, + } if path == std::path::Path::new("settings.ini") && pointer == "/" + )); + assert_usage_error( + parse_cli_from(["config", "--value-type", "string", "get", "/"]) + .expect_err("value type with get"), + "--value-type is only valid with 'set'", + ); + } + + #[test] + fn argument_shape_helpers_cover_read_set_and_delete_patterns() { + let (path, pointer) = parse_read_args(Vec::::new(), "get").expect("no args"); + assert!(path.is_none()); + assert_eq!(pointer, "/"); + + let (path, pointer) = + parse_read_args(vec![OsString::from("/pkg")], "get").expect("single pointer"); + assert!(path.is_none()); + assert_eq!(pointer, "/pkg"); + + let (path, pointer) = + parse_read_args(vec![OsString::from("config.json")], "get").expect("single path"); + assert_eq!(path, Some(PathBuf::from("config.json"))); + assert_eq!(pointer, "/"); + + let (path, pointer) = parse_read_args( + vec![OsString::from("config.json"), OsString::from("/pkg/name")], + "get", + ) + .expect("path and pointer"); + assert_eq!(path, Some(PathBuf::from("config.json"))); + assert_eq!(pointer, "/pkg/name"); + + assert_usage_error( + parse_read_args( + vec![ + OsString::from("a"), + OsString::from("b"), + OsString::from("c"), + ], + "get", + ) + .expect_err("too many get args"), + "get expects [PATH] [POINTER]", + ); + assert_usage_error( + parse_set_args(vec![OsString::from("only-path")]).expect_err("set shape"), + "set expects ", + ); + assert_usage_error( + parse_delete_args(vec![OsString::from("only-path")]).expect_err("delete shape"), + "delete expects ", + ); + assert_usage_error( + parse_command_mode("noop", Vec::new()).expect_err("unknown command"), + "unknown subcommand", + ); + } + + #[test] + fn run_get_and_ls_cover_dispatch_render_and_no_result_paths() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write( + &path, + "{\"mode\":\"dev\",\"items\":[1,2],\"pkg\":{\"name\":\"toolbox\"}}\n", + ) + .expect("fixture"); + + let mut cli = Cli { + common: CommonArgs::default(), + format: ConfigFormat::Auto, + value_type: ValueType::Auto, + command: CommandMode::Get { + path: Some(path.clone()), + pointer: "/pkg/name".to_string(), + }, + }; + cli.common.json = true; + assert_eq!(run(&cli).expect("get json"), ExitCode::Success); + + cli.common.json = false; + cli.command = CommandMode::Get { + path: Some(path.clone()), + pointer: "/mode".to_string(), + }; + assert_eq!(run(&cli).expect("get text"), ExitCode::Success); + + cli.command = CommandMode::Ls { + path: Some(path.clone()), + pointer: "/items".to_string(), + }; + assert_eq!(run(&cli).expect("ls array"), ExitCode::Success); + + cli.command = CommandMode::Ls { + path: Some(path.clone()), + pointer: "/mode".to_string(), + }; + assert_eq!(run(&cli).expect("ls scalar"), ExitCode::NoResults); + + cli.command = CommandMode::Get { + path: Some(path.clone()), + pointer: "/missing".to_string(), + }; + assert_runtime_error( + run(&cli).expect_err("missing get pointer"), + "try 'config ls /'", + ); + + cli.command = CommandMode::Ls { + path: Some(path), + pointer: "/missing".to_string(), + }; + assert_runtime_error( + run(&cli).expect_err("missing ls pointer"), + "nearby: /items, /mode, /pkg", + ); + } + + #[test] + fn missing_pointer_errors_suggest_discovery_and_nearby_valid_pointers() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write( + &path, + "{\"pkg\":{\"name\":\"toolbox\",\"version\":\"1.0.0\",\"authors\":[\"Ada\"]}}\n", + ) + .expect("fixture"); + + let cli = Cli { + common: CommonArgs::default(), + format: ConfigFormat::Auto, + value_type: ValueType::Auto, + command: CommandMode::Get { + path: Some(path), + pointer: "/pkg/versoin".to_string(), + }, + }; + + let error = run(&cli).expect_err("missing nested pointer"); + assert_eq!(error.exit_code(), ExitCode::RuntimeError); + match error { + CliError::Runtime(message) => { + assert!(message.contains("pointer not found: /pkg/versoin")); + assert!(message.contains("try 'config ls /pkg' (or 'config ls /')")); + assert!(message.contains("nearby: /pkg/authors, /pkg/name, /pkg/version")); + } + other @ CliError::Usage(_) => panic!("expected runtime error, got '{other}'"), + } + } + + #[test] + fn run_set_and_delete_cover_changed_unchanged_and_read_failures() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, "{\"mode\":\"dev\",\"nested\":{\"drop\":true}}\n").expect("fixture"); + + let mut cli_set = Cli { + common: CommonArgs::default(), + format: ConfigFormat::Auto, + value_type: ValueType::String, + command: CommandMode::Set { + path: path.clone(), + pointer: "/mode".to_string(), + raw_value: "prod".to_string(), + }, + }; + assert_eq!(run(&cli_set).expect("set changed"), ExitCode::Success); + assert_eq!(run(&cli_set).expect("set unchanged"), ExitCode::NoResults); + + let cli_delete = Cli { + common: CommonArgs::default(), + format: ConfigFormat::Auto, + value_type: ValueType::Auto, + command: CommandMode::Delete { + path: path.clone(), + pointer: "/nested/drop".to_string(), + }, + }; + assert_eq!(run(&cli_delete).expect("delete changed"), ExitCode::Success); + assert_eq!( + run(&Cli { + common: CommonArgs::default(), + format: ConfigFormat::Auto, + value_type: ValueType::Auto, + command: CommandMode::Delete { + path, + pointer: "/nested/drop".to_string(), + }, + }) + .expect("delete missing"), + ExitCode::NoResults + ); + + let missing = temp.path().join("missing.json"); + cli_set.command = CommandMode::Set { + path: missing.clone(), + pointer: "/mode".to_string(), + raw_value: "prod".to_string(), + }; + assert_runtime_error( + run(&cli_set).expect_err("set missing file"), + "failed to read", + ); + assert_runtime_error( + run(&Cli { + common: CommonArgs::default(), + format: ConfigFormat::Auto, + value_type: ValueType::Auto, + command: CommandMode::Delete { + path: missing, + pointer: "/mode".to_string(), + }, + }) + .expect_err("delete missing file"), + "failed to read", + ); + } + + #[test] + fn document_and_format_helpers_cover_resolution_and_error_paths() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.env"); + let second = temp.path().join("second.env"); + fs::write(&path, "KEY=value\n").expect("fixture"); + fs::write(&second, "OTHER=value\n").expect("second fixture"); + + let loaded = load_document(Some(&path), true).expect("load from path"); + assert_eq!(loaded.path, Some(path)); + assert_eq!(loaded.content, "KEY=value\n"); + + let missing = temp.path().join("missing.env"); + assert_runtime_error( + load_document(Some(&missing), true).expect_err("missing path"), + "failed to read", + ); + assert_usage_error( + load_document(None, true).expect_err("terminal stdin needs path"), + "provide a config path", + ); + let loaded_from_stdin = { + let buffer = format!("{}\n", second.display()); + let path = read_existing_stdin_paths(&buffer, InputFormat::Auto, "config") + .expect("stdin path detection") + .expect("path list") + .into_iter() + .next() + .expect("one path"); + InputDocument { + content: fs::read_to_string(&path).expect("stdin path content"), + path: Some(path), + } + }; + assert_eq!(loaded_from_stdin.content, "OTHER=value\n"); + assert!(matches!( + read_existing_stdin_paths( + &format!( + "{}\n{}\n", + temp.path().join("a").display(), + second.display() + ), + InputFormat::Auto, + "config", + ), + Err(common::CliError::Usage(message)) + if message.contains("stdin path line 1 does not exist") + )); + + assert_eq!( + resolve_format(None, ConfigFormat::Toml, "ignored").expect("requested format"), + ConfigFormat::Toml + ); + assert_eq!( + resolve_format(Some(Path::new("settings.dotenv")), ConfigFormat::Auto, "x") + .expect("dotenv extension"), + ConfigFormat::Env + ); + assert_eq!( + detect_format_from_content("title = \"\"\"\nhello\nworld\n\"\"\"\n") + .expect("toml detection"), + ConfigFormat::Toml + ); + assert_usage_error( + detect_format_from_content("\"unterminated").expect_err("detect error"), + "unable to detect config format", + ); + + assert!(looks_like_env("# c\n; c\nKEY=1\n")); + assert!(!looks_like_env("[sec]\nKEY=1\n")); + assert!(looks_like_ini("# c\n; c\n[sec]\nKEY=1\n")); + assert!(!looks_like_ini("plain-text\n")); + } + + #[test] + fn parse_and_serialize_cover_format_specific_success_and_failures() { + assert_eq!( + parse_value("KEY=value\n", ConfigFormat::Env).expect("parse env"), + serde_json::json!({"KEY": "value"}) + ); + assert_eq!( + parse_value("mode=dev\n", ConfigFormat::Ini).expect("parse ini"), + serde_json::json!({"mode": "dev"}) + ); + assert_runtime_error( + parse_value("{", ConfigFormat::Json).expect_err("invalid json"), + "failed to parse JSON", + ); + assert_runtime_error( + parse_value("[1,\n", ConfigFormat::Yaml).expect_err("invalid yaml"), + "failed to parse YAML", + ); + assert_runtime_error( + parse_value("arr = [1\n", ConfigFormat::Toml).expect_err("invalid toml"), + "failed to parse TOML", + ); + + let json_text = + serialize_value(&serde_json::json!({"mode": "dev"}), ConfigFormat::Json).expect("json"); + assert!(json_text.contains("\"mode\"")); + assert!(json_text.ends_with('\n')); + + let yaml_text = + serialize_value(&serde_json::json!({"mode": "dev"}), ConfigFormat::Yaml).expect("yaml"); + assert!(yaml_text.contains("mode: dev")); + + let toml_text = serialize_value( + &serde_json::json!({"package": {"name": "toolbox"}}), + ConfigFormat::Toml, + ) + .expect("toml"); + assert!(toml_text.contains("[package]")); + + let env_text = serialize_value(&serde_json::json!({"B": "2", "A": "1"}), ConfigFormat::Env) + .expect("env"); + assert_eq!(env_text, "A=1\nB=2\n"); + + let ini_text = serialize_value( + &serde_json::json!({"first": {"a": "1"}, "second": {"b": "2"}}), + ConfigFormat::Ini, + ) + .expect("ini"); + assert!(ini_text.contains("[first]\na=1\n\n[second]\nb=2\n")); + + assert_usage_error( + serialize_value(&serde_json::json!({}), ConfigFormat::Auto) + .expect_err("auto format unsupported"), + "auto format must be resolved", + ); + } + + #[test] + fn pointer_helpers_cover_resolution_and_container_defaults() { + let mut document = serde_json::json!([{"name": "toolbox"}]); + let pointer = parse_pointer("/0/name").expect("pointer"); + assert_eq!( + resolve_pointer(&document, &pointer), + Some(&Value::String("toolbox".to_string())) + ); + let slot = resolve_pointer_mut(&mut document, &pointer).expect("mutable pointer"); + *slot = Value::String("mercury".to_string()); + assert_eq!( + resolve_pointer(&document, &pointer), + Some(&Value::String("mercury".to_string())) + ); + assert!(resolve_pointer(&document, &parse_pointer("/x").expect("bad index")).is_none()); + assert!(resolve_pointer_mut(&mut Value::Bool(true), &[String::from("child")]).is_none()); + + let array_entries = list_entries(&serde_json::json!(["a", 2]), "/arr"); + assert_eq!(array_entries.len(), 2); + assert_eq!(array_entries[0].pointer, "/arr/0"); + assert_eq!(array_entries[1].pointer, "/arr/1"); + assert_eq!(join_pointer("/pkg", "a/b~c"), "/pkg/a~1b~0c"); + + let numeric_next = String::from("0"); + let object_next = String::from("key"); + assert_eq!( + default_container(Some(&numeric_next)), + Value::Array(Vec::new()) + ); + assert_eq!( + default_container(Some(&object_next)), + Value::Object(serde_json::Map::new()) + ); + } + + #[test] + fn container_mutation_helpers_report_array_scalar_and_env_errors() { + let mut array = serde_json::json!([1]); + assert!(!set_in_container(&mut array, "0", serde_json::json!(1)).expect("array noop")); + assert!(set_in_container(&mut array, "0", serde_json::json!(2)).expect("array replace")); + assert!(set_in_container(&mut array, "1", serde_json::json!(3)).expect("array append")); + assert_runtime_error( + set_in_container(&mut array, "3", serde_json::json!(9)).expect_err("array oob"), + "out of bounds", + ); + let mut scalar = Value::Bool(true); + assert_runtime_error( + set_in_container(&mut scalar, "0", serde_json::json!(1)).expect_err("scalar set"), + "cannot set child pointer", + ); + assert_runtime_error( + delete_from_container(&mut array, "x").expect_err("invalid index"), + "expected array index segment", + ); + + let mut env_doc = serde_json::json!({"KEEP": "1"}); + assert_runtime_error( + apply_delete_env( + &mut env_doc, + &parse_pointer("/A/B").expect("nested env pointer"), + ) + .expect_err("nested env delete"), + "only support root keys", + ); + let mut ini_doc = serde_json::json!({"mode": "dev"}); + assert!( + apply_delete_ini(&mut ini_doc, &parse_pointer("/mode").expect("ini pointer")) + .expect("valid ini delete") + ); + assert_eq!( + scalar_to_env_text_ref(&Value::String("ok".to_string())).expect("string scalar"), + "ok" + ); + } + + #[test] + fn value_kind_and_render_helpers_cover_output_shapes() { + let array_entries = list_entries(&serde_json::json!(["a", 2]), "/arr"); + + assert_eq!( + value_kind_name(kind_for_value(&serde_json::json!({}))), + "object" + ); + assert_eq!( + value_kind_name(kind_for_value(&serde_json::json!([]))), + "array" + ); + assert_eq!( + value_kind_name(kind_for_value(&serde_json::json!("s"))), + "string" + ); + assert_eq!( + value_kind_name(kind_for_value(&serde_json::json!(1))), + "number" + ); + assert_eq!( + value_kind_name(kind_for_value(&serde_json::json!(true))), + "bool" + ); + assert_eq!(value_kind_name(kind_for_value(&Value::Null)), "null"); + + let get_text = render_get_output(&GetOutput { + path: None, + format: ConfigFormat::Json, + pointer: "/mode".to_string(), + kind: ValueKind::String, + value: Value::String("dev".to_string()), + }); + assert!(get_text.contains("kind=string")); + + let list_text = render_list_output(&ListOutput { + path: None, + format: ConfigFormat::Json, + pointer: "/arr".to_string(), + kind: ValueKind::Array, + entries: array_entries, + }); + assert!(list_text.contains("key=0")); + assert!(list_text.contains("key=1")); + + let object_list_text = render_list_output(&ListOutput { + path: None, + format: ConfigFormat::Json, + pointer: "/".to_string(), + kind: ValueKind::Object, + entries: vec![ + ListEntry { + key: "app".to_string(), + pointer: "/app".to_string(), + kind: ValueKind::Object, + value: serde_json::json!({"name":"toolbox","logging":{"level":"debug"}}), + }, + ListEntry { + key: "targets".to_string(), + pointer: "/targets".to_string(), + kind: ValueKind::Array, + value: serde_json::json!(["stderr", "file"]), + }, + ], + }); + assert!(object_list_text.contains("value=object(keys=2)")); + assert!(object_list_text.contains("value=array(len=2)")); + } +} diff --git a/crates/config/src/main.rs b/crates/config/src/main.rs new file mode 100644 index 0000000..aaba5ff --- /dev/null +++ b/crates/config/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `config`. + +fn main() { + std::process::exit(config::main_entry()); +} diff --git a/crates/config/tests/config_cli.rs b/crates/config/tests/config_cli.rs new file mode 100644 index 0000000..f54be95 --- /dev/null +++ b/crates/config/tests/config_cli.rs @@ -0,0 +1,247 @@ +//! Integration tests for the `config` command. + +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::json; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("config").expect("binary") +} + +#[test] +fn no_args_prints_quick_help_card() { + let mut command = cargo_command(); + command + .assert() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "error: provide a subcommand: get, set, delete, or ls", + )) + .stderr(predicate::str::contains("config - Mercury Toolbox")) + .stderr(predicate::str::contains("Usage:")) + .stderr(predicate::str::contains("get [PATH] [POINTER]")) + .stderr(predicate::str::contains( + "config [OPTIONS] inspect [PATH] [POINTER]", + )) + .stderr(predicate::str::contains( + "Type 'config --help' for the full command reference.", + )); +} + +#[test] +fn get_reads_toml_pointer_as_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.toml"); + fs::write( + &path, + "[package]\nname = \"toolbox\"\nversion = \"3.0.0\"\n", + ) + .expect("fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("get") + .arg(&path) + .arg("/package/version") + .assert() + .success() + .stdout(predicate::str::contains("\"format\":\"toml\"")) + .stdout(predicate::str::contains("\"pointer\":\"/package/version\"")) + .stdout(predicate::str::contains("\"value\":\"3.0.0\"")); +} + +#[test] +fn set_then_get_updates_json_number_value() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, "{\"retries\":1}\n").expect("fixture"); + + let mut set = cargo_command(); + set.arg("set") + .arg(&path) + .arg("/retries") + .arg("5") + .arg("--json") + .arg("--value-type") + .arg("number") + .assert() + .success() + .stdout(predicate::str::contains("\"changed\":true")); + + let mut get = cargo_command(); + get.arg("--json") + .arg("get") + .arg(&path) + .arg("/retries") + .assert() + .success() + .stdout(predicate::str::contains("\"value\":5")); +} + +#[test] +fn delete_removes_env_key() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join(".env"); + fs::write(&path, "KEEP=yes\nDROP=no\n").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("delete") + .arg(&path) + .arg("/DROP") + .assert() + .success() + .stdout(predicate::str::contains("op=delete")); + + let content = fs::read_to_string(&path).expect("updated"); + assert!(content.contains("KEEP=yes")); + assert!(!content.contains("DROP=no")); +} + +#[test] +fn ls_lists_ini_section_entries() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("settings.ini"); + fs::write(&path, "mode=dev\n[server]\nport=8080\nhost=127.0.0.1\n").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("ls") + .arg(&path) + .arg("/server") + .assert() + .success() + .stdout(predicate::str::contains("\"key\":\"host\"")) + .stdout(predicate::str::contains("\"key\":\"port\"")); +} + +#[test] +fn ls_accepts_single_stdin_path_stream() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, "{\"mode\":\"dev\",\"nested\":{\"ok\":true}}\n").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("ls") + .write_stdin(format!("{}\n", path.display())) + .assert() + .success() + .stdout(predicate::str::contains("\"pointer\":\"/\"")) + .stdout(predicate::str::contains("\"key\":\"mode\"")) + .stdout(predicate::str::contains("\"key\":\"nested\"")); +} + +#[test] +fn help_includes_new_subcommands_and_flags() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("get [PATH] [POINTER]")) + .stdout(predicate::str::contains("set ")) + .stdout(predicate::str::contains("--value-type")) + .stdout(predicate::str::contains("--format")) + .stdout(predicate::str::contains("config")); +} + +#[test] +fn ls_on_scalar_pointer_returns_no_results_exit_code() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, "{\"mode\":\"dev\"}\n").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("ls") + .arg(&path) + .arg("/mode") + .assert() + .code(1) + .stdout(predicate::str::contains("entries=0")); +} + +#[test] +fn delete_missing_json_key_returns_no_results_and_preserves_file() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, "{\"present\":true}\n").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("delete") + .arg(&path) + .arg("/missing") + .assert() + .code(1) + .stdout(predicate::str::contains("\"changed\":false")) + .stdout(predicate::str::contains("\"pointer\":\"/missing\"")); + + let content = fs::read_to_string(&path).expect("updated"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json"); + assert_eq!(parsed, json!({"present": true})); +} + +#[test] +fn set_creates_nested_json_arrays_and_objects() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, "{}\n").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("set") + .arg(&path) + .arg("/pkg/deps/0/name") + .arg("serde") + .assert() + .success() + .stdout(predicate::str::contains("\"changed\":true")); + + let content = fs::read_to_string(&path).expect("updated"); + let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json"); + assert_eq!(parsed, json!({"pkg": {"deps": [{"name": "serde"}]}})); +} + +#[test] +fn env_set_rejects_nested_pointer_and_non_scalar_json_values() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join(".env"); + fs::write(&path, "KEEP=yes\n").expect("fixture"); + + let mut nested_pointer = cargo_command(); + nested_pointer + .arg("set") + .arg(&path) + .arg("/A/B") + .arg("x") + .assert() + .code(3) + .stderr(predicate::str::contains( + "env write operations only support root keys like /NAME", + )); + + let mut object_value = cargo_command(); + object_value + .arg("set") + .arg(&path) + .arg("/OBJ") + .arg("{\"nested\":true}") + .arg("--value-type") + .arg("json") + .assert() + .code(3) + .stderr(predicate::str::contains( + "env/ini writes require scalar string|number|bool values", + )); +} diff --git a/crates/configsupport/Cargo.toml b/crates/configsupport/Cargo.toml new file mode 100644 index 0000000..cfded89 --- /dev/null +++ b/crates/configsupport/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "configsupport" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared CLI helpers for standalone Mercury Toolbox config and repo commands." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/crates/configsupport/src/lib.rs b/crates/configsupport/src/lib.rs new file mode 100644 index 0000000..dbe7bca --- /dev/null +++ b/crates/configsupport/src/lib.rs @@ -0,0 +1,614 @@ +//! Shared CLI helpers for standalone Mercury Toolbox commands. + +use std::fmt::Display; +use std::io::{self, IsTerminal, Write}; + +use serde::Serialize; +use thiserror::Error; + +/// Controls ANSI color behavior for command output. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ColorChoice { + /// Enable color only when output looks interactive. + #[default] + Auto, + /// Never emit ANSI color sequences. + Never, +} + +/// Selects the output surface for a command invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + /// Emit compact text. + Text, + /// Emit compact JSON. + Json, + /// Emit compact TOON. + Toon, +} + +/// Shared flags that every standalone command accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CommonArgs { + /// Emit JSON instead of text. + pub json: bool, + /// Explicit structured output format selected by `--format`, `--json`, or `--toon`. + pub format: Option, + /// Suppress non-essential status output. + pub quiet: bool, + /// Control ANSI color output. + pub color: ColorChoice, +} + +impl CommonArgs { + /// Records an explicit output format selection. + pub fn set_render_mode(&mut self, render_mode: RenderMode) { + self.json = render_mode == RenderMode::Json; + self.format = Some(render_mode); + } + + /// Returns the output mode implied by the current settings. + #[must_use] + pub const fn render_mode(self) -> RenderMode { + if let Some(format) = self.format { + return format; + } + if self.json { + RenderMode::Json + } else { + RenderMode::Text + } + } + + /// Reports whether stdin is attached to an interactive terminal. + #[must_use] + pub fn stdin_is_terminal(&self) -> bool { + io::stdin().is_terminal() + } +} + +/// Stable process exit codes shared by the standalone commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitCode { + /// The command succeeded and produced at least one result. + Success = 0, + /// The command succeeded but produced no matching results. + NoResults = 1, + /// The invocation was rejected because the input was invalid. + UsageError = 2, + /// The command hit an operational failure at runtime. + RuntimeError = 3, +} + +impl ExitCode { + /// Converts the enum into a process exit code. + #[must_use] + pub const fn as_i32(self) -> i32 { + self as i32 + } +} + +/// Represents user-facing command failures. +#[derive(Debug, Error)] +pub enum CliError { + /// The user supplied invalid arguments or malformed input. + #[error("{0}")] + Usage(String), + /// The command failed while reading, probing, or rendering data. + #[error("{0}")] + Runtime(String), +} + +impl CliError { + /// Builds a usage error. + #[must_use] + pub fn usage(message: impl Into) -> Self { + Self::Usage(message.into()) + } + + /// Builds a runtime error. + #[must_use] + pub fn runtime(message: impl Into) -> Self { + Self::Runtime(message.into()) + } + + /// Returns the exit code associated with the error category. + #[must_use] + pub const fn exit_code(&self) -> ExitCode { + match self { + Self::Usage(_) => ExitCode::UsageError, + Self::Runtime(_) => ExitCode::RuntimeError, + } + } +} + +/// Parses the shared `--color` argument. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the value is not supported. +pub fn parse_color_choice(value: &str) -> Result { + match value { + "auto" => Ok(ColorChoice::Auto), + "never" => Ok(ColorChoice::Never), + other => Err(CliError::usage(format!( + "invalid --color value '{other}'; expected auto or never" + ))), + } +} + +/// Parses the shared `--format` output argument. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the value is not supported. +pub fn parse_format_choice(value: &str) -> Result { + match value { + "text" => Ok(RenderMode::Text), + "json" => Ok(RenderMode::Json), + "toon" => Ok(RenderMode::Toon), + other => Err(CliError::usage(format!( + "invalid --format value '{other}'; expected text, json, or toon" + ))), + } +} + +/// Writes a JSON value to stdout. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when serialization or stdout writes fail. +pub fn print_json(value: &T) -> Result<(), CliError> +where + T: Serialize, +{ + let mut stdout = io::stdout().lock(); + if let Err(error) = serde_json::to_writer(&mut stdout, value) { + return match error.io_error_kind() { + Some(io::ErrorKind::BrokenPipe) => Ok(()), + _ => Err(CliError::runtime(format!( + "failed to write stdout: {error}" + ))), + }; + } + stdout_write_result(stdout.write_all(b"\n")) +} + +/// Writes a structured value to stdout as JSON or TOON. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when serialization or stdout writes fail. +pub fn print_structured(value: &T, render_mode: RenderMode) -> Result<(), CliError> +where + T: Serialize, +{ + match render_mode { + RenderMode::Json => print_json(value), + RenderMode::Toon => { + let value = serde_json::to_value(value).map_err(|error| { + CliError::runtime(format!("failed to serialize structured output: {error}")) + })?; + let mut stdout = io::stdout().lock(); + write_toon_value(&mut stdout, &value, 0)?; + stdout_write_result(stdout.write_all(b"\n")) + } + RenderMode::Text => Err(CliError::runtime( + "structured text rendering requires command-specific text output", + )), + } +} + +fn write_toon_value( + writer: &mut impl Write, + value: &serde_json::Value, + depth: usize, +) -> Result<(), CliError> { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + stdout_write_result(writeln!(writer, "{}{}:", " ".repeat(depth), key))?; + write_toon_value(writer, value, depth + 1)?; + } + primitive => { + stdout_write_result(writeln!( + writer, + "{}{}: {}", + " ".repeat(depth), + key, + toon_primitive(primitive) + ))?; + } + } + } + } + serde_json::Value::Array(array) => { + for value in array { + stdout_write_result(writeln!( + writer, + "{}- {}", + " ".repeat(depth), + toon_primitive(value) + ))?; + } + } + primitive => { + stdout_write_result(write!(writer, "{}", toon_primitive(primitive)))?; + } + } + Ok(()) +} + +fn stdout_write_result(result: io::Result<()>) -> Result<(), CliError> { + match result { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()), + Err(error) => Err(CliError::runtime(format!( + "failed to write stdout: {error}" + ))), + } +} + +fn toon_primitive(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => "{}".to_string(), + } +} + +/// Writes a single line of text to stdout. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when stdout cannot be written. +pub fn print_text(text: impl Display) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + stdout_write_result(writeln!(stdout, "{text}")) +} + +/// Writes a formatted error message to stderr. +pub fn print_error(error: &CliError) { + let _ = writeln!(io::stderr().lock(), "{error}"); +} + +/// Writes a compact first-page help card for usage failures. +pub fn print_quick_help_error(error: &CliError, help: &str) { + let use_color = stderr_supports_color(); + let mut stderr = io::stderr().lock(); + if use_color { + let _ = writeln!(stderr, "\x1b[31;1merror:\x1b[0m {error}"); + } else { + let _ = writeln!(stderr, "error: {error}"); + } + let _ = writeln!(stderr); + let _ = write_quick_help(&mut stderr, help, use_color); +} + +fn stderr_supports_color() -> bool { + io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +fn write_quick_help(mut writer: impl Write, help: &str, use_color: bool) -> io::Result<()> { + let summary = first_help_line(help).unwrap_or("Mercury Toolbox command"); + let command = quick_help_command(help).unwrap_or("mercury"); + let title = format!("{command} - Mercury Toolbox"); + + write_quick_heading(&mut writer, &title, use_color)?; + writeln!(writer, " {summary}")?; + writeln!(writer)?; + + write_named_section( + &mut writer, + help, + "Usage:", + &[ + "Commands:", + "Subcommands:", + "Options:", + "Shared Options:", + "Examples:", + ], + "Usage:", + 5, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Commands:", + &["Options:", "Shared Options:", "Examples:"], + "Commands:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Subcommands:", + &["Options:", "Shared Options:", "Examples:"], + "Commands:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Options:", + &["Commands:", "Subcommands:", "Examples:"], + "Common options:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Shared Options:", + &[ + "Commands:", + "Subcommands:", + "Find Options:", + "Body Options:", + "Examples:", + ], + "Common options:", + 8, + use_color, + )?; + write_named_section( + &mut writer, + help, + "Examples:", + &[], + "Examples:", + 3, + use_color, + )?; + + if use_color { + writeln!( + writer, + "Type \x1b[1m{command} --help\x1b[0m for the full command reference." + ) + } else { + writeln!( + writer, + "Type '{command} --help' for the full command reference." + ) + } +} + +fn first_help_line(help: &str) -> Option<&str> { + help.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn quick_help_command(help: &str) -> Option<&str> { + help_section( + help, + "Usage:", + &[ + "Commands:", + "Subcommands:", + "Options:", + "Shared Options:", + "Examples:", + ], + ) + .and_then(|lines| lines.into_iter().find_map(first_usage_token)) +} + +fn first_usage_token(line: &str) -> Option<&str> { + line.split_whitespace() + .next() + .filter(|token| token.chars().any(char::is_alphanumeric)) +} + +fn write_quick_heading(writer: &mut impl Write, heading: &str, use_color: bool) -> io::Result<()> { + if use_color { + writeln!(writer, "\x1b[1;36m{heading}\x1b[0m") + } else { + writeln!(writer, "{heading}") + } +} + +fn write_named_section( + writer: &mut impl Write, + help: &str, + source_heading: &str, + stop_headings: &[&str], + display_heading: &str, + limit: usize, + use_color: bool, +) -> io::Result<()> { + if let Some(lines) = help_section(help, source_heading, stop_headings) { + write_quick_heading(writer, display_heading, use_color)?; + write_limited_section(writer, lines, limit)?; + writeln!(writer)?; + } + Ok(()) +} + +fn help_section<'a>(help: &'a str, heading: &str, stop_headings: &[&str]) -> Option> { + let mut lines = help.lines(); + for line in lines.by_ref() { + if line.trim() == heading { + let mut section = Vec::new(); + for candidate in lines { + let trimmed = candidate.trim(); + if stop_headings.contains(&trimmed) || is_top_level_help_heading(candidate, trimmed) + { + break; + } + if !trimmed.is_empty() { + section.push(candidate); + } + } + return Some(section); + } + } + None +} + +fn is_top_level_help_heading(raw: &str, trimmed: &str) -> bool { + !trimmed.is_empty() && raw == trimmed && trimmed.ends_with(':') +} + +fn write_limited_section( + writer: &mut impl Write, + lines: Vec<&str>, + limit: usize, +) -> io::Result<()> { + for line in lines.into_iter().take(limit) { + writeln!(writer, "{line}")?; + } + Ok(()) +} + +/// Maps a result count to the shared exit code contract. +#[must_use] +pub const fn map_result_count(count: usize) -> ExitCode { + if count == 0 { + ExitCode::NoResults + } else { + ExitCode::Success + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn cli_error_exit_codes_match_categories() { + assert_eq!(CliError::usage("bad").exit_code(), ExitCode::UsageError); + assert_eq!(CliError::runtime("bad").exit_code(), ExitCode::RuntimeError); + } + + #[test] + fn print_helpers_succeed() { + assert!(print_json(&json!({"ok": true})).is_ok()); + assert!(print_text("ok").is_ok()); + } + + #[test] + fn stdout_write_errors_ignore_broken_pipe_only() { + assert!( + stdout_write_result(Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))).is_ok() + ); + assert!(matches!( + stdout_write_result(Err(io::Error::other("disk"))), + Err(CliError::Runtime(message)) if message.contains("failed to write stdout: disk") + )); + } + + #[test] + fn common_args_render_mode_and_result_mapping_are_stable() { + assert_eq!(CommonArgs::default().render_mode(), RenderMode::Text); + assert_eq!( + CommonArgs { + json: true, + format: None, + quiet: false, + color: ColorChoice::Auto, + } + .render_mode(), + RenderMode::Json + ); + assert_eq!(map_result_count(0), ExitCode::NoResults); + assert_eq!(map_result_count(3), ExitCode::Success); + } + + #[test] + fn parse_color_choice_accepts_known_values_and_rejects_unknowns() { + assert_eq!(parse_color_choice("auto").expect("auto"), ColorChoice::Auto); + assert_eq!( + parse_color_choice("never").expect("never"), + ColorChoice::Never + ); + assert!(matches!( + parse_color_choice("always"), + Err(CliError::Usage(message)) + if message.contains("invalid --color value 'always'") + )); + } + + #[test] + fn format_choice_and_toon_writer_cover_nested_values() { + assert_eq!(parse_format_choice("text").expect("text"), RenderMode::Text); + assert_eq!(parse_format_choice("json").expect("json"), RenderMode::Json); + assert_eq!(parse_format_choice("toon").expect("toon"), RenderMode::Toon); + assert!(parse_format_choice("yaml").is_err()); + + let mut output = Vec::new(); + write_toon_value( + &mut output, + &json!({ + "meta": {"ok": true, "count": 2}, + "items": ["a", {"nested": true}], + "none": null + }), + 0, + ) + .expect("toon writer"); + let rendered = String::from_utf8(output).expect("utf8"); + assert!(rendered.contains("meta:\n")); + assert!(rendered.contains(" ok: true\n")); + assert!(rendered.contains(" count: 2\n")); + assert!(rendered.contains("items:\n - a\n - {}\n")); + assert!(rendered.contains("none: null\n")); + + assert_eq!(toon_primitive(&json!("plain")), "plain"); + assert_eq!(toon_primitive(&json!({ "nested": true })), "{}"); + } + + #[test] + fn quick_help_extracts_sections_and_limits_output() { + let help = "\ +Example command. + +Usage: + example [OPTIONS] + +Commands: + run + inspect + +Options: + --json + --toon + --verbose + +Examples: + example README.md + example --json config.json +"; + assert_eq!(first_help_line(help), Some("Example command.")); + assert_eq!(quick_help_command(help), Some("example")); + assert_eq!( + help_section(help, "Commands:", &["Options:"]).expect("commands"), + vec![" run", " inspect"] + ); + assert!(is_top_level_help_heading("Options:", "Options:")); + assert!(!is_top_level_help_heading(" --json", "--json")); + + let mut output = Vec::new(); + write_quick_help(&mut output, help, false).expect("plain quick help"); + let rendered = String::from_utf8(output).expect("utf8"); + assert!(rendered.contains("example - Mercury Toolbox")); + assert!(rendered.contains("Common options:")); + assert!(rendered.contains("Type 'example --help'")); + + let mut colored = Vec::new(); + write_quick_heading(&mut colored, "Title", true).expect("colored heading"); + assert!( + String::from_utf8(colored) + .expect("utf8") + .contains("\x1b[1;36m") + ); + } +} diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml new file mode 100644 index 0000000..f27051b --- /dev/null +++ b/crates/context/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "context" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Pack files, hits, snippets, and definitions into compact context blocks." +keywords.workspace = true +categories.workspace = true +autobins = false + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true + +[[bin]] +name = "ctxpack" +path = "src/main.rs" diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs new file mode 100644 index 0000000..5a7bb1e --- /dev/null +++ b/crates/context/src/lib.rs @@ -0,0 +1,1685 @@ +//! The `ctxpack` command packs mixed inputs into compact context blocks. + +use codeindex::{CodeIndexer, detect_language}; +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const KIND_DEFINITION: &str = "definition"; +const KIND_CODESHAPE: &str = "codeshape"; +const KIND_FILE: &str = "file"; +const KIND_SNIPPET: &str = "snippet"; +const HEADER_BLOCK: &str = "block"; +const HEADER_FILE: &str = "file"; +const HEADER_SNIPPET: &str = "snippet"; +const PATH_STDIN: &str = ""; +use std::collections::BTreeSet; +use std::env; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, BufRead, Read}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +const HELP: &str = "\ +Pack files, hits, snippets, and definition records into compact context blocks. + +Usage: + ctxpack [OPTIONS] [INPUT...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --max-blocks Maximum number of blocks to emit + --max-lines Maximum number of lines per block + --dedupe Collapse duplicate blocks + --sort Block order: input, path, recent + -h, --help Show this help text + -V, --version Show the command version + +Examples: + ctxpack .\\fixtures\\polyglot\\repo\\src\\lib.rs:26 + refs helper .\\fixtures\\polyglot\\repo --json | ctxpack --input-format auto + rg -nH \"helper\" .\\fixtures\\polyglot\\repo\\src\\lib.rs | hitsnip --def --json | ctxpack --input-format auto + diagpick .\\fixtures\\diag\\rust-errors.txt --json | ctxpack --input-format auto --json | ConvertFrom-Json + '.\\fixtures\\polyglot\\repo\\src\\lib.rs' | ctxpack --json | ConvertFrom-Json + +Notes: + JSON input may be a direct block record or a wrapper object with arrays such as: + items, records, diagnostics, hits, blocks, files, source, snippet, or enclosing_definition + Wrapper arrays expand in-place; scalar source/snippet/definition records become one block each + Definition records dedupe by source location automatically; --dedupe also collapses identical non-definition blocks + When JSON records contain both snippet and definition payloads, ctxpack prefers the definition block +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + inputs: Vec, + max_blocks: usize, + max_lines: usize, + dedupe: bool, + sort_mode: SortMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SortMode { + Input, + Path, + Recent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ContextBlock { + path: String, + kind: String, + header: String, + start_line: Option, + end_line: Option, + truncated: bool, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InputItem { + Text(String), + Json(Value), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LineHit { + path: String, + line: usize, + column: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("ctxpack {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + inputs: Vec::new(), + max_blocks: 32, + max_lines: 40, + dedupe: false, + sort_mode: SortMode::Input, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("max-blocks") => { + cli.max_blocks = parse_positive_usize_flag( + "--max-blocks", + &parser_value_string(&mut parser, "--max-blocks")?, + )?; + } + Long("max-lines") => { + cli.max_lines = parse_positive_usize_flag( + "--max-lines", + &parser_value_string(&mut parser, "--max-lines")?, + )?; + } + Long("dedupe") => cli.dedupe = true, + Long("sort") => { + cli.sort_mode = parse_sort_mode(&parser_value_string(&mut parser, "--sort")?)?; + } + ArgValue(value) => cli.inputs.push(os_value_string(value, "input")?), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn run(cli: &Cli) -> Result { + let items = load_inputs(cli)?; + if items.is_empty() { + return Err(CliError::usage( + "provide at least one input or pipe inputs into stdin", + )); + } + + let mut indexer = CodeIndexer::new(); + let mut blocks = Vec::new(); + for item in items { + blocks.extend(materialize_blocks(cli, &item, &mut indexer)?); + } + + blocks = dedupe_definition_blocks(blocks); + if cli.dedupe { + blocks = dedupe_blocks(blocks); + } + sort_blocks(&mut blocks, cli.sort_mode); + if blocks.len() > cli.max_blocks { + blocks.truncate(cli.max_blocks); + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&blocks)?, + RenderMode::Toon => print_structured(&blocks, RenderMode::Toon)?, + RenderMode::Text => { + if blocks.is_empty() { + if !cli.common.quiet { + println!("0 context blocks"); + } + } else { + print!("{}", render_blocks(&blocks)); + } + } + } + + Ok(if blocks.is_empty() { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn load_inputs(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.inputs.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let items = parse_input_items(&buffer, cli.common.input_format)?; + if !items.is_empty() { + return Ok(items); + } + } + + Ok(cli + .inputs + .iter() + .cloned() + .map(InputItem::Text) + .collect::>()) +} + +fn parse_input_items(buffer: &str, input_format: InputFormat) -> Result, CliError> { + let trimmed = buffer.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + if input_format != InputFormat::Lines + && matches!(trimmed.as_bytes().first(), Some(b'[' | b'{')) + && let Ok(value) = serde_json::from_str::(trimmed) + { + return expand_json_value(value); + } + + let mut items = Vec::new(); + for (index, line) in io::Cursor::new(buffer).lines().enumerate() { + let raw = + line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + match input_format { + InputFormat::Lines => items.push(InputItem::Text(trimmed.to_string())), + InputFormat::Jsonl => { + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::runtime(format!( + "invalid JSONL input at line {}: {error}", + index + 1 + )) + })?; + items.extend(expand_json_value(value)?); + } + InputFormat::Auto => match serde_json::from_str::(trimmed) { + Ok(value) => items.extend(expand_json_value(value)?), + Err(_) => items.push(InputItem::Text(trimmed.to_string())), + }, + } + } + + Ok(items) +} + +fn expand_json_value(value: Value) -> Result, CliError> { + match value { + Value::Array(items) => Ok(items.into_iter().map(InputItem::Json).collect()), + Value::Object(object) => array_field_items(&object)? + .map_or_else(|| Ok(vec![InputItem::Json(Value::Object(object))]), Ok), + _ => Err(CliError::runtime( + "JSON input must be an object or array of objects", + )), + } +} + +fn array_field_items( + object: &serde_json::Map, +) -> Result>, CliError> { + for key in ["items", "records", "diagnostics", "blocks", "hits", "files"] { + let Some(candidate) = object.get(key) else { + continue; + }; + if candidate.is_null() { + continue; + } + let Value::Array(items) = candidate else { + return Err(CliError::runtime(format!( + "context JSON field '{key}' must be an array when present" + ))); + }; + return Ok(Some(items.iter().cloned().map(InputItem::Json).collect())); + } + Ok(None) +} + +fn materialize_blocks( + cli: &Cli, + item: &InputItem, + indexer: &mut CodeIndexer, +) -> Result, CliError> { + match item { + InputItem::Text(value) => materialize_text_input(value, cli.max_lines, indexer), + InputItem::Json(value) => materialize_json_value(value, cli.max_lines, indexer), + } +} + +fn materialize_text_input( + value: &str, + max_lines: usize, + indexer: &mut CodeIndexer, +) -> Result, CliError> { + let path = PathBuf::from(value); + if path.exists() && path.is_file() { + return Ok(vec![block_from_file(&path, max_lines)?]); + } + + if let Ok(hit) = parse_text_hit(value) { + return Ok(vec![block_from_hit(&hit, max_lines, indexer)?]); + } + + Err(CliError::runtime(format!( + "unsupported context input: {value}" + ))) +} + +fn materialize_json_value( + value: &Value, + max_lines: usize, + indexer: &mut CodeIndexer, +) -> Result, CliError> { + let Value::Object(object) = value else { + return Err(CliError::runtime( + "context JSON input must be an object or array of objects", + )); + }; + Ok(decorate_block_headers( + materialize_json_object(object, value, max_lines, indexer)?, + diagnostic_label(object), + )) +} + +fn materialize_json_object( + object: &serde_json::Map, + value: &Value, + max_lines: usize, + indexer: &mut CodeIndexer, +) -> Result, CliError> { + if let Some(definition) = object + .get("definition") + .filter(|value| !value.is_null()) + .or_else(|| { + object + .get("enclosing_definition") + .filter(|value| !value.is_null()) + }) + { + return materialize_json_value(definition, max_lines, indexer); + } + if let Some(snippet) = object + .get("snippet") + .or_else(|| object.get("source")) + .filter(|value| !value.is_null()) + { + return Ok(vec![block_from_source_object( + snippet, + object.get("path").and_then(Value::as_str), + max_lines, + )?]); + } + if object.get("text").is_some() && object.get("path").is_some() { + return Ok(vec![block_from_text_object(object, max_lines)?]); + } + if object.get("items").is_some() && object.get("path").is_some() { + return Ok(vec![block_from_codeshape_file_object(object, max_lines)?]); + } + if object.get("lines").is_some() && object.get("path").is_some() { + return Ok(vec![block_from_source_object(value, None, max_lines)?]); + } + if let (Some(path), Some(line)) = ( + object.get("path").and_then(Value::as_str), + object.get("line").and_then(Value::as_u64), + ) { + return Ok(vec![block_from_hit( + &LineHit { + path: path.to_string(), + line: usize::try_from(line).map_err(|error| { + CliError::runtime(format!("invalid JSON line number for context: {error}")) + })?, + column: object + .get("column") + .and_then(Value::as_u64) + .map(usize::try_from) + .transpose() + .map_err(|error| { + CliError::runtime(format!( + "invalid JSON column number for context: {error}" + )) + })?, + }, + max_lines, + indexer, + )?]); + } + if let (Some(path), Some(start_line), Some(end_line)) = ( + object.get("path").and_then(Value::as_str), + object.get("start_line").and_then(Value::as_u64), + object.get("end_line").and_then(Value::as_u64), + ) { + return Ok(vec![block_from_ranged_file_object( + path, + usize::try_from(start_line).map_err(|error| { + CliError::runtime(format!("invalid JSON start_line for context: {error}")) + })?, + usize::try_from(end_line).map_err(|error| { + CliError::runtime(format!("invalid JSON end_line for context: {error}")) + })?, + object, + max_lines, + )?]); + } + if let Some(path) = object.get("path").and_then(Value::as_str) { + let file = PathBuf::from(path); + if file.exists() && file.is_file() { + return Ok(vec![block_from_file(&file, max_lines)?]); + } + } + + Err(CliError::runtime( + "unsupported JSON shape for context input", + )) +} + +fn block_from_codeshape_file_object( + object: &serde_json::Map, + max_lines: usize, +) -> Result { + let path = object + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| CliError::runtime("codeshape file object is missing a path"))?; + let items = object + .get("items") + .and_then(Value::as_array) + .ok_or_else(|| CliError::runtime("codeshape file object is missing an items array"))?; + let language = object + .get("language") + .and_then(Value::as_str) + .unwrap_or("unknown"); + + let rendered_lines = items + .iter() + .map(render_codeshape_item_line) + .collect::>(); + let start_line = items + .first() + .and_then(|item| item.get("start_line")) + .map(|value| optional_usize_value(value, "codeshape start_line")) + .transpose()? + .flatten(); + let end_line = items + .last() + .and_then(|item| item.get("end_line")) + .map(|value| optional_usize_value(value, "codeshape end_line")) + .transpose()? + .flatten(); + + Ok(build_block( + path.to_string(), + KIND_CODESHAPE.to_owned(), + format!("codeshape {language}"), + start_line, + end_line, + &rendered_lines.join("\n"), + max_lines, + )) +} + +fn render_codeshape_item_line(item: &Value) -> String { + let depth = item + .get("depth") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + let indent = " ".repeat(depth.saturating_sub(1)); + let kind = item.get("kind").and_then(Value::as_str).unwrap_or("item"); + let name = item + .get("qualified_name") + .and_then(Value::as_str) + .or_else(|| item.get("name").and_then(Value::as_str)) + .unwrap_or("?"); + let signature = item.get("signature").and_then(Value::as_str).unwrap_or(""); + let start_line = item.get("start_line").and_then(Value::as_u64); + + let mut line = format!("{indent}{kind} {name}"); + if !signature.is_empty() && signature != name { + let _ = write!(line, " signature={signature}"); + } + if let Some(start_line) = start_line { + let _ = write!(line, " line={start_line}"); + } + line +} + +fn optional_usize_value(value: &Value, label: &str) -> Result, CliError> { + value + .as_u64() + .map(usize::try_from) + .transpose() + .map_err(|error| CliError::runtime(format!("invalid {label}: {error}"))) +} + +fn block_from_file(path: &Path, max_lines: usize) -> Result { + let source = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + Ok(build_block( + path.display().to_string(), + KIND_FILE.to_owned(), + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or(HEADER_FILE) + .to_owned(), + Some(1), + source.lines().count().checked_sub(0), + &source, + max_lines, + )) +} + +fn block_from_hit( + hit: &LineHit, + max_lines: usize, + indexer: &mut CodeIndexer, +) -> Result { + let path = PathBuf::from(&hit.path); + let source = fs::read_to_string(&path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + if detect_language(&path).is_some() + && let Some(symbol) = indexer.find_enclosing_symbol(&path, &source, hit.line, hit.column)? + { + return Ok(build_block( + symbol.path, + KIND_DEFINITION.to_owned(), + symbol.qualified_name, + Some(symbol.start_line), + Some(symbol.end_line), + &symbol.text, + max_lines, + )); + } + + Ok(build_snippet_block(&hit.path, &source, hit.line, max_lines)) +} + +fn block_from_source_object( + value: &Value, + path_override: Option<&str>, + max_lines: usize, +) -> Result { + let Value::Object(object) = value else { + return Err(CliError::runtime("invalid source snippet object")); + }; + let path = path_override + .or_else(|| object.get("path").and_then(Value::as_str)) + .unwrap_or(PATH_STDIN) + .to_string(); + let start_line = object + .get("start_line") + .map(|value| optional_usize_value(value, "start_line for context")) + .transpose()? + .flatten(); + let end_line = object + .get("end_line") + .map(|value| optional_usize_value(value, "end_line for context")) + .transpose()? + .flatten(); + let lines = object + .get("lines") + .and_then(Value::as_array) + .ok_or_else(|| CliError::runtime("source snippet is missing a lines array"))?; + let mut text = String::new(); + for line in lines { + let line_text = line + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| CliError::runtime("source snippet line is missing text"))?; + if !text.is_empty() { + text.push('\n'); + } + text.push_str(line_text); + } + Ok(build_block( + path, + KIND_SNIPPET.to_owned(), + HEADER_SNIPPET.to_owned(), + start_line, + end_line, + &text, + max_lines, + )) +} + +fn block_from_text_object( + object: &serde_json::Map, + max_lines: usize, +) -> Result { + let path = object + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| CliError::runtime("context text object is missing path"))?; + let text = object + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| CliError::runtime("context text object is missing text"))?; + let header = object + .get("qualified_name") + .or_else(|| object.get("signature")) + .or_else(|| object.get("name")) + .and_then(Value::as_str) + .unwrap_or(HEADER_BLOCK); + let start_line = object + .get("start_line") + .map(|value| optional_usize_value(value, "start_line for context")) + .transpose()? + .flatten(); + let end_line = object + .get("end_line") + .map(|value| optional_usize_value(value, "end_line for context")) + .transpose()? + .flatten(); + Ok(build_block( + path.to_string(), + KIND_DEFINITION.to_owned(), + header.to_string(), + start_line, + end_line, + text, + max_lines, + )) +} + +fn block_from_ranged_file_object( + path: &str, + start_line: usize, + end_line: usize, + object: &serde_json::Map, + max_lines: usize, +) -> Result { + let file = PathBuf::from(path); + let source = fs::read_to_string(&file).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", file.display())) + })?; + let lines = source.lines().collect::>(); + let start_index = start_line.saturating_sub(1).min(lines.len()); + let end_index = end_line.min(lines.len()); + let text = if start_index < end_index { + lines[start_index..end_index].join("\n") + } else { + String::new() + }; + let header = object + .get("qualified_name") + .or_else(|| object.get("signature")) + .or_else(|| object.get("name")) + .and_then(Value::as_str) + .unwrap_or(KIND_DEFINITION); + Ok(build_block( + path.to_string(), + KIND_DEFINITION.to_owned(), + header.to_string(), + Some(start_line), + Some(end_line), + &text, + max_lines, + )) +} + +fn build_snippet_block(path: &str, source: &str, line: usize, max_lines: usize) -> ContextBlock { + let lines = source.lines().collect::>(); + if lines.is_empty() { + return build_block( + path.to_string(), + KIND_SNIPPET.to_owned(), + format!("line {line} (past EOF, empty file)"), + Some(0), + Some(0), + "", + max_lines, + ); + } + let effective_line = line.clamp(1, lines.len()); + let (start_line, end_line) = if line > lines.len() { + (effective_line, effective_line) + } else { + let radius = max_lines.saturating_sub(1) / 2; + let start_line = effective_line.saturating_sub(radius).max(1); + let end_line = (start_line + max_lines.saturating_sub(1)).min(lines.len()); + (start_line, end_line) + }; + let text = lines + .get(start_line.saturating_sub(1)..end_line) + .unwrap_or_default() + .join("\n"); + let header = if effective_line == line { + format!("line {line}") + } else { + format!("line {line} (past EOF, clamped to {effective_line})") + }; + build_block( + path.to_string(), + KIND_SNIPPET.to_owned(), + header, + Some(start_line), + Some(end_line), + &text, + max_lines, + ) +} + +fn build_block( + path: String, + kind: String, + header: String, + start_line: Option, + end_line: Option, + text: &str, + max_lines: usize, +) -> ContextBlock { + let line_budget = if kind == KIND_DEFINITION { + max_lines.saturating_mul(3).max(24) + } else { + max_lines + }; + let (rendered_lines, rendered_line_count, truncated) = render_limited_lines(text, line_budget); + let actual_start = start_line; + let actual_end = actual_start + .map(|start| start + rendered_line_count.saturating_sub(1)) + .or(end_line); + ContextBlock { + path, + kind, + header, + start_line: actual_start, + end_line: actual_end, + truncated, + text: rendered_lines, + } +} + +fn render_limited_lines(text: &str, line_budget: usize) -> (String, usize, bool) { + let mut rendered = String::new(); + let mut rendered_count = 0usize; + + for (index, line) in text.lines().enumerate() { + if index >= line_budget { + return (rendered, rendered_count, true); + } + if index > 0 { + rendered.push('\n'); + } + rendered.push_str(line); + rendered_count += 1; + } + + (rendered, rendered_count, false) +} + +fn dedupe_blocks(blocks: Vec) -> Vec { + let mut seen = BTreeSet::<(String, String, Option, Option, String)>::new(); + let mut deduped = Vec::new(); + for block in blocks { + let key = ( + block.path.clone(), + block.kind.clone(), + block.start_line, + block.end_line, + block.text.clone(), + ); + if seen.insert(key) { + deduped.push(block); + } + } + deduped +} + +fn dedupe_definition_blocks(blocks: Vec) -> Vec { + let mut seen = BTreeSet::<(String, Option, Option, String)>::new(); + let mut deduped = Vec::with_capacity(blocks.len()); + for block in blocks { + if block.kind == "definition" { + let key = ( + block.path.clone(), + block.start_line, + block.end_line, + block.text.clone(), + ); + if seen.insert(key) { + deduped.push(block); + } + continue; + } + deduped.push(block); + } + deduped +} + +fn sort_blocks(blocks: &mut [ContextBlock], sort_mode: SortMode) { + match sort_mode { + SortMode::Input => {} + SortMode::Path => { + blocks.sort_by(|left, right| { + ( + left.path.as_str(), + left.start_line.unwrap_or(usize::MAX), + left.end_line.unwrap_or(usize::MAX), + left.header.as_str(), + ) + .cmp(&( + right.path.as_str(), + right.start_line.unwrap_or(usize::MAX), + right.end_line.unwrap_or(usize::MAX), + right.header.as_str(), + )) + }); + } + SortMode::Recent => { + blocks.sort_by(|left, right| recent_sort_key(right).cmp(&recent_sort_key(left))); + } + } +} + +fn recent_sort_key(block: &ContextBlock) -> (Option, &str, Option) { + let modified = fs::metadata(&block.path) + .and_then(|metadata| metadata.modified()) + .ok(); + (modified, block.path.as_str(), block.start_line) +} + +fn render_blocks(blocks: &[ContextBlock]) -> String { + let mut output = String::new(); + let current_dir = env::current_dir().ok(); + for (index, block) in blocks.iter().enumerate() { + let display_path = compact_display_path(&block.path, current_dir.as_deref()); + if index > 0 { + output.push('\n'); + } + if let (Some(start_line), Some(end_line)) = (block.start_line, block.end_line) { + writeln!( + output, + "{}:{}-{} kind={} header={}", + display_path, start_line, end_line, block.kind, block.header + ) + .expect("writing to a String cannot fail"); + for (offset, line) in block.text.lines().enumerate() { + writeln!(output, "{}: {}", start_line + offset, line) + .expect("writing to a String cannot fail"); + } + } else { + writeln!( + output, + "{} kind={} header={}", + display_path, block.kind, block.header + ) + .expect("writing to a String cannot fail"); + writeln!(output, "{}", block.text).expect("writing to a String cannot fail"); + } + if block.truncated { + writeln!(output, "[truncated]").expect("writing to a String cannot fail"); + } + } + output +} + +fn compact_display_path(path: &str, current_dir: Option<&Path>) -> String { + let candidate = PathBuf::from(path); + if !candidate.is_absolute() { + return path.to_string(); + } + let Some(current_dir) = current_dir else { + return path.to_string(); + }; + candidate.strip_prefix(current_dir).map_or_else( + |_| path.to_string(), + |relative| relative.display().to_string(), + ) +} + +fn decorate_block_headers(blocks: Vec, prefix: Option) -> Vec { + let Some(prefix) = prefix else { + return blocks; + }; + + blocks + .into_iter() + .map(|mut block| { + block.header = format!("{prefix} :: {}", block.header); + block + }) + .collect() +} + +fn diagnostic_label(object: &serde_json::Map) -> Option { + let severity = object.get("severity").and_then(Value::as_str)?; + let code = object.get("code").and_then(Value::as_str); + let message = object + .get("message") + .and_then(Value::as_str) + .map(compact_header_text) + .filter(|message| !message.is_empty()); + + let mut label = String::from("diag "); + label.push_str(severity); + if let Some(code) = code.filter(|code| !code.is_empty()) { + let _ = write!(label, "[{code}]"); + } + if let Some(message) = message { + let _ = write!(label, " {message}"); + } + Some(label) +} + +fn compact_header_text(text: &str) -> String { + const MAX_CHARS: usize = 72; + + let mut single_line = String::new(); + for word in text.split_whitespace() { + if !single_line.is_empty() { + single_line.push(' '); + } + single_line.push_str(word); + } + if single_line.chars().count() <= MAX_CHARS { + return single_line; + } + + let end_index = single_line + .char_indices() + .nth(MAX_CHARS) + .map_or(single_line.len(), |(index, _)| index); + format!("{}...", &single_line[..end_index]) +} + +fn parse_text_hit(raw: &str) -> Result { + let segments = raw.split(':').collect::>(); + if segments.len() < 2 { + return Err(CliError::usage(format!("invalid hit format: {raw}"))); + } + + let numeric_index = segments + .iter() + .enumerate() + .rev() + .find_map(|(index, segment)| segment.parse::().ok().map(|value| (index, value))) + .ok_or_else(|| CliError::usage(format!("invalid hit format: {raw}")))?; + + let (path, line, column) = if numeric_index.0 > 0 + && segments[numeric_index.0 - 1].parse::().is_ok() + && !segments[..numeric_index.0 - 1].join(":").is_empty() + { + let path = segments[..numeric_index.0 - 1].join(":"); + let line = segments[numeric_index.0 - 1] + .parse::() + .map_err(|error| CliError::usage(format!("invalid hit line in '{raw}': {error}")))?; + (path, line, Some(numeric_index.1)) + } else { + let path = segments[..numeric_index.0].join(":"); + (path, numeric_index.1, None) + }; + + if line == 0 || column == Some(0) || path.is_empty() { + return Err(CliError::usage(format!("invalid hit format: {raw}"))); + } + + Ok(LineHit { path, line, column }) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_value_string(value, flag) +} + +fn os_value_string(value: OsString, flag: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn parse_sort_mode(value: &str) -> Result { + match value { + "input" => Ok(SortMode::Input), + "path" => Ok(SortMode::Path), + "recent" => Ok(SortMode::Recent), + other => Err(CliError::usage(format!( + "invalid --sort value '{other}'; expected input, path, or recent" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{thread, time::Duration}; + + fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) + } + + #[test] + fn parse_text_hit_supports_rg_style_lines() { + let hit = parse_text_hit(r"C:\repo\demo.rs:18:9:pub fn run() {}").expect("hit"); + assert_eq!(hit.path, r"C:\repo\demo.rs"); + assert_eq!(hit.line, 18); + assert_eq!(hit.column, Some(9)); + } + + #[test] + fn build_block_truncates_to_max_lines() { + let block = build_block( + "demo.rs".to_string(), + "file".to_string(), + "demo".to_string(), + Some(1), + Some(4), + "a\nb\nc\nd", + 2, + ); + assert_eq!(block.end_line, Some(2)); + assert!(block.truncated); + assert_eq!(block.text, "a\nb"); + } + + #[test] + fn build_block_keeps_larger_definition_windows() { + let long_text = (1..=30) + .map(|value| format!("line {value}")) + .collect::>() + .join("\n"); + let block = build_block( + "demo.rs".to_string(), + "definition".to_string(), + "fn helper()".to_string(), + Some(1), + Some(30), + &long_text, + 2, + ); + assert!(block.text.contains("line 24")); + assert_eq!(block.end_line, Some(24)); + assert!(block.truncated); + } + + #[test] + fn materialize_text_hit_prefers_enclosing_definition() { + let mut indexer = CodeIndexer::new(); + let blocks = materialize_text_input( + &format!("{}:26", fixture_path("src/lib.rs").display()), + 20, + &mut indexer, + ) + .expect("blocks"); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].kind, "definition"); + assert_eq!(blocks[0].header, "call_helper"); + } + + #[test] + fn materialize_json_snippet_uses_lines_array() { + let placeholder = ['{', 'v', 'a', 'l', 'u', 'e', '}'] + .into_iter() + .collect::(); + let line_text = format!("println!(\"{placeholder}\");"); + let value = serde_json::json!({ + "path": "demo.rs", + "start_line": 4, + "end_line": 5, + "lines": [ + { "number": 4, "text": "let value = 1;" }, + { "number": 5, "text": line_text } + ] + }); + let mut indexer = CodeIndexer::new(); + + let blocks = materialize_json_value(&value, 20, &mut indexer).expect("blocks"); + + assert_eq!(blocks[0].kind, "snippet"); + assert_eq!(blocks[0].start_line, Some(4)); + assert!(blocks[0].text.contains("println!")); + } + + #[test] + fn materialize_json_value_prefers_nested_definition_records() { + let mut indexer = CodeIndexer::new(); + let blocks = materialize_json_value( + &serde_json::json!({ + "path": "demo.rs", + "line": 7, + "definition": { + "path": "demo.rs", + "qualified_name": "demo::helper", + "signature": "fn helper()", + "start_line": 3, + "end_line": 5, + "text": "fn helper() {\n 1\n}" + }, + "snippet": { + "start_line": 7, + "end_line": 7, + "lines": [ + { "number": 7, "text": "helper();" } + ] + } + }), + 4, + &mut indexer, + ) + .expect("blocks"); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].kind, "definition"); + assert_eq!(blocks[0].header, "demo::helper"); + } + + #[test] + fn materialize_json_value_preserves_diagnostic_identity_in_headers() { + let mut indexer = CodeIndexer::new(); + let blocks = materialize_json_value( + &serde_json::json!({ + "path": "demo.rs", + "line": 7, + "column": 2, + "severity": "error", + "code": "E0425", + "message": "cannot find value `missing` in this scope", + "source": { + "start_line": 7, + "end_line": 7, + "lines": [ + { "text": "missing();" } + ] + } + }), + 4, + &mut indexer, + ) + .expect("blocks"); + + assert_eq!(blocks.len(), 1); + assert!(blocks[0].header.contains("diag error[E0425]")); + assert!(blocks[0].header.contains("cannot find value")); + assert!(blocks[0].header.contains("snippet")); + } + + #[test] + fn expand_json_value_supports_wrapper_records_array() { + let items = expand_json_value(serde_json::json!({ + "records": [ + { "path": "demo.rs", "line": 7 } + ] + })) + .expect("items"); + + assert_eq!(items.len(), 1); + assert!(matches!(items[0], InputItem::Json(_))); + } + + #[test] + fn expand_json_value_supports_codeshape_files_array() { + let items = expand_json_value(serde_json::json!({ + "files": [ + { + "path": "demo.rs", + "language": "rust", + "items": [ + { + "kind": "function", + "name": "helper", + "qualified_name": "helper", + "signature": "fn helper()", + "depth": 1, + "start_line": 3, + "end_line": 5 + } + ] + } + ] + })) + .expect("items"); + + assert_eq!(items.len(), 1); + assert!(matches!(items[0], InputItem::Json(_))); + } + + #[test] + fn materialize_json_value_supports_codeshape_file_objects() { + let mut indexer = CodeIndexer::new(); + let blocks = materialize_json_value( + &serde_json::json!({ + "path": "demo.rs", + "language": "rust", + "items": [ + { + "kind": "module", + "name": "demo", + "qualified_name": "demo", + "signature": "mod demo", + "depth": 1, + "start_line": 1, + "end_line": 10 + }, + { + "kind": "function", + "name": "helper", + "qualified_name": "demo::helper", + "signature": "fn helper()", + "depth": 2, + "start_line": 3, + "end_line": 5 + } + ] + }), + 20, + &mut indexer, + ) + .expect("blocks"); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].kind, "codeshape"); + assert_eq!(blocks[0].header, "codeshape rust"); + assert!(blocks[0].text.contains("module demo")); + assert!(blocks[0].text.contains("function demo::helper")); + } + + #[test] + fn materialize_json_value_ignores_null_optional_fields() { + let mut indexer = CodeIndexer::new(); + let blocks = materialize_json_value( + &serde_json::json!({ + "path": fixture_path("src/lib.rs").display().to_string(), + "line": 26, + "column": 11, + "snippet": null, + "source": null, + "enclosing_definition": null + }), + 12, + &mut indexer, + ) + .expect("blocks"); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].kind, "definition"); + } + + #[test] + fn parse_input_items_auto_mixes_wrappers_and_text_lines() { + let items = parse_input_items( + "{\"hits\":[{\"path\":\"demo.rs\",\"line\":7}]}\nplain.txt\n{\"path\":\"demo.rs\",\"name\":\"helper\",\"text\":\"fn helper() {}\"}\n", + InputFormat::Auto, + ) + .expect("items"); + + assert_eq!(items.len(), 3); + assert!(matches!(items[0], InputItem::Json(_))); + assert_eq!(items[1], InputItem::Text("plain.txt".to_string())); + assert!(matches!(items[2], InputItem::Json(_))); + } + + #[test] + fn materialize_json_value_supports_source_and_text_objects() { + let mut indexer = CodeIndexer::new(); + let source_blocks = materialize_json_value( + &serde_json::json!({ + "path": "outer.rs", + "source": { + "start_line": 3, + "end_line": 4, + "lines": [ + { "text": "let alpha = 1;" }, + { "text": "let beta = 2;" } + ] + } + }), + 20, + &mut indexer, + ) + .expect("source blocks"); + assert_eq!(source_blocks[0].path, "outer.rs"); + assert_eq!(source_blocks[0].kind, "snippet"); + assert_eq!(source_blocks[0].start_line, Some(3)); + assert!(source_blocks[0].text.contains("beta")); + + let text_blocks = materialize_json_value( + &serde_json::json!({ + "path": "demo.rs", + "signature": "fn helper()", + "text": "fn helper() {}" + }), + 20, + &mut indexer, + ) + .expect("text blocks"); + assert_eq!(text_blocks[0].kind, "definition"); + assert_eq!(text_blocks[0].header, "fn helper()"); + assert_eq!(text_blocks[0].start_line, None); + } + + #[test] + fn dedupe_and_recent_sort_keep_unique_newest_blocks_first() { + let temp = std::env::temp_dir().join(format!( + "ctxpack-tests-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let older = temp.join("older.rs"); + let newer = temp.join("newer.rs"); + fs::write(&older, "fn older() {}\n").expect("older"); + thread::sleep(Duration::from_millis(20)); + fs::write(&newer, "fn newer() {}\n").expect("newer"); + + let mut blocks = dedupe_blocks(vec![ + ContextBlock { + path: older.display().to_string(), + kind: "file".to_string(), + header: "older".to_string(), + start_line: Some(1), + end_line: Some(1), + truncated: false, + text: "fn older() {}".to_string(), + }, + ContextBlock { + path: older.display().to_string(), + kind: "file".to_string(), + header: "older".to_string(), + start_line: Some(1), + end_line: Some(1), + truncated: false, + text: "fn older() {}".to_string(), + }, + ContextBlock { + path: newer.display().to_string(), + kind: "file".to_string(), + header: "newer".to_string(), + start_line: Some(1), + end_line: Some(1), + truncated: false, + text: "fn newer() {}".to_string(), + }, + ]); + + assert_eq!(blocks.len(), 2); + sort_blocks(&mut blocks, SortMode::Recent); + assert_eq!(blocks[0].path, newer.display().to_string()); + assert_eq!(blocks[1].path, older.display().to_string()); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn dedupe_definition_blocks_only_collapses_definition_duplicates() { + let blocks = dedupe_definition_blocks(vec![ + ContextBlock { + path: "src/lib.rs".to_string(), + kind: "definition".to_string(), + header: "fn helper()".to_string(), + start_line: Some(10), + end_line: Some(12), + truncated: false, + text: "fn helper() {\n run();\n}".to_string(), + }, + ContextBlock { + path: "src/lib.rs".to_string(), + kind: "definition".to_string(), + header: "fn helper()".to_string(), + start_line: Some(10), + end_line: Some(12), + truncated: false, + text: "fn helper() {\n run();\n}".to_string(), + }, + ContextBlock { + path: "src/lib.rs".to_string(), + kind: "snippet".to_string(), + header: "line 11".to_string(), + start_line: Some(11), + end_line: Some(11), + truncated: false, + text: "run();".to_string(), + }, + ContextBlock { + path: "src/lib.rs".to_string(), + kind: "snippet".to_string(), + header: "line 11".to_string(), + start_line: Some(11), + end_line: Some(11), + truncated: false, + text: "run();".to_string(), + }, + ]); + + assert_eq!(blocks.len(), 3); + assert_eq!(blocks[0].kind, "definition"); + assert_eq!(blocks[1].kind, "snippet"); + assert_eq!(blocks[2].kind, "snippet"); + } + + #[test] + fn parse_input_items_and_json_helpers_reject_invalid_shapes() { + assert!(matches!( + expand_json_value(serde_json::json!(42)), + Err(CliError::Runtime(message)) + if message.contains("JSON input must be an object or array of objects") + )); + assert!(matches!( + expand_json_value(serde_json::json!({ "hits": { "path": "demo.rs", "line": 7 } })), + Err(CliError::Runtime(message)) + if message.contains("context JSON field 'hits' must be an array") + )); + assert!(matches!( + parse_input_items("42\n", InputFormat::Auto), + Err(CliError::Runtime(message)) + if message.contains("JSON input must be an object or array of objects") + )); + } + + #[test] + fn diagnostic_header_helpers_compact_message_text() { + let label = diagnostic_label( + serde_json::json!({ + "severity": "warning", + "code": "W1", + "message": "first line\nsecond line with extra spacing" + }) + .as_object() + .expect("object"), + ) + .expect("label"); + assert_eq!( + label, + "diag warning[W1] first line second line with extra spacing" + ); + assert!(compact_header_text(&"x".repeat(100)).ends_with("...")); + } + + #[test] + fn materialize_text_and_json_file_inputs_cover_file_and_snippet_paths() { + let temp = std::env::temp_dir().join(format!( + "ctxpack-inputs-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let notes = temp.join("notes.txt"); + fs::write(¬es, "alpha\nbeta\ngamma\ndelta\n").expect("notes"); + + let mut indexer = CodeIndexer::new(); + let file_blocks = + materialize_text_input(notes.to_str().expect("path"), 3, &mut indexer).expect("file"); + assert_eq!(file_blocks[0].kind, "file"); + assert_eq!(file_blocks[0].header, "notes.txt"); + assert_eq!(file_blocks[0].start_line, Some(1)); + assert_eq!(file_blocks[0].end_line, Some(3)); + assert!(file_blocks[0].truncated); + + let hit_blocks = materialize_text_input(&format!("{}:3", notes.display()), 3, &mut indexer) + .expect("hit"); + assert_eq!(hit_blocks[0].kind, "snippet"); + assert_eq!(hit_blocks[0].header, "line 3"); + assert_eq!(hit_blocks[0].start_line, Some(2)); + assert_eq!(hit_blocks[0].end_line, Some(4)); + assert!(hit_blocks[0].text.contains("gamma")); + + let json_blocks = materialize_json_value( + &serde_json::json!({ "path": notes.display().to_string() }), + 2, + &mut indexer, + ) + .expect("json file"); + assert_eq!(json_blocks[0].kind, "file"); + assert_eq!(json_blocks[0].header, "notes.txt"); + assert_eq!(json_blocks[0].start_line, Some(1)); + assert_eq!(json_blocks[0].end_line, Some(2)); + assert!(json_blocks[0].truncated); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn build_snippet_block_clamps_past_eof_requests() { + let block = build_snippet_block("notes.txt", "alpha\nbeta\n", 10, 3); + + assert_eq!(block.header, "line 10 (past EOF, clamped to 2)"); + assert_eq!(block.start_line, Some(2)); + assert_eq!(block.end_line, Some(2)); + assert_eq!(block.text, "beta"); + } + + #[test] + fn materialize_json_value_and_render_blocks_cover_error_and_formatting_paths() { + let mut indexer = CodeIndexer::new(); + assert!(matches!( + materialize_json_value( + &serde_json::json!({ + "path": "demo.rs", + "lines": { "text": "fn helper() {}" } + }), + 5, + &mut indexer, + ), + Err(CliError::Runtime(message)) + if message.contains("source snippet is missing a lines array") + )); + assert!(matches!( + materialize_json_value( + &serde_json::json!({ "text": "fn helper() {}" }), + 5, + &mut indexer, + ), + Err(CliError::Runtime(message)) + if message.contains("unsupported JSON shape for context input") + )); + + let rendered = render_blocks(&[ + ContextBlock { + path: "demo.rs".to_string(), + kind: "definition".to_string(), + header: "helper".to_string(), + start_line: Some(4), + end_line: Some(5), + truncated: false, + text: "fn helper() {\n}".to_string(), + }, + ContextBlock { + path: "".to_string(), + kind: "snippet".to_string(), + header: "snippet".to_string(), + start_line: None, + end_line: None, + truncated: true, + text: "line one\nline two".to_string(), + }, + ]); + + assert_eq!( + rendered, + "demo.rs:4-5 kind=definition header=helper\n4: fn helper() {\n5: }\n\n kind=snippet header=snippet\nline one\nline two\n[truncated]\n" + ); + } + + #[test] + fn sort_blocks_supports_input_and_path_modes() { + let first = ContextBlock { + path: "b.rs".to_string(), + kind: "definition".to_string(), + header: "beta".to_string(), + start_line: Some(4), + end_line: Some(4), + truncated: false, + text: "beta".to_string(), + }; + let second = ContextBlock { + path: "a.rs".to_string(), + kind: "definition".to_string(), + header: "alpha".to_string(), + start_line: Some(2), + end_line: Some(2), + truncated: false, + text: "alpha".to_string(), + }; + let mut input_sorted = vec![first.clone(), second.clone()]; + sort_blocks(&mut input_sorted, SortMode::Input); + assert_eq!( + input_sorted + .iter() + .map(|block| block.path.as_str()) + .collect::>(), + vec!["b.rs", "a.rs"] + ); + + let mut path_sorted = vec![first, second]; + sort_blocks(&mut path_sorted, SortMode::Path); + assert_eq!( + path_sorted + .iter() + .map(|block| block.path.as_str()) + .collect::>(), + vec!["a.rs", "b.rs"] + ); + } + + #[test] + fn parse_text_hit_and_cli_validation_reject_invalid_zero_values() { + assert!(matches!( + parse_text_hit("demo.rs:0"), + Err(CliError::Usage(message)) if message.contains("invalid hit format") + )); + assert!(matches!( + parse_text_hit("demo.rs:4:0"), + Err(CliError::Usage(message)) if message.contains("invalid hit format") + )); + assert!(matches!( + parse_cli_from(["ctxpack", "--max-blocks", "0", "demo.rs"]), + Err(CliError::Usage(message)) if message.contains("--max-blocks must be greater than 0") + )); + assert!(matches!( + parse_cli_from(["ctxpack", "--max-lines", "0", "demo.rs"]), + Err(CliError::Usage(message)) if message.contains("--max-lines must be greater than 0") + )); + } +} diff --git a/crates/context/src/main.rs b/crates/context/src/main.rs new file mode 100644 index 0000000..4405bf6 --- /dev/null +++ b/crates/context/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `ctxpack`. + +fn main() { + std::process::exit(context::main_entry()); +} diff --git a/crates/context/tests/context_cli.rs b/crates/context/tests/context_cli.rs new file mode 100644 index 0000000..6758c0a --- /dev/null +++ b/crates/context/tests/context_cli.rs @@ -0,0 +1,129 @@ +//! Integration tests for the `ctxpack` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("ctxpack").expect("binary") +} + +fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn emits_definition_blocks_for_line_hits() { + let mut command = cargo_command(); + command + .arg(format!("{}:26", fixture_path("src/lib.rs").display())) + .assert() + .success() + .stdout(predicate::str::contains("kind=definition")) + .stdout(predicate::str::contains("call_helper")); +} + +#[test] +fn emits_json_blocks_from_definition_records() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("{\"path\":\"demo.rs\",\"qualified_name\":\"helper\",\"start_line\":1,\"end_line\":2,\"text\":\"fn helper() {}\"}") + .assert() + .failure(); +} + +#[test] +fn supports_powershell_pipeline_json_input() { + let binary = assert_cmd::cargo::cargo_bin("ctxpack"); + let input = "[{\"path\":\"demo.rs\",\"qualified_name\":\"helper\",\"start_line\":1,\"end_line\":2,\"text\":\"fn helper() {}\"}]"; + let script = format!( + "'{}' | & '{}' --input-format auto --json | ConvertFrom-Json | Select-Object -First 1 -ExpandProperty header", + input, + binary.display() + ); + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("helper")); +} + +#[test] +fn rejects_scalar_json_from_stdin_auto_mode() { + let mut command = cargo_command(); + command + .args(["--json", "--input-format", "auto"]) + .write_stdin("42\n") + .assert() + .failure() + .stderr(predicate::str::contains( + "JSON input must be an object or array of objects", + )); +} + +#[test] +fn rejects_non_array_wrapper_fields_from_jsonl_stdin() { + let mut command = cargo_command(); + command + .args(["--json", "--input-format", "jsonl"]) + .write_stdin("{\"hits\":{\"path\":\"demo.rs\",\"line\":7}}\n") + .assert() + .failure() + .stderr(predicate::str::contains( + "context JSON field 'hits' must be an array when present", + )); +} + +#[test] +fn expands_jsonl_wrappers_and_applies_dedupe_limits() { + let input = concat!( + "{\"items\":[{\"path\":\"demo.rs\",\"name\":\"first\",\"text\":\"fn first() {}\"}]}\n", + "{\"path\":\"demo.rs\",\"name\":\"first\",\"text\":\"fn first() {}\"}\n", + "{\"path\":\"demo.rs\",\"name\":\"second\",\"text\":\"fn second() {}\"}\n" + ); + let mut command = cargo_command(); + command + .args([ + "--json", + "--input-format", + "jsonl", + "--dedupe", + "--sort", + "path", + "--max-blocks", + "1", + ]) + .write_stdin(input) + .assert() + .success() + .stdout(predicate::str::contains("\"header\":\"first\"")) + .stdout(predicate::str::contains("\"header\":\"second\"").not()); +} + +#[test] +fn help_includes_examples_and_pipeline_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--max-blocks")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("ctxpack")); +} diff --git a/crates/csvshape/Cargo.toml b/crates/csvshape/Cargo.toml new file mode 100644 index 0000000..3f98ac5 --- /dev/null +++ b/crates/csvshape/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "csvshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Summarize large CSV and TSV files with bounded, AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +csv.workspace = true +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/csvshape/src/lib.rs b/crates/csvshape/src/lib.rs new file mode 100644 index 0000000..aa9ddda --- /dev/null +++ b/crates/csvshape/src/lib.rs @@ -0,0 +1,1811 @@ +//! The `csvshape` command summarizes large CSV and TSV files. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + read_existing_stdin_paths, should_read_stdin, +}; +use csv::{ByteRecord, ReaderBuilder, StringRecord}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +const MAX_CSV_COLUMNS: usize = 4096; +const MAX_CSV_FIELD_BYTES: usize = 1_048_576; + +const HELP: &str = "\ +Summarize CSV and TSV files into compact schema and stats for AI-friendly inspection. + +Usage: + csvshape [OPTIONS] [PATH...] + csvshape [OPTIONS] diff + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --delimiter Delimiter mode: auto, comma, tab, semicolon, pipe + --sample-rows Maximum rows to sample for examples and type inference + --limit-columns Optional maximum number of columns to emit + --no-examples Omit example values from the summary + -h, --help Show this help text + -V, --version Show the command version + +Examples: + csvshape .\\fixtures\\csvshape\\sample.csv + bat --style=plain --paging=never .\\fixtures\\csvshape\\sample.tsv | csvshape --delimiter tab --json | ConvertFrom-Json + csvshape .\\fixtures\\csvshape\\sample.csv --limit-columns 8 + csvshape diff before.csv after.csv --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: CommandMode, + delimiter_mode: DelimiterMode, + sample_rows: usize, + limit_columns: Option, + include_examples: bool, + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandMode { + Summary, + Diff { before: PathBuf, after: PathBuf }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DelimiterMode { + Auto, + Comma, + Tab, + Semicolon, + Pipe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CsvSummary { + path: Option, + delimiter: String, + header_present: bool, + row_count: usize, + column_count: usize, + columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ColumnSummary { + name: String, + inferred_kind: ColumnKind, + null_count: usize, + non_null_count: usize, + unique_values_estimate: usize, + examples: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +enum ColumnKind { + Empty, + Boolean, + Integer, + Float, + DateLike, + String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ColumnAccumulator { + name: String, + null_count: usize, + non_null_count: usize, + seen_kinds: BTreeSet, + unique_values: BTreeSet, + examples: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CsvDiff { + path_before: Option, + path_after: Option, + delimiter_before: String, + delimiter_after: String, + header_before: bool, + header_after: bool, + row_count_before: usize, + row_count_after: usize, + added_columns: Vec, + removed_columns: Vec, + changed_columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ColumnChange { + name: String, + kind_before: ColumnKind, + kind_after: ColumnKind, + null_count_before: usize, + null_count_after: usize, + non_null_count_before: usize, + non_null_count_after: usize, + unique_values_estimate_before: usize, + unique_values_estimate_after: usize, + added_examples: Vec, + removed_examples: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("csvshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 32, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }; + let mut diff_paths = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("delimiter") => { + cli.delimiter_mode = + parse_delimiter_mode(&parser_value_string(&mut parser, "--delimiter")?)?; + } + Long("sample-rows") => { + cli.sample_rows = parse_positive_usize_flag( + "--sample-rows", + &parser_value_string(&mut parser, "--sample-rows")?, + )?; + } + Long("limit-columns") => { + cli.limit_columns = Some(parse_positive_usize_flag( + "--limit-columns", + &parser_value_string(&mut parser, "--limit-columns")?, + )?); + } + Long("no-examples") => cli.include_examples = false, + ArgValue(path) => { + if matches!(cli.command, CommandMode::Summary) + && cli.paths.is_empty() + && diff_paths.is_empty() + && path == "diff" + { + cli.command = CommandMode::Diff { + before: PathBuf::new(), + after: PathBuf::new(), + }; + } else if matches!(cli.command, CommandMode::Diff { .. }) { + diff_paths.push(PathBuf::from(path)); + } else { + cli.paths.push(PathBuf::from(path)); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if matches!(cli.command, CommandMode::Diff { .. }) { + if diff_paths.len() != 2 { + return Err(CliError::usage("diff expects exactly two CSV inputs")); + } + cli.command = CommandMode::Diff { + before: diff_paths[0].clone(), + after: diff_paths[1].clone(), + }; + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_delimiter_mode(value: &str) -> Result { + match value { + "auto" => Ok(DelimiterMode::Auto), + "comma" => Ok(DelimiterMode::Comma), + "tab" => Ok(DelimiterMode::Tab), + "semicolon" => Ok(DelimiterMode::Semicolon), + "pipe" => Ok(DelimiterMode::Pipe), + other => Err(CliError::usage(format!( + "invalid --delimiter value '{other}'; expected auto, comma, tab, semicolon, or pipe" + ))), + } +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + CommandMode::Summary => { + let inputs = load_inputs(cli)?; + let summaries = inputs + .iter() + .map(|input| summarize_input(input, cli)) + .collect::, _>>()?; + + let summary_count = summaries.len(); + let mut output_summaries = summaries + .into_iter() + .map(|summary| limit_summary_columns(summary, cli.limit_columns)) + .collect::>(); + + match cli.common.render_mode() { + RenderMode::Json => { + if output_summaries.len() == 1 { + print_json(&output_summaries.remove(0))?; + } else { + print_json(&output_summaries)?; + } + } + RenderMode::Toon => { + if output_summaries.len() == 1 { + print_structured(&output_summaries.remove(0), RenderMode::Toon)?; + } else { + print_structured(&output_summaries, RenderMode::Toon)?; + } + } + RenderMode::Text => { + for summary in &output_summaries { + print!("{}", render_summary(summary)); + } + } + } + + Ok(map_result_count(summary_count)) + } + CommandMode::Diff { before, after } => { + let before_path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(before), "csvshape")?, + "csvshape diff before", + )?; + let after_path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(after), "csvshape")?, + "csvshape diff after", + )?; + let before_summary = summarize_input(&load_input_from_path(&before_path)?, cli)?; + let after_summary = summarize_input(&load_input_from_path(&after_path)?, cli)?; + let diff = diff_summaries(&before_summary, &after_summary); + + match cli.common.render_mode() { + RenderMode::Json => print_json(&diff)?, + RenderMode::Toon => print_structured(&diff, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_diff(&diff)), + } + + Ok(ExitCode::Success) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InputDocument { + path: Option, + content: Vec, +} + +fn load_inputs(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = Vec::new(); + io::stdin() + .read_to_end(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.is_empty() { + if let Ok(text) = std::str::from_utf8(&buffer) + && let Some(paths) = + read_existing_stdin_paths(text, cli.common.input_format, "csvshape")? + { + return paths.iter().map(load_input_from_path).collect(); + } + if buffer.iter().all(u8::is_ascii_whitespace) { + buffer.clear(); + } else { + return Ok(vec![InputDocument { + path: None, + content: buffer, + }]); + } + } + } + + if cli.paths.is_empty() { + return Err(CliError::usage( + "provide at least one CSV path or pipe CSV data into stdin", + )); + } + + let paths = common::expand_input_patterns(&cli.paths, "csvshape")?; + paths.iter().map(load_input_from_path).collect() +} + +fn load_input_from_path(path: &PathBuf) -> Result { + fs::read(path) + .map(|content| InputDocument { + path: Some(path.clone()), + content, + }) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn summarize_input(input: &InputDocument, cli: &Cli) -> Result { + let delimiter = resolve_delimiter(cli.delimiter_mode, &input.content); + validate_csv_quote_structure(&input.content, delimiter)?; + validate_delimiter_drift(&input.content, delimiter)?; + let header_present = detect_header(&input.content, delimiter)?; + let mut reader = ReaderBuilder::new() + .delimiter(delimiter) + .has_headers(header_present) + .flexible(true) + .from_reader(input.content.as_slice()); + + let headers = if header_present { + let headers = reader + .headers() + .map_err(|error| CliError::runtime(format!("failed to parse CSV header: {error}")))? + .clone(); + validate_record_bounds(&headers, 0)?; + headers + } else { + StringRecord::new() + }; + + let mut row_count = 0_usize; + let mut accumulators = Vec::::new(); + + for row in reader.records() { + let row = + row.map_err(|error| CliError::runtime(format!("failed to parse CSV row: {error}")))?; + validate_record_bounds(&row, row_count + 1)?; + if accumulators.is_empty() { + accumulators = make_initial_columns(&headers, row.len()); + } else if row.len() > accumulators.len() { + extend_columns(&mut accumulators, row.len()); + } + + row_count += 1; + update_accumulators( + &mut accumulators, + &row, + cli.sample_rows, + cli.include_examples, + ); + } + + if accumulators.is_empty() { + accumulators = make_initial_columns(&headers, headers.len()); + } + + let columns = accumulators + .into_iter() + .map(|accumulator| ColumnSummary { + name: accumulator.name.clone(), + inferred_kind: resolve_kind(&accumulator), + null_count: accumulator.null_count, + non_null_count: accumulator.non_null_count, + unique_values_estimate: accumulator.unique_values.len(), + examples: accumulator.examples, + }) + .collect::>(); + + Ok(CsvSummary { + path: input.path.as_ref().map(|path| path.display().to_string()), + delimiter: delimiter_label(delimiter).to_string(), + header_present, + row_count, + column_count: columns.len(), + columns, + }) +} + +fn validate_csv_quote_structure(content: &[u8], delimiter: u8) -> Result<(), CliError> { + #[derive(Clone, Copy)] + enum State { + StartField, + Unquoted, + Quoted, + AfterQuote, + } + + let mut state = State::StartField; + let mut line = 1_usize; + let mut field = 1_usize; + let mut index = 0_usize; + + while index < content.len() { + let byte = content[index]; + match state { + State::StartField => match byte { + b'"' => state = State::Quoted, + b'\r' => { + line += 1; + field = 1; + if content.get(index + 1) == Some(&b'\n') { + index += 1; + } + } + b'\n' => { + line += 1; + field = 1; + } + value if value == delimiter => field += 1, + _ => state = State::Unquoted, + }, + State::Unquoted => match byte { + b'\r' => { + state = State::StartField; + line += 1; + field = 1; + if content.get(index + 1) == Some(&b'\n') { + index += 1; + } + } + b'\n' => { + state = State::StartField; + line += 1; + field = 1; + } + value if value == delimiter => { + state = State::StartField; + field += 1; + } + _ => {} + }, + State::Quoted => match byte { + b'"' if content.get(index + 1) == Some(&b'"') => index += 1, + b'"' => state = State::AfterQuote, + b'\r' => { + line += 1; + if content.get(index + 1) == Some(&b'\n') { + index += 1; + } + } + b'\n' => line += 1, + _ => {} + }, + State::AfterQuote => match byte { + value if value == delimiter => { + state = State::StartField; + field += 1; + } + b'\r' => { + state = State::StartField; + line += 1; + field = 1; + if content.get(index + 1) == Some(&b'\n') { + index += 1; + } + } + b'\n' => { + state = State::StartField; + line += 1; + field = 1; + } + value if value.is_ascii_whitespace() => {} + _ => { + return Err(CliError::runtime(format!( + "malformed quoted field at line {line}, field {field}: non-delimiter data follows closing quote" + ))); + } + }, + } + index += 1; + } + + if matches!(state, State::Quoted) { + return Err(CliError::runtime(format!( + "malformed quoted field at line {line}, field {field}: missing closing quote" + ))); + } + + Ok(()) +} + +fn validate_record_bounds(record: &StringRecord, row_number: usize) -> Result<(), CliError> { + if record.len() > MAX_CSV_COLUMNS { + return Err(CliError::runtime(format!( + "too many CSV columns in row {row_number}: found {}, maximum supported is {MAX_CSV_COLUMNS}", + record.len() + ))); + } + + for (index, value) in record.iter().enumerate() { + if value.len() > MAX_CSV_FIELD_BYTES { + return Err(CliError::runtime(format!( + "CSV field is too large in row {row_number}, column {}: found {} bytes, maximum supported is {MAX_CSV_FIELD_BYTES}", + index + 1, + value.len() + ))); + } + } + + Ok(()) +} + +fn validate_delimiter_drift(content: &[u8], delimiter: u8) -> Result<(), CliError> { + let mut reader = ReaderBuilder::new() + .delimiter(delimiter) + .has_headers(false) + .flexible(true) + .from_reader(content); + let mut expected_width = None; + let mut row_number = 0_usize; + + for record in reader.byte_records() { + row_number += 1; + let record = record + .map_err(|error| CliError::runtime(format!("failed to parse CSV row: {error}")))?; + if record.is_empty() || byte_record_is_blank(&record) { + continue; + } + if expected_width.is_none() && record.len() > 1 { + expected_width = Some(record.len()); + continue; + } + if record.len() == 1 + && let Some(width) = expected_width + && alternate_delimiter_matching_width( + record.get(0).unwrap_or_default(), + delimiter, + width, + ) + .is_some() + { + return Err(CliError::runtime(format!( + "inconsistent CSV delimiter in row {row_number}: expected {}", + delimiter_label(delimiter) + ))); + } + } + + Ok(()) +} + +fn byte_record_is_blank(record: &ByteRecord) -> bool { + record + .iter() + .all(|field| field.iter().all(u8::is_ascii_whitespace)) +} + +fn alternate_delimiter_matching_width(field: &[u8], delimiter: u8, width: usize) -> Option { + [b',', b'\t', b';', b'|'] + .into_iter() + .filter(|candidate| *candidate != delimiter) + .find(|candidate| field.split(|byte| byte == candidate).count() == width) +} + +fn limit_summary_columns(mut summary: CsvSummary, limit: Option) -> CsvSummary { + if let Some(max_columns) = limit { + summary.columns.truncate(max_columns); + } + summary +} + +fn resolve_delimiter(mode: DelimiterMode, content: &[u8]) -> u8 { + match mode { + DelimiterMode::Auto => detect_delimiter(content), + DelimiterMode::Comma => b',', + DelimiterMode::Tab => b'\t', + DelimiterMode::Semicolon => b';', + DelimiterMode::Pipe => b'|', + } +} + +fn detect_delimiter(content: &[u8]) -> u8 { + let mut best = (b',', (0_usize, 0_usize, 0_usize, 0_usize)); + + for delimiter in [b',', b'\t', b';', b'|'] { + let score = delimiter_score(content, delimiter); + if score > best.1 { + best = (delimiter, score); + } + } + + best.0 +} + +fn delimiter_score(content: &[u8], delimiter: u8) -> (usize, usize, usize, usize) { + let mut reader = ReaderBuilder::new() + .delimiter(delimiter) + .has_headers(false) + .flexible(true) + .from_reader(content); + let mut widths = Vec::new(); + + for record in reader.byte_records() { + let Ok(record) = record else { + return (0, 0, 0, 0); + }; + if record.is_empty() || byte_record_is_blank(&record) { + continue; + } + widths.push(record.len()); + if widths.len() == 8 { + break; + } + } + + let Some((mode_width, consistent_rows)) = delimiter_width_mode(&widths) else { + return (0, 0, 0, widths.len()); + }; + let first_record_matches = usize::from(widths.first() == Some(&mode_width)); + let split_fields = widths + .iter() + .filter(|width| **width == mode_width) + .map(|width| width.saturating_sub(1)) + .sum(); + + ( + consistent_rows, + first_record_matches, + split_fields, + widths.len(), + ) +} + +fn delimiter_width_mode(widths: &[usize]) -> Option<(usize, usize)> { + let mut best_width = 0_usize; + let mut best_count = 0_usize; + + for &width in widths.iter().filter(|width| **width > 1) { + let count = widths + .iter() + .filter(|candidate| **candidate == width) + .count(); + if count > best_count || (count == best_count && width > best_width) { + best_width = width; + best_count = count; + } + } + + (best_count > 0).then_some((best_width, best_count)) +} + +fn detect_header(content: &[u8], delimiter: u8) -> Result { + let mut reader = ReaderBuilder::new() + .delimiter(delimiter) + .has_headers(false) + .flexible(true) + .from_reader(content); + let mut records = reader.records(); + let Some(first_row) = records.next() else { + return Ok(false); + }; + let first_row = first_row + .map_err(|error| CliError::runtime(format!("failed to parse CSV row: {error}")))?; + let Some(second_row) = records.next() else { + return Ok(first_row.iter().all(is_header_like_name)); + }; + let second_row = second_row + .map_err(|error| CliError::runtime(format!("failed to parse CSV row: {error}")))?; + + let first_is_header_like = first_row.iter().all(is_header_like_name) + && first_row + .iter() + .any(|value| infer_value_kind(value) == ColumnKind::String); + let second_is_data_like = second_row + .iter() + .any(|value| infer_value_kind(value) != ColumnKind::String); + + Ok(first_is_header_like && second_is_data_like) +} + +fn is_header_like_name(value: &str) -> bool { + !value.trim().is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ' ')) +} + +fn make_initial_columns(headers: &StringRecord, width: usize) -> Vec { + (0..width) + .map(|index| ColumnAccumulator { + name: headers + .get(index) + .map_or_else(|| format!("column_{}", index + 1), ToString::to_string), + ..ColumnAccumulator::default() + }) + .collect() +} + +fn extend_columns(columns: &mut Vec, width: usize) { + while columns.len() < width { + let index = columns.len(); + columns.push(ColumnAccumulator { + name: format!("column_{}", index + 1), + ..ColumnAccumulator::default() + }); + } +} + +fn update_accumulators( + accumulators: &mut [ColumnAccumulator], + row: &StringRecord, + sample_rows: usize, + include_examples: bool, +) { + for (index, column) in accumulators.iter_mut().enumerate() { + let value = row.get(index).unwrap_or_default().trim(); + if value.is_empty() { + column.null_count += 1; + continue; + } + + column.non_null_count += 1; + column.seen_kinds.insert(infer_value_kind(value)); + if column.unique_values.len() < sample_rows { + column.unique_values.insert(value.to_string()); + } + if include_examples + && column.examples.len() < sample_rows + && !column.examples.iter().any(|sample| sample == value) + { + column.examples.push(value.to_string()); + } + } +} + +fn infer_value_kind(value: &str) -> ColumnKind { + if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") { + ColumnKind::Boolean + } else if value.parse::().is_ok() { + ColumnKind::Integer + } else if value.parse::().is_ok() { + ColumnKind::Float + } else if is_date_like(value) { + ColumnKind::DateLike + } else { + ColumnKind::String + } +} + +fn is_date_like(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 8 + && bytes + .iter() + .filter(|byte| **byte == b'-' || **byte == b'/') + .count() + >= 2 + && bytes.iter().all(|byte| { + byte.is_ascii_digit() || matches!(*byte, b'-' | b'/' | b':' | b' ' | b'T' | b'Z') + }) +} + +fn resolve_kind(accumulator: &ColumnAccumulator) -> ColumnKind { + if accumulator.non_null_count == 0 { + return ColumnKind::Empty; + } + if accumulator.seen_kinds.len() == 1 { + return accumulator + .seen_kinds + .iter() + .next() + .copied() + .unwrap_or(ColumnKind::String); + } + if accumulator.seen_kinds.iter().all(|kind| { + matches!( + kind, + ColumnKind::Integer | ColumnKind::Float | ColumnKind::Boolean | ColumnKind::DateLike + ) + }) { + if accumulator.seen_kinds.contains(&ColumnKind::Float) { + ColumnKind::Float + } else { + ColumnKind::String + } + } else { + ColumnKind::String + } +} + +const fn delimiter_label(delimiter: u8) -> &'static str { + match delimiter { + b',' => "comma", + b'\t' => "tab", + b';' => "semicolon", + b'|' => "pipe", + _ => "unknown", + } +} + +fn render_summary(summary: &CsvSummary) -> String { + let mut rendered = String::new(); + let path = summary.path.as_deref().unwrap_or("-"); + writeln!( + rendered, + "path={path} delimiter={} header={} rows={} columns={}", + summary.delimiter, summary.header_present, summary.row_count, summary.column_count + ) + .expect("writing to a String cannot fail"); + for column in &summary.columns { + write!( + rendered, + "column={} kind={:?} nulls={} non_nulls={} unique_estimate={}", + column.name, + column.inferred_kind, + column.null_count, + column.non_null_count, + column.unique_values_estimate + ) + .expect("writing to a String cannot fail"); + if !column.examples.is_empty() { + write!(rendered, " examples={}", render_examples(&column.examples)) + .expect("writing to a String cannot fail"); + } + rendered.push('\n'); + } + rendered +} + +fn render_examples(examples: &[String]) -> String { + if examples.is_empty() { + "-".to_string() + } else { + examples.join("|") + } +} + +fn diff_summaries(before: &CsvSummary, after: &CsvSummary) -> CsvDiff { + let before_columns = before + .columns + .iter() + .map(|column| (column.name.clone(), column)) + .collect::>(); + let after_columns = after + .columns + .iter() + .map(|column| (column.name.clone(), column)) + .collect::>(); + + let mut added_columns = Vec::new(); + let mut removed_columns = Vec::new(); + let mut changed_columns = Vec::new(); + + let column_names = before_columns + .keys() + .chain(after_columns.keys()) + .collect::>(); + + for name in column_names { + match (before_columns.get(name), after_columns.get(name)) { + (None, Some(column)) => added_columns.push((*column).clone()), + (Some(column), None) => removed_columns.push((*column).clone()), + (Some(before_column), Some(after_column)) if before_column != after_column => { + changed_columns.push(ColumnChange { + name: name.clone(), + kind_before: before_column.inferred_kind, + kind_after: after_column.inferred_kind, + null_count_before: before_column.null_count, + null_count_after: after_column.null_count, + non_null_count_before: before_column.non_null_count, + non_null_count_after: after_column.non_null_count, + unique_values_estimate_before: before_column.unique_values_estimate, + unique_values_estimate_after: after_column.unique_values_estimate, + added_examples: difference(&after_column.examples, &before_column.examples), + removed_examples: difference(&before_column.examples, &after_column.examples), + }); + } + _ => {} + } + } + + CsvDiff { + path_before: before.path.clone(), + path_after: after.path.clone(), + delimiter_before: before.delimiter.clone(), + delimiter_after: after.delimiter.clone(), + header_before: before.header_present, + header_after: after.header_present, + row_count_before: before.row_count, + row_count_after: after.row_count, + added_columns, + removed_columns, + changed_columns, + } +} + +fn difference(left: &[String], right: &[String]) -> Vec { + let right_set = right.iter().collect::>(); + left.iter() + .filter(|item| !right_set.contains(item)) + .cloned() + .collect() +} + +fn render_diff(diff: &CsvDiff) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "before={} after={} delimiter={}=>{} header={}=>{} rows={}=>{} added_columns={} removed_columns={} changed_columns={}", + diff.path_before.as_deref().unwrap_or("-"), + diff.path_after.as_deref().unwrap_or("-"), + diff.delimiter_before, + diff.delimiter_after, + diff.header_before, + diff.header_after, + diff.row_count_before, + diff.row_count_after, + diff.added_columns.len(), + diff.removed_columns.len(), + diff.changed_columns.len() + ) + .expect("writing to a String cannot fail"); + for column in &diff.added_columns { + writeln!( + rendered, + "column={} change=added kind={:?} nulls={} non_nulls={} unique_estimate={} examples={}", + column.name, + column.inferred_kind, + column.null_count, + column.non_null_count, + column.unique_values_estimate, + render_examples(&column.examples) + ) + .expect("writing to a String cannot fail"); + } + for column in &diff.removed_columns { + writeln!( + rendered, + "column={} change=removed kind={:?} nulls={} non_nulls={} unique_estimate={} examples={}", + column.name, + column.inferred_kind, + column.null_count, + column.non_null_count, + column.unique_values_estimate, + render_examples(&column.examples) + ) + .expect("writing to a String cannot fail"); + } + for column in &diff.changed_columns { + writeln!( + rendered, + "column={} change=changed kind={:?}=>{:?} nulls={}=>{} non_nulls={}=>{} unique_estimate={}=>{} added_examples={} removed_examples={}", + column.name, + column.kind_before, + column.kind_after, + column.null_count_before, + column.null_count_after, + column.non_null_count_before, + column.non_null_count_after, + column.unique_values_estimate_before, + column.unique_values_estimate_after, + render_examples(&column.added_examples), + render_examples(&column.removed_examples) + ) + .expect("writing to a String cannot fail"); + } + rendered +} + +#[cfg(test)] +mod tests { + use std::fmt::Write as _; + + use common::{ColorChoice, InputFormat}; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn delimiter_detection_and_header_heuristics_work() { + let comma = b"name,age\nAda,34\n"; + let pipe = b"name|age\nAda|34\n"; + assert_eq!(detect_delimiter(comma), b','); + assert_eq!(detect_delimiter(pipe), b'|'); + assert!(detect_header(comma, b',').expect("header")); + assert!(!detect_header(b"34,56\n12,89\n", b',').expect("no header")); + } + + #[test] + fn summarize_input_reports_schema_and_stats() { + let summary = summarize_input( + &InputDocument { + path: Some(PathBuf::from("sample.csv")), + content: b"name,age,city\nAda,34,London\nBob,,Paris\nCara,29,Paris\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("summary"); + + assert_eq!(summary.row_count, 3); + assert_eq!(summary.column_count, 3); + assert_eq!(summary.delimiter, "comma"); + assert!(summary.header_present); + assert_eq!(summary.columns[1].name, "age"); + assert_eq!(summary.columns[1].null_count, 1); + } + + #[test] + fn utf8_bom_header_does_not_pollute_first_column_name() { + let mut content = vec![0xEF, 0xBB, 0xBF]; + content.extend_from_slice(b"name,age\nAda,34\n"); + + let summary = summarize_input( + &InputDocument { + path: Some(PathBuf::from("bom.csv")), + content, + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("summary"); + + assert_eq!(summary.columns[0].name, "name"); + assert_eq!(summary.row_count, 1); + } + + #[test] + fn invalid_non_utf8_csv_reports_parse_diagnostic() { + let error = summarize_input( + &InputDocument { + path: Some(PathBuf::from("latin1.csv")), + content: b"name,city\nAda,\xFF\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect_err("invalid UTF-8 should fail"); + + assert!( + matches!(error, CliError::Runtime(message) if message.contains("failed to parse CSV")) + ); + } + + #[test] + fn utf16le_csv_reports_non_utf8_parse_diagnostic() { + let content = [ + 0xFF, 0xFE, b'n', 0, b'a', 0, b'm', 0, b'e', 0, b',', 0, b'a', 0, b'g', 0, b'e', 0, + b'\n', 0, b'A', 0, b'd', 0, b'a', 0, b',', 0, b'3', 0, b'4', 0, b'\n', 0, + ] + .to_vec(); + + let error = summarize_input( + &InputDocument { + path: Some(PathBuf::from("utf16le.csv")), + content, + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect_err("UTF-16LE CSV should fail as non-UTF-8"); + + assert!( + matches!(error, CliError::Runtime(message) if message.contains("failed to parse CSV")) + ); + } + + #[test] + fn mixed_type_columns_are_reported_as_strings() { + let summary = summarize_input( + &InputDocument { + path: Some(PathBuf::from("mixed.csv")), + content: b"value\n1\n2.5\ntrue\n2026-05-23\nunknown\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 8, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("summary"); + + assert_eq!(summary.columns[0].inferred_kind, ColumnKind::String); + assert_eq!(summary.columns[0].non_null_count, 5); + } + + #[test] + fn large_mixed_type_sample_stays_bounded_and_infers_columns() { + let mut content = String::from("id,amount,flag,when,note,optional\n"); + for index in 0..2048 { + let amount = if index % 3 == 0 { + format!("{index}.5") + } else { + index.to_string() + }; + let flag = match index % 5 { + 0 => "true", + 1 => "false", + _ => "unknown", + }; + let optional = if index % 4 == 0 { "" } else { "present" }; + writeln!( + content, + "{index},{amount},{flag},2026-05-{day:02},note-{index},{optional}", + day = (index % 28) + 1 + ) + .expect("write csv row"); + } + + let summary = summarize_input( + &InputDocument { + path: Some(PathBuf::from("large_mixed.csv")), + content: content.into_bytes(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 5, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("large mixed CSV should summarize"); + + assert_eq!(summary.row_count, 2048); + assert_eq!(summary.column_count, 6); + assert_eq!(summary.columns[0].inferred_kind, ColumnKind::Integer); + assert_eq!(summary.columns[1].inferred_kind, ColumnKind::Float); + assert_eq!(summary.columns[2].inferred_kind, ColumnKind::String); + assert_eq!(summary.columns[3].inferred_kind, ColumnKind::DateLike); + assert_eq!(summary.columns[4].examples.len(), 5); + assert_eq!(summary.columns[4].unique_values_estimate, 5); + assert_eq!(summary.columns[5].null_count, 512); + } + + #[test] + fn malformed_quoted_field_reports_diagnostic_instead_of_summarizing() { + let error = summarize_input( + &InputDocument { + path: Some(PathBuf::from("malformed.csv")), + content: b"name,age\nAda,\"34\"x\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect_err("malformed quote should fail"); + + assert!( + matches!(error, CliError::Runtime(message) if message.contains("malformed quoted field")) + ); + } + + #[test] + fn delimiter_drift_reports_diagnostic_instead_of_wrong_shape() { + let error = summarize_input( + &InputDocument { + path: Some(PathBuf::from("drift.csv")), + content: b"name;age\nAda;34\nBob,35\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect_err("delimiter drift should fail"); + + assert!( + matches!(error, CliError::Runtime(message) if message.contains("inconsistent CSV delimiter")) + ); + } + + #[test] + fn quoted_alternate_delimiters_do_not_trigger_delimiter_drift() { + let summary = summarize_input( + &InputDocument { + path: Some(PathBuf::from("quoted.csv")), + content: b"id;note\n1;\"uses, commas\"\n2;\"pipes | too\"\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("quoted alternate delimiters should remain valid CSV"); + + assert_eq!(summary.delimiter, "semicolon"); + assert_eq!(summary.row_count, 2); + assert_eq!(summary.columns[1].name, "note"); + } + + #[test] + fn auto_delimiter_ignores_quoted_embedded_crlf_and_escaped_quotes() { + let summary = summarize_input( + &InputDocument { + path: Some(PathBuf::from("quoted_crlf.csv")), + content: concat!( + "id,note\r\n", + "1,\"alpha; beta; gamma\r\n", + "delta; epsilon; zeta with \"\"quoted\"\" text\"\r\n", + "2,\"plain comma row\"\r\n" + ) + .as_bytes() + .to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("quoted CRLF CSV should summarize with comma delimiter"); + + assert_eq!(summary.delimiter, "comma"); + assert!(summary.header_present); + assert_eq!(summary.row_count, 2); + assert_eq!(summary.column_count, 2); + assert_eq!( + summary.columns[1].examples[0], + "alpha; beta; gamma\r\ndelta; epsilon; zeta with \"quoted\" text" + ); + } + + #[test] + fn overwide_csv_rows_are_rejected_before_shape_expansion() { + let mut content = String::new(); + for index in 0..4097 { + if index > 0 { + content.push(','); + } + content.push('x'); + } + content.push('\n'); + + let error = summarize_input( + &InputDocument { + path: Some(PathBuf::from("wide.csv")), + content: content.into_bytes(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect_err("overwide CSV should fail"); + + assert!( + matches!(error, CliError::Runtime(message) if message.contains("too many CSV columns")) + ); + } + + #[test] + fn huge_csv_fields_are_rejected_with_a_bounded_diagnostic() { + let mut content = b"name\n".to_vec(); + content.extend(std::iter::repeat_n(b'a', 1_048_577)); + content.push(b'\n'); + + let error = summarize_input( + &InputDocument { + path: Some(PathBuf::from("huge.csv")), + content, + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect_err("huge field should fail"); + + assert!( + matches!(error, CliError::Runtime(message) if message.contains("CSV field is too large")) + ); + } + + #[test] + fn render_summary_is_compact() { + let text = render_summary(&CsvSummary { + path: Some("sample.csv".to_string()), + delimiter: "comma".to_string(), + header_present: true, + row_count: 2, + column_count: 1, + columns: vec![ColumnSummary { + name: "age".to_string(), + inferred_kind: ColumnKind::Integer, + null_count: 0, + non_null_count: 2, + unique_values_estimate: 2, + examples: vec!["34".to_string(), "29".to_string()], + }], + }); + + assert!(text.contains("path=sample.csv")); + assert!(text.contains("column=age")); + assert!(text.contains("examples=34|29")); + } + + #[test] + fn render_summary_omits_empty_examples_field() { + let text = render_summary(&CsvSummary { + path: Some("sample.csv".to_string()), + delimiter: "comma".to_string(), + header_present: true, + row_count: 2, + column_count: 1, + columns: vec![ColumnSummary { + name: "age".to_string(), + inferred_kind: ColumnKind::Integer, + null_count: 0, + non_null_count: 2, + unique_values_estimate: 2, + examples: Vec::new(), + }], + }); + + assert!(text.contains("column=age")); + assert!(!text.contains("examples=")); + } + + #[test] + fn parsing_and_validation_cover_common_flags() { + let (outcome, _) = parse_cli_from(["csvshape", "--help"]).expect("help"); + assert_eq!(outcome, ParseOutcome::Help); + + let (_, cli) = parse_cli_from([ + "csvshape", + "--json", + "--delimiter", + "tab", + "--sample-rows", + "4", + "--limit-columns", + "2", + "--no-examples", + "sample.tsv", + ]) + .expect("cli"); + assert!(cli.common.json); + assert_eq!(cli.delimiter_mode, DelimiterMode::Tab); + assert_eq!(cli.sample_rows, 4); + assert_eq!(cli.limit_columns, Some(2)); + assert!(!cli.include_examples); + } + + #[test] + fn delimiter_and_header_helpers_cover_more_shapes() { + assert_eq!( + parse_delimiter_mode("pipe").expect("pipe"), + DelimiterMode::Pipe + ); + assert!(matches!( + parse_delimiter_mode("weird"), + Err(CliError::Usage(message)) if message.contains("invalid --delimiter") + )); + assert_eq!(delimiter_label(b';'), "semicolon"); + assert!(detect_header(b"name,age\nAda,30\n", b',').expect("header")); + assert!(!detect_header(b"10,20\n30,40\n", b',').expect("no header")); + assert!(is_header_like_name("build-count")); + assert!(!is_header_like_name("bad!name")); + assert_eq!(render_examples(&[]), "-"); + } + + #[test] + fn helper_inference_and_validation_cover_more_branches() { + assert_eq!(infer_value_kind("true"), ColumnKind::Boolean); + assert_eq!(infer_value_kind("42"), ColumnKind::Integer); + assert_eq!(infer_value_kind("3.14"), ColumnKind::Float); + assert_eq!( + infer_value_kind("2026-04-21 10:20:30"), + ColumnKind::DateLike + ); + assert_eq!(infer_value_kind("Ada"), ColumnKind::String); + assert!(is_date_like("2026/04/21")); + assert!(!is_date_like("not-a-date")); + assert_eq!(delimiter_label(b'|'), "pipe"); + assert_eq!(delimiter_label(b'?'), "unknown"); + assert!(matches!( + parse_usize_flag("--sample-rows", "nope"), + Err(CliError::Usage(message)) if message.contains("invalid --sample-rows") + )); + assert!(matches!( + parse_positive_usize_flag("--sample-rows", "0"), + Err(CliError::Usage(message)) if message.contains("--sample-rows must be greater than 0") + )); + } + + #[test] + fn summarize_and_load_inputs_cover_no_header_limits_and_errors() { + let temp_dir = std::env::temp_dir().join(format!( + "csvshape-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + let csv_path = temp_dir.join("sample.csv"); + fs::write(&csv_path, "10;true;2026-04-21\n20;false;2026-04-22\n").expect("csv"); + + let cli = Cli { + common: common_args(false, InputFormat::Lines), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Semicolon, + sample_rows: 2, + limit_columns: Some(2), + include_examples: false, + paths: vec![csv_path.clone()], + }; + + let loaded = load_inputs(&cli).expect("inputs"); + assert_eq!(loaded.len(), 1); + + let summary = summarize_input(&loaded[0], &cli).expect("summary"); + assert!(!summary.header_present); + assert_eq!(summary.column_count, 3); + assert_eq!(summary.columns[0].name, "column_1"); + assert!( + summary + .columns + .iter() + .all(|column| column.examples.is_empty()) + ); + + let empty_cli = Cli { + common: common_args(false, InputFormat::Lines), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 2, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }; + assert!(matches!( + load_inputs(&empty_cli), + Err(CliError::Usage(message)) if message.contains("provide at least one CSV path") + )); + + let mixed = ColumnAccumulator { + name: "mixed".to_string(), + null_count: 0, + non_null_count: 2, + seen_kinds: [ColumnKind::Integer, ColumnKind::Float] + .into_iter() + .collect(), + unique_values: BTreeSet::new(), + examples: Vec::new(), + }; + assert_eq!(resolve_kind(&mixed), ColumnKind::Float); + + let _ = fs::remove_file(csv_path); + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn parse_cli_supports_diff_subcommand() { + let (_, cli) = parse_cli_from([ + "csvshape", + "diff", + "--delimiter", + "tab", + "--sample-rows", + "2", + "before.tsv", + "after.tsv", + ]) + .expect("cli"); + + assert_eq!(cli.delimiter_mode, DelimiterMode::Tab); + assert_eq!(cli.sample_rows, 2); + assert_eq!( + cli.command, + CommandMode::Diff { + before: PathBuf::from("before.tsv"), + after: PathBuf::from("after.tsv"), + } + ); + } + + #[test] + fn diff_summary_reports_header_and_column_changes() { + let before = summarize_input( + &InputDocument { + path: Some(PathBuf::from("before.csv")), + content: b"name,age\nAda,34\nBob,\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 3, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("before summary"); + let after = summarize_input( + &InputDocument { + path: Some(PathBuf::from("after.csv")), + content: b"name,score\nAda,1\nBob,2\nCara,3\n".to_vec(), + }, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 3, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }, + ) + .expect("after summary"); + + let diff = diff_summaries(&before, &after); + + assert_eq!(diff.row_count_before, 2); + assert_eq!(diff.row_count_after, 3); + assert_eq!(diff.added_columns.len(), 1); + assert_eq!(diff.added_columns[0].name, "score"); + assert_eq!(diff.removed_columns.len(), 1); + assert_eq!(diff.removed_columns[0].name, "age"); + } + + #[test] + fn diff_uses_full_columns_when_display_limit_is_set() { + let cli = Cli { + common: common_args(false, InputFormat::Auto), + command: CommandMode::Summary, + delimiter_mode: DelimiterMode::Comma, + sample_rows: 10, + limit_columns: Some(1), + include_examples: true, + paths: Vec::new(), + }; + let before = summarize_input( + &InputDocument { + path: None, + content: b"first,second\n1,stable\n".to_vec(), + }, + &cli, + ) + .expect("before summary"); + let after = summarize_input( + &InputDocument { + path: None, + content: b"first,second\n1,changed\n".to_vec(), + }, + &cli, + ) + .expect("after summary"); + + let diff = diff_summaries(&before, &after); + + assert_eq!( + diff.changed_columns + .iter() + .map(|change| change.name.as_str()) + .collect::>(), + vec!["second"] + ); + } + + #[test] + fn diff_runs_return_success_even_when_no_schema_changes_exist() { + let temp_dir = std::env::temp_dir().join(format!( + "csvshape-diff-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + let before = temp_dir.join("before.csv"); + let after = temp_dir.join("after.csv"); + fs::write(&before, "name,age\nAda,34\n").expect("before"); + fs::write(&after, "name,age\nBob,29\n").expect("after"); + + let exit = run(&Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Diff { + before: before.clone(), + after: after.clone(), + }, + delimiter_mode: DelimiterMode::Auto, + sample_rows: 4, + limit_columns: None, + include_examples: true, + paths: Vec::new(), + }) + .expect("diff exit"); + assert_eq!(exit, ExitCode::Success); + + let _ = fs::remove_file(before); + let _ = fs::remove_file(after); + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn load_inputs_reads_stdin_path_streams_as_files() { + let temp_dir = std::env::temp_dir().join(format!( + "csvshape-stdin-paths-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + let first = temp_dir.join("first.csv"); + let second = temp_dir.join("second.tsv"); + fs::write(&first, "name,age\nAda,34\n").expect("first"); + fs::write(&second, "name\tage\nBob\t29\n").expect("second"); + + let paths = read_existing_stdin_paths( + &format!("{}\n{}\n", first.display(), second.display()), + common::InputFormat::Lines, + "csvshape", + ) + .expect("stdin paths") + .expect("detected"); + let loaded = paths + .iter() + .map(load_input_from_path) + .collect::, _>>() + .expect("loaded"); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].path.as_deref(), Some(first.as_path())); + assert_eq!(loaded[1].path.as_deref(), Some(second.as_path())); + + let _ = fs::remove_file(first); + let _ = fs::remove_file(second); + let _ = fs::remove_dir_all(temp_dir); + } +} diff --git a/crates/csvshape/src/main.rs b/crates/csvshape/src/main.rs new file mode 100644 index 0000000..579d44b --- /dev/null +++ b/crates/csvshape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `csvshape`. + +fn main() { + std::process::exit(csvshape::main_entry()); +} diff --git a/crates/csvshape/tests/csvshape_cli.rs b/crates/csvshape/tests/csvshape_cli.rs new file mode 100644 index 0000000..2ff44a0 --- /dev/null +++ b/crates/csvshape/tests/csvshape_cli.rs @@ -0,0 +1,45 @@ +//! Integration tests for the `csvshape` command. + +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("csvshape").expect("binary") +} + +#[test] +fn summarizes_csv_as_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.csv"); + fs::write( + &path, + "name,age,city\nAda,34,London\nBob,,Paris\nCara,29,Paris\n", + ) + .expect("fixture"); + + cargo_command() + .arg("--json") + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("\"row_count\":3")) + .stdout(predicate::str::contains("\"column_count\":3")) + .stdout(predicate::str::contains("\"name\":\"age\"")); +} + +#[test] +fn help_includes_csv_examples() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "csvshape [OPTIONS] diff ", + )) + .stdout(predicate::str::contains("--sample-rows")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("csvshape")); +} diff --git a/crates/defsnip/Cargo.toml b/crates/defsnip/Cargo.toml new file mode 100644 index 0000000..7deed00 --- /dev/null +++ b/crates/defsnip/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "defsnip" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Extract full code definitions by exact symbol name with AST-backed matching." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/defsnip/src/lib.rs b/crates/defsnip/src/lib.rs new file mode 100644 index 0000000..5bdad2e --- /dev/null +++ b/crates/defsnip/src/lib.rs @@ -0,0 +1,672 @@ +//! The `defsnip` command extracts full AST-backed definitions by exact symbol name. + +use codeindex::{ + CodeIndexer, CodeLanguage, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind, detect_language, + parse_language_label, +}; +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files, + parse_color_choice, parse_format_choice, parse_input_format, print_json, + print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; + +const HELP: &str = "\ +Extract full code definitions by exact symbol name via the shared codeindex engine. + +Usage: + defsnip [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --lang Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript + --kind Restrict kinds: module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static + --limit Maximum number of matching definitions to emit + --allow-empty Exit 0 when no matching definitions are found + --parents Include parent-chain metadata in text output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + defsnip helper .\\fixtures\\polyglot\\repo + defsnip build_report . --json | ConvertFrom-Json + defsnip --kind method --parents Build .\\fixtures\\polyglot\\repo + '.\\fixtures\\polyglot\\repo\\web\\app.ts' | defsnip helper --json | ConvertFrom-Json + +Notes: + exact-name lookups can return multiple definitions; narrow with PATH, --kind, or --lang + +JSON fields: + engine, path, language, kind, name, qualified_name, signature, start_line, end_line, text +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + symbol: String, + paths: Vec, + languages: Option>, + kinds: Option>, + limit: usize, + allow_empty: bool, + parents: bool, +} + +#[derive(Debug, Clone)] +struct CliDraft { + common: CommonArgs, + paths: Vec, + languages: Option>, + kinds: Option>, + limit: usize, + allow_empty: bool, + parents: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("defsnip {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut draft = CliDraft { + common: CommonArgs::default(), + paths: Vec::new(), + languages: None, + kinds: None, + limit: 20, + allow_empty: false, + parents: false, + }; + let mut symbol = None::; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok((ParseOutcome::Help, draft.clone().into_cli(String::new()))); + } + Long("version") | Short('V') => { + return Ok((ParseOutcome::Version, draft.clone().into_cli(String::new()))); + } + Long("json") => draft.common.set_render_mode(RenderMode::Json), + Long("toon") => draft.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + draft.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + draft.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + draft.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => draft.common.quiet = true, + Long("lang") => { + draft.languages = Some(parse_language_list(&parser_value_string( + &mut parser, + "--lang", + )?)?); + } + Long("kind") => { + draft.kinds = Some(parse_kind_list(&parser_value_string( + &mut parser, + "--kind", + )?)?); + } + Long("limit") => { + draft.limit = parse_positive_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?; + } + Long("allow-empty") => draft.allow_empty = true, + Long("parents") => draft.parents = true, + ArgValue(value) => { + if symbol.is_none() { + symbol = Some(os_value_string(value, "symbol")?); + } else { + draft.paths.push(PathBuf::from(value)); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let symbol = + symbol.ok_or_else(|| CliError::usage("provide an exact symbol name to extract"))?; + Ok((ParseOutcome::Run, draft.into_cli(symbol))) +} + +impl CliDraft { + fn into_cli(self, symbol: String) -> Cli { + Cli { + common: self.common, + symbol, + paths: self.paths, + languages: self.languages, + kinds: self.kinds, + limit: self.limit, + allow_empty: self.allow_empty, + parents: self.parents, + } + } +} + +fn run(cli: &Cli) -> Result { + let matches = collect_matches(cli)?; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&matches)?, + RenderMode::Toon => print_structured(&matches, RenderMode::Toon)?, + RenderMode::Text => { + if matches.is_empty() { + if !cli.common.quiet { + if cli.allow_empty { + println!("0 matches"); + } else { + println!("0 matches (use --allow-empty to exit 0)"); + } + } + } else { + print!("{}", render_text(&matches, cli.parents, !cli.common.quiet)); + } + } + } + + Ok(if matches.is_empty() { + if cli.allow_empty { + ExitCode::Success + } else { + ExitCode::NoResults + } + } else { + ExitCode::Success + }) +} + +fn collect_matches(cli: &Cli) -> Result, CliError> { + let roots = collect_roots(cli)?; + let files = discover_supported_files(&roots)?; + let mut indexer = CodeIndexer::new(); + let mut matches = Vec::new(); + + for path in files { + let source = fs::read_to_string(&path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let symbols = indexer.index_source(&path, &source)?; + for symbol in symbols { + if !matches_symbol(&symbol, cli) { + continue; + } + matches.push(symbol); + if matches.len() >= cli.limit { + break; + } + } + if matches.len() >= cli.limit { + break; + } + } + + Ok(matches) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_value_string(value, flag) +} + +fn os_value_string(value: OsString, flag: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn parse_language_list(value: &str) -> Result, CliError> { + let mut languages = BTreeSet::new(); + for raw in split_csv_values(value) { + let language = parse_language_label(raw).ok_or_else(|| { + CliError::usage(format!( + "invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}" + )) + })?; + let _ = languages.insert(language); + } + Ok(languages) +} + +fn parse_kind_list(value: &str) -> Result, CliError> { + let mut kinds = BTreeSet::new(); + for raw in split_csv_values(value) { + let kind = match raw { + "module" => SymbolKind::Module, + "namespace" => SymbolKind::Namespace, + "class" => SymbolKind::Class, + "struct" => SymbolKind::Struct, + "enum" => SymbolKind::Enum, + "interface" => SymbolKind::Interface, + "record" => SymbolKind::Record, + "trait" => SymbolKind::Trait, + "impl" => SymbolKind::Impl, + "type_alias" => SymbolKind::TypeAlias, + "function" => SymbolKind::Function, + "method" => SymbolKind::Method, + "constructor" => SymbolKind::Constructor, + "const" => SymbolKind::Const, + "static" => SymbolKind::Static, + other => { + return Err(CliError::usage(format!( + "invalid --kind entry '{other}'; expected module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static" + ))); + } + }; + let _ = kinds.insert(kind); + } + Ok(kinds) +} + +fn split_csv_values(value: &str) -> impl Iterator { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) +} + +fn collect_roots(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let roots = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !roots.is_empty() { + return Ok(roots); + } + } + + if cli.paths.is_empty() { + Ok(vec![PathBuf::from(".")]) + } else { + common::expand_input_patterns(&cli.paths, "defsnip") + } +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "defsnip")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn discover_supported_files(roots: &[PathBuf]) -> Result, CliError> { + collect_matching_files(roots, &|path| detect_language(path).is_some()) +} + +fn matches_symbol(symbol: &IndexedSymbol, cli: &Cli) -> bool { + if symbol.name != cli.symbol { + return false; + } + if let Some(languages) = &cli.languages { + if !languages.contains(&symbol.language) { + return false; + } + } + if let Some(kinds) = &cli.kinds { + if !kinds.contains(&symbol.kind) { + return false; + } + } + true +} + +fn render_text(matches: &[IndexedSymbol], include_parents: bool, include_guidance: bool) -> String { + let mut output = String::new(); + if include_guidance && matches.len() > 1 { + let _ = writeln!( + output, + "matches={} narrow_with=path|--kind|--lang", + matches.len() + ); + let _ = writeln!(output); + } + + for (index, symbol) in matches.iter().enumerate() { + if index > 0 { + let _ = writeln!(output); + } + let _ = writeln!( + output, + "== {}:{}-{} | {} {}", + symbol.path, + symbol.start_line, + symbol.end_line, + language_label(symbol.language), + kind_label(symbol.kind), + ); + let _ = writeln!(output, "qualified: {}", symbol.qualified_name); + if include_parents && !symbol.parents.is_empty() { + let _ = writeln!(output, "parents: {}", symbol.parents.join("::")); + } + let _ = writeln!(output, "{}", symbol.text); + } + output +} + +const fn language_label(language: CodeLanguage) -> &'static str { + match language { + CodeLanguage::Rust + | CodeLanguage::Csharp + | CodeLanguage::Powershell + | CodeLanguage::Python + | CodeLanguage::Go + | CodeLanguage::Java + | CodeLanguage::Javascript + | CodeLanguage::Typescript => language.label(), + } +} + +const fn kind_label(kind: SymbolKind) -> &'static str { + match kind { + SymbolKind::Module => "module", + SymbolKind::Namespace => "namespace", + SymbolKind::Class => "class", + SymbolKind::Struct => "struct", + SymbolKind::Enum => "enum", + SymbolKind::Interface => "interface", + SymbolKind::Record => "record", + SymbolKind::Trait => "trait", + SymbolKind::Impl => "impl", + SymbolKind::TypeAlias => "type_alias", + SymbolKind::Function => "function", + SymbolKind::Method => "method", + SymbolKind::Constructor => "constructor", + SymbolKind::Const => "const", + SymbolKind::Static => "static", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use common::ColorChoice; + use std::fs; + use tempfile::tempdir; + + fn fixture_repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + } + + fn sample_symbol() -> IndexedSymbol { + IndexedSymbol { + engine: codeindex::ENGINE_NAME, + path: "fixture.rs".to_string(), + language: CodeLanguage::Rust, + kind: SymbolKind::Function, + name: "helper".to_string(), + qualified_name: "nested::Widget::helper".to_string(), + signature: "pub fn helper()".to_string(), + parents: vec!["nested".to_string(), "Widget".to_string()], + depth: 2, + start_line: 3, + end_line: 6, + text: "pub fn helper() {\n 42\n}".to_string(), + } + } + + fn cli_for(symbol: &str) -> Cli { + Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Auto, + color: ColorChoice::Never, + quiet: false, + }, + symbol: symbol.to_string(), + paths: vec![fixture_repo()], + languages: None, + kinds: None, + limit: 20, + allow_empty: false, + parents: false, + } + } + + #[test] + fn parse_cli_and_filters_cover_help_version_and_validation() { + assert_eq!( + parse_cli_from(["defsnip", "--help"]).expect("help").0, + ParseOutcome::Help + ); + assert_eq!( + parse_cli_from(["defsnip", "--version"]).expect("version").0, + ParseOutcome::Version + ); + + let (_, cli) = parse_cli_from([ + "defsnip", + "--json", + "--input-format", + "jsonl", + "--color", + "never", + "--lang", + "rust,typescript", + "--kind", + "function,method", + "--limit", + "5", + "--parents", + "helper", + "fixtures/polyglot/repo", + ]) + .expect("parsed cli"); + assert!(cli.common.json); + assert_eq!(cli.common.input_format, InputFormat::Jsonl); + assert_eq!(cli.common.color, ColorChoice::Never); + assert_eq!(cli.limit, 5); + assert!(cli.parents); + assert!( + cli.languages + .as_ref() + .expect("languages") + .contains(&CodeLanguage::Rust) + ); + assert!( + cli.kinds + .as_ref() + .expect("kinds") + .contains(&SymbolKind::Method) + ); + + assert!(parse_cli_from(["defsnip"]).is_err()); + assert!(parse_language_list("lua").is_err()); + assert!(parse_kind_list("macro").is_err()); + assert!(parse_usize_flag("--limit", "nope").is_err()); + assert!(parse_positive_usize_flag("--limit", "0").is_err()); + assert!(os_value_string(OsString::from("helper"), "symbol").is_ok()); + } + + #[test] + fn path_parsing_and_discovery_cover_auto_jsonl_and_missing_paths() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("alpha.rs"); + let second = temp.path().join("beta.ts"); + let third = temp.path().join("plain.rs"); + let fourth = temp.path().join("code.py"); + fs::write(&first, "a").expect("first"); + fs::write(&second, "b").expect("second"); + fs::write(&third, "c").expect("third"); + fs::write(&fourth, "d").expect("fourth"); + let line_paths = parse_paths_from_string( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Lines, + ) + .expect("line paths"); + assert_eq!(line_paths, vec![first, second]); + + let json_paths = parse_paths_from_string( + &format!( + "{{\"path\":{}}}\n", + serde_json::to_string(&third.display().to_string()).expect("json path") + ), + InputFormat::Jsonl, + ) + .expect("json paths"); + assert_eq!(json_paths, vec![third.clone()]); + + let auto_paths = parse_paths_from_string( + &format!("{}\n{}\n", third.display(), fourth.display()), + InputFormat::Auto, + ) + .expect("auto paths"); + assert_eq!(auto_paths, vec![fourth, third]); + + assert!( + discover_supported_files(&[fixture_repo()]) + .expect("discover") + .len() + >= 8 + ); + assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err()); + } + + #[test] + fn collect_matches_render_and_run_cover_success_and_no_results() { + let mut cli = cli_for("helper"); + let matches = collect_matches(&cli).expect("matches"); + assert!(matches.len() >= 4); + assert!(matches.iter().all(|symbol| symbol.name == "helper")); + assert!( + matches + .iter() + .all(|symbol| symbol.engine == codeindex::ENGINE_NAME) + ); + + cli.languages = Some(BTreeSet::from([CodeLanguage::Typescript])); + cli.kinds = Some(BTreeSet::from([SymbolKind::Function])); + cli.limit = 2; + let filtered = collect_matches(&cli).expect("filtered"); + assert!(!filtered.is_empty()); + assert!(filtered.len() <= 2); + assert!( + filtered + .iter() + .all(|symbol| symbol.language == CodeLanguage::Typescript) + ); + + let text = render_text(&[sample_symbol()], true, true); + assert!(text.contains("qualified: nested::Widget::helper")); + assert!(text.contains("parents: nested::Widget")); + assert!(text.contains("pub fn helper()")); + assert_eq!(language_label(CodeLanguage::Powershell), "powershell"); + assert_eq!(language_label(CodeLanguage::Java), "java"); + assert_eq!(kind_label(SymbolKind::Constructor), "constructor"); + + let ambiguous_text = render_text(&[sample_symbol(), sample_symbol()], false, true); + assert!(ambiguous_text.contains("matches=2")); + assert!(ambiguous_text.contains("narrow_with=path|--kind|--lang")); + + let match_cli = cli_for("helper"); + assert_eq!(run(&match_cli).expect("run success"), ExitCode::Success); + + let missing_cli = cli_for("does_not_exist"); + assert_eq!(run(&missing_cli).expect("run empty"), ExitCode::NoResults); + } + + #[test] + fn symbol_matching_honors_language_and_kind_filters() { + let symbol = sample_symbol(); + let mut cli = cli_for("helper"); + assert!(matches_symbol(&symbol, &cli)); + + cli.languages = Some(BTreeSet::from([CodeLanguage::Rust])); + assert!(matches_symbol(&symbol, &cli)); + + cli.languages = Some(BTreeSet::from([CodeLanguage::Python])); + assert!(!matches_symbol(&symbol, &cli)); + + cli.languages = None; + cli.kinds = Some(BTreeSet::from([SymbolKind::Method])); + assert!(!matches_symbol(&symbol, &cli)); + } +} diff --git a/crates/defsnip/src/main.rs b/crates/defsnip/src/main.rs new file mode 100644 index 0000000..b2b2846 --- /dev/null +++ b/crates/defsnip/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `defsnip`. + +fn main() { + std::process::exit(defsnip::main_entry()); +} diff --git a/crates/defsnip/tests/defsnip_cli.rs b/crates/defsnip/tests/defsnip_cli.rs new file mode 100644 index 0000000..51d955a --- /dev/null +++ b/crates/defsnip/tests/defsnip_cli.rs @@ -0,0 +1,85 @@ +//! Integration tests for the `defsnip` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("defsnip").expect("binary") +} + +fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn extracts_exact_symbol_matches_as_json() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("helper") + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains("\"qualified_name\":\"helper\"")) + .stdout(predicate::str::contains( + "\"qualified_name\":\"nested::Widget::helper\"", + )) + .stdout(predicate::str::contains("\"qualified_name\":\"Worker::build\"").not()); +} + +#[test] +fn filters_method_matches_and_renders_text() { + let mut command = cargo_command(); + command + .args(["--kind", "method", "--parents", "Build"]) + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains("RocketBuilder::Build")) + .stdout(predicate::str::contains("NestedThing::Build")) + .stdout(predicate::str::contains("public void Build(string name)")); +} + +#[test] +fn supports_powershell_pipeline_paths() { + let binary = assert_cmd::cargo::cargo_bin("defsnip"); + let path = fixture_path("web/app.ts"); + let script = format!( + "'{}' | & '{}' helper --json | ConvertFrom-Json | Select-Object -ExpandProperty qualified_name", + path.display(), + binary.display() + ); + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("helper")); +} + +#[test] +fn help_includes_examples_and_pipeline_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--kind")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("defsnip helper")); +} diff --git a/crates/diagpick/Cargo.toml b/crates/diagpick/Cargo.toml new file mode 100644 index 0000000..0ac6354 --- /dev/null +++ b/crates/diagpick/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "diagpick" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Extract actionable diagnostics from noisy logs." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/diagpick/src/lib.rs b/crates/diagpick/src/lib.rs new file mode 100644 index 0000000..a68413a --- /dev/null +++ b/crates/diagpick/src/lib.rs @@ -0,0 +1,1186 @@ +//! The `diagpick` command extracts diagnostics from logs. + +use codeindex::{CodeIndexer, IndexedSymbol, detect_language, enclosing_symbol}; +use std::collections::{HashMap, hash_map::Entry}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, BufRead, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const HELP: &str = "\ +Extract actionable diagnostics from noisy logs. + +Usage: + diagpick [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --with-source Attach source context when the file exists + --with-snippet Alias for --with-source + --snip Attach source context when the file exists + --with-definition Alias for --def + --def Attach the enclosing definition when codeindex supports the file + --jsonl Alias for --input-format jsonl + --context Source context lines above and below the hit + --limit Optional maximum number of diagnostics to emit + --severity Restrict output to all, error, warning, or note + --allow-empty Exit 0 when filters leave no diagnostics + -h, --help Show this help text + -V, --version Show the command version + +Examples: + diagpick .\\fixtures\\diag\\rust-errors.txt + bat --style=plain --paging=never .\\fixtures\\diag\\unity-errors.txt | diagpick --snip --json | ConvertFrom-Json + diagpick .\\fixtures\\diag\\rust-errors.txt --def + diagpick .\\fixtures\\diag\\unity-errors.txt --severity error + diagpick .\\fixtures\\diag\\unity-errors.txt --severity warning + diagpick .\\fixtures\\diag\\unity-errors.txt --severity note --allow-empty + +Notes: + For Unity Player.log or BepInEx LogOutput.log incident grouping, prefer unitydiag; diagpick is best for compiler-style file:line diagnostics. +"; + +/// CLI arguments for the `diagpick` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Attach source context when the referenced file exists. + with_snippet: bool, + /// Attach the enclosing definition when the file can be indexed. + with_definition: bool, + /// Source context lines above and below the diagnostic line. + context: usize, + /// Optional maximum number of diagnostics to emit. + limit: Option, + /// Restrict output to one severity. + severity: SeverityFilter, + /// Exit successfully when the filtered diagnostic set is empty. + allow_empty: bool, + /// Log files to read when stdin is empty. + paths: Vec, +} + +/// Diagnostic severity filter values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SeverityFilter { + /// Return diagnostics of any severity. + All, + /// Return only errors. + Error, + /// Return only warnings. + Warning, + /// Return only notes. + Note, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum Severity { + Error, + Warning, + Note, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SourceLine { + number: usize, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SourceSnippet { + start_line: usize, + end_line: usize, + lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Diagnostic { + path: String, + line: usize, + column: usize, + severity: Severity, + code: Option, + message: String, + tool_hint: String, + source: Option, + snippet: Option, + enclosing_definition: Option, + context_warning: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("diagpick {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + with_snippet: false, + with_definition: false, + context: 1, + limit: None, + severity: SeverityFilter::All, + allow_empty: false, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("with-source" | "with-snippet" | "snip") => cli.with_snippet = true, + Long("with-definition" | "def") => cli.with_definition = true, + Long("jsonl") => cli.common.input_format = InputFormat::Jsonl, + Long("context") => { + cli.context = + parse_usize_flag("--context", &parser_value_string(&mut parser, "--context")?)?; + } + Long("limit") => { + cli.limit = Some(parse_positive_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + Long("severity") => { + cli.severity = + parse_severity_filter(&parser_value_string(&mut parser, "--severity")?)?; + } + Long("allow-empty") => cli.allow_empty = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn parse_severity_filter(value: &str) -> Result { + match value { + "all" => Ok(SeverityFilter::All), + "error" => Ok(SeverityFilter::Error), + "warning" => Ok(SeverityFilter::Warning), + "note" => Ok(SeverityFilter::Note), + other => Err(CliError::usage(format!( + "invalid --severity value '{other}'; expected all, error, warning, or note" + ))), + } +} + +fn run(cli: &Cli) -> Result { + let mut diagnostics = load_diagnostics(cli)?; + diagnostics.retain(|diagnostic| severity_matches(diagnostic.severity, cli.severity)); + if let Some(limit) = cli.limit { + diagnostics.truncate(limit); + } + if cli.with_snippet || cli.with_definition { + diagnostics = attach_context( + diagnostics, + cli.context, + cli.with_snippet, + cli.with_definition, + )?; + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&diagnostics)?, + RenderMode::Toon => print_structured(&diagnostics, RenderMode::Toon)?, + RenderMode::Text => { + if diagnostics.is_empty() { + if !cli.common.quiet { + println!("{}", empty_diagnostics_message(cli.allow_empty)); + } + } else { + print!("{}", render_text(&diagnostics)); + } + } + } + + if diagnostics.is_empty() && cli.allow_empty { + Ok(ExitCode::Success) + } else { + Ok(map_result_count(diagnostics.len())) + } +} + +fn load_diagnostics(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.trim().is_empty() { + return parse_input(&buffer, cli.common.input_format); + } + } + + if cli.paths.is_empty() { + return Err(CliError::usage( + "provide at least one log path or pipe log lines into stdin", + )); + } + + let mut diagnostics = Vec::new(); + for path in &cli.paths { + let content = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let mut parsed = parse_input(&content, cli.common.input_format)?; + resolve_relative_paths(&mut parsed, path); + diagnostics.extend(parsed); + } + Ok(diagnostics) +} + +const fn empty_diagnostics_message(allow_empty: bool) -> &'static str { + if allow_empty { + "0 actionable diagnostics; if this looks like a generic log or transcript, try logshape or fileprobe first" + } else { + "0 actionable diagnostics; if this looks like a generic log or transcript, try logshape or fileprobe first (use --allow-empty to exit 0)" + } +} + +fn resolve_relative_paths(diagnostics: &mut [Diagnostic], log_path: &Path) { + let resolution_roots = candidate_resolution_roots(log_path); + for diagnostic in diagnostics { + diagnostic.path = normalize_path_string(&diagnostic.path); + let raw_path = Path::new(&diagnostic.path); + if raw_path.is_absolute() { + continue; + } + if let Some(resolved) = resolve_path_against_roots(raw_path, &resolution_roots) { + diagnostic.path = normalize_path_string(&resolved.display().to_string()); + } + } +} + +fn candidate_resolution_roots(log_path: &Path) -> Vec { + let mut roots = Vec::new(); + let mut current = log_path.parent(); + while let Some(path) = current { + roots.push(path.to_path_buf()); + current = path.parent(); + } + roots +} + +fn resolve_path_against_roots(path: &Path, roots: &[PathBuf]) -> Option { + roots + .iter() + .map(|root| root.join(path)) + .find(|candidate| candidate.exists()) +} + +fn parse_input(content: &str, input_format: InputFormat) -> Result, CliError> { + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + match input_format { + InputFormat::Lines | InputFormat::Auto => Ok(parse_text_diagnostics(content)), + InputFormat::Jsonl => parse_jsonl_diagnostics(content), + } +} + +fn parse_jsonl_diagnostics(content: &str) -> Result, CliError> { + let mut diagnostics = Vec::new(); + + for (index, line) in io::Cursor::new(content).lines().enumerate() { + let raw = + line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::runtime(format!( + "invalid JSONL diagnostic at line {}: {error}", + index + 1 + )) + })?; + diagnostics.push(parse_json_diagnostic(&value, index + 1)?); + } + + Ok(diagnostics) +} + +fn parse_json_diagnostic(value: &Value, line_number: usize) -> Result { + let Value::Object(object) = value else { + return Err(CliError::runtime(format!( + "JSON diagnostic at line {line_number} must be an object" + ))); + }; + + let path = object + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON diagnostic at line {line_number} missing path" + )) + })? + .to_string(); + let line = object + .get("line") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON diagnostic at line {line_number} missing line" + )) + })?; + let column = object + .get("column") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON diagnostic at line {line_number} missing column" + )) + })?; + let severity = object + .get("severity") + .and_then(Value::as_str) + .and_then(parse_severity) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON diagnostic at line {line_number} missing severity" + )) + })?; + let message = object + .get("message") + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON diagnostic at line {line_number} missing message" + )) + })? + .to_string(); + let code = object + .get("code") + .and_then(Value::as_str) + .map(str::to_string); + let tool_hint = object + .get("tool_hint") + .and_then(Value::as_str) + .unwrap_or("jsonl") + .to_string(); + + Ok(Diagnostic { + path: normalize_path_string(&path), + line, + column, + severity, + code, + message, + tool_hint, + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }) +} + +fn parse_text_diagnostics(content: &str) -> Vec { + let lines = content.lines().collect::>(); + let mut diagnostics = Vec::new(); + let mut index = 0_usize; + + while index < lines.len() { + let line = lines[index]; + if let Some(diagnostic) = parse_csharp_diagnostic(line) { + diagnostics.push(diagnostic); + index += 1; + continue; + } + + if let Some((severity, code, message)) = parse_rust_header(line) { + if let Some(location_line) = lines.get(index + 1) { + if let Some((path, line, column)) = parse_rust_location(location_line) { + diagnostics.push(Diagnostic { + path, + line, + column, + severity, + code, + message, + tool_hint: "rustc".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }); + index += 1; + continue; + } + } + + let tool_hint = tool_hint_for_tool_diagnostic(line); + diagnostics.push(Diagnostic { + path: synthetic_tool_path(tool_hint), + line: 1, + column: 1, + severity, + code, + message, + tool_hint: tool_hint.to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }); + } + + index += 1; + } + + diagnostics +} + +fn parse_rust_header(line: &str) -> Option<(Severity, Option, String)> { + let (severity, trimmed) = [ + (Severity::Error, "error"), + (Severity::Warning, "warning"), + (Severity::Note, "note"), + ] + .into_iter() + .find_map(|(severity, prefix)| line.strip_prefix(prefix).map(|rest| (severity, rest)))?; + + if let Some(code_and_rest) = trimmed.strip_prefix('[') { + let (code, message_with_colon) = code_and_rest.split_once("]:")?; + let message = message_with_colon.trim_start(); + (!message.is_empty()).then(|| (severity, Some(code.to_string()), message.to_string())) + } else { + let message = trimmed.strip_prefix(':')?.trim_start(); + (!message.is_empty()).then(|| (severity, None, message.to_string())) + } +} + +fn parse_rust_location(line: &str) -> Option<(String, usize, usize)> { + let location = line.trim_start().strip_prefix("--> ")?; + parse_path_line_column(location) +} + +fn parse_csharp_diagnostic(line: &str) -> Option { + let (severity, marker) = [ + (Severity::Error, "): error "), + (Severity::Warning, "): warning "), + (Severity::Note, "): note "), + ] + .into_iter() + .find_map(|(severity, marker)| line.find(marker).map(|index| (severity, (index, marker))))?; + let (marker_index, marker_text) = marker; + let location = &line[..marker_index]; + let after_severity = &line[marker_index + marker_text.len()..]; + let left_paren = location.rfind('(')?; + let path = normalize_path_string(&location[..left_paren]); + let coordinates = &location[left_paren + 1..]; + let (line_number, column_number) = coordinates.split_once(',')?; + let line = parse_positive_usize(line_number)?; + let column = parse_positive_usize(column_number)?; + let (code, message) = after_severity.split_once(": ")?; + Some(Diagnostic { + path, + line, + column, + severity, + code: Some(code.to_string()), + message: message.to_string(), + tool_hint: "csharp".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }) +} + +fn parse_severity(raw: &str) -> Option { + match raw { + "error" => Some(Severity::Error), + "warning" => Some(Severity::Warning), + "note" => Some(Severity::Note), + _ => None, + } +} + +fn severity_matches(severity: Severity, filter: SeverityFilter) -> bool { + match filter { + SeverityFilter::All => true, + SeverityFilter::Error => severity == Severity::Error, + SeverityFilter::Warning => severity == Severity::Warning, + SeverityFilter::Note => severity == Severity::Note, + } +} + +fn attach_context( + diagnostics: Vec, + context: usize, + with_snippet: bool, + with_definition: bool, +) -> Result, CliError> { + let mut cache = HashMap::>::new(); + let mut definition_cache = HashMap::>::new(); + let mut indexer = CodeIndexer::new(); + diagnostics + .into_iter() + .map(|diagnostic| { + attach_source_cached( + diagnostic, + context, + with_snippet, + with_definition, + &mut cache, + &mut definition_cache, + &mut indexer, + ) + }) + .collect() +} + +#[cfg(test)] +fn attach_source(mut diagnostic: Diagnostic, context: usize) -> Result { + let mut cache = HashMap::>::new(); + let mut definition_cache = HashMap::>::new(); + let mut indexer = CodeIndexer::new(); + diagnostic = attach_source_cached( + diagnostic, + context, + true, + false, + &mut cache, + &mut definition_cache, + &mut indexer, + )?; + Ok(diagnostic) +} + +fn attach_source_cached( + mut diagnostic: Diagnostic, + context: usize, + with_snippet: bool, + with_definition: bool, + cache: &mut HashMap>, + definition_cache: &mut HashMap>, + indexer: &mut CodeIndexer, +) -> Result { + if is_virtual_tool_path(&diagnostic.path) { + return Ok(diagnostic); + } + + let source_text = match cache.entry(diagnostic.path.clone()) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let path = Path::new(&diagnostic.path); + if path.exists() { + let source_text = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", diagnostic.path)) + })?; + entry.insert(Some(source_text)) + } else { + entry.insert(None) + } + } + }; + + if let Some(source_text) = source_text { + let lines = source_text.lines().collect::>(); + let snippet = build_source_snippet(&lines, diagnostic.line, context); + if with_snippet || with_definition { + diagnostic.source.clone_from(&snippet); + diagnostic.snippet = snippet; + } + if with_definition && detect_language(Path::new(&diagnostic.path)).is_some() { + if !definition_cache.contains_key(&diagnostic.path) { + let cached_file = indexer.index_file(Path::new(&diagnostic.path), source_text)?; + definition_cache.insert(diagnostic.path.clone(), cached_file.symbols().to_vec()); + } + diagnostic.enclosing_definition = definition_cache + .get(&diagnostic.path) + .and_then(|symbols| { + enclosing_symbol(symbols, diagnostic.line, Some(diagnostic.column)) + }) + .cloned(); + } + } else if with_snippet || with_definition { + diagnostic.context_warning = Some(format!( + "source lookup failed for {}; rerun from the repo root or feed absolute paths", + diagnostic.path + )); + } + + Ok(diagnostic) +} + +fn build_source_snippet(lines: &[&str], line: usize, context: usize) -> Option { + if lines.is_empty() || line == 0 { + return None; + } + + let start_line = line.saturating_sub(context).max(1); + let end_line = (line + context).min(lines.len()); + (start_line <= end_line).then(|| SourceSnippet { + start_line, + end_line, + lines: lines[start_line - 1..end_line] + .iter() + .enumerate() + .map(|(offset, text)| SourceLine { + number: start_line + offset, + text: (*text).to_owned(), + }) + .collect::>(), + }) +} + +fn parse_path_line_column(input: &str) -> Option<(String, usize, usize)> { + let mut parts = input.rsplitn(3, ':'); + let column = parse_positive_usize(parts.next()?)?; + let line = parse_positive_usize(parts.next()?)?; + let path = normalize_path_string(parts.next()?); + Some((path, line, column)) +} + +fn parse_positive_usize(input: &str) -> Option { + let trimmed = input.trim(); + (!trimmed.is_empty()) + .then_some(trimmed) + .and_then(|value| value.parse::().ok()) +} + +fn normalize_path_string(path: &str) -> String { + let trimmed = path.strip_prefix(r"\\?\").unwrap_or(path); + if cfg!(windows) { + trimmed.replace('/', "\\") + } else { + trimmed.to_string() + } +} + +fn tool_hint_for_tool_diagnostic(text: &str) -> &'static str { + if text.contains("package ID specification") + || text.contains("did not match any packages") + || text.contains("Cargo.toml") + || text.contains("manifest path") + { + "cargo" + } else { + "tool" + } +} + +fn synthetic_tool_path(tool_hint: &str) -> String { + format!("<{tool_hint}>") +} + +fn is_virtual_tool_path(path: &str) -> bool { + path.starts_with('<') && path.ends_with('>') +} + +fn render_text(diagnostics: &[Diagnostic]) -> String { + let mut rendered = String::new(); + + for (index, diagnostic) in diagnostics.iter().enumerate() { + if index > 0 { + rendered.push('\n'); + } + writeln!( + rendered, + "severity={} code={} path={} line={} column={} tool={} message={}", + severity_label(diagnostic.severity), + diagnostic.code.as_deref().unwrap_or("-"), + diagnostic.path, + diagnostic.line, + diagnostic.column, + diagnostic.tool_hint, + diagnostic.message + ) + .expect("writing to a String cannot fail"); + + if let Some(source) = diagnostic.snippet.as_ref().or(diagnostic.source.as_ref()) { + writeln!( + rendered, + "source_lines={}:{}", + source.start_line, source.end_line + ) + .expect("writing to a String cannot fail"); + for line in &source.lines { + writeln!(rendered, "{}: {}", line.number, line.text) + .expect("writing to a String cannot fail"); + } + } + if let Some(definition) = &diagnostic.enclosing_definition { + writeln!( + rendered, + "definition={} lines={}:{}", + definition.qualified_name, definition.start_line, definition.end_line + ) + .expect("writing to a String cannot fail"); + for (offset, line) in definition.text.lines().enumerate() { + writeln!(rendered, "{}: {}", definition.start_line + offset, line) + .expect("writing to a String cannot fail"); + } + } + if let Some(warning) = &diagnostic.context_warning { + writeln!(rendered, "context_warning={warning}") + .expect("writing to a String cannot fail"); + } + } + + rendered +} + +const fn severity_label(severity: Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Note => "note", + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use common::ColorChoice; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parses_rust_and_csharp_text_diagnostics() { + let rust = parse_text_diagnostics( + "error[E0425]: cannot find value `x` in this scope\n --> src/main.rs:12:7\n", + ); + assert_eq!(rust.len(), 1); + assert_eq!(rust[0].code.as_deref(), Some("E0425")); + assert_eq!(rust[0].tool_hint, "rustc"); + + let csharp = parse_text_diagnostics( + "fixtures\\reading\\sample.cs(9,17): error CS0103: The name 'speeed' does not exist in the current context\n", + ); + assert_eq!(csharp.len(), 1); + assert_eq!(csharp[0].code.as_deref(), Some("CS0103")); + assert_eq!(csharp[0].tool_hint, "csharp"); + + let windows_rust = + parse_text_diagnostics("warning: unused variable\n --> C:\\repo\\src\\main.rs:8:3\n"); + assert_eq!(windows_rust.len(), 1); + assert_eq!(windows_rust[0].path, "C:\\repo\\src\\main.rs"); + + let windows_csharp = parse_text_diagnostics( + "C:\\repo\\Game\\Scripts\\Demo.cs(4,21): warning CS0168: The variable 'unusedValue' is declared but never used\n", + ); + assert_eq!(windows_csharp.len(), 1); + assert_eq!(windows_csharp[0].path, "C:\\repo\\Game\\Scripts\\Demo.cs"); + } + + #[test] + fn jsonl_parsing_and_source_attachment_cover_helpers() { + let jsonl = parse_input( + "{\"path\":\"demo.rs\",\"line\":4,\"column\":2,\"severity\":\"warning\",\"message\":\"unused\",\"tool_hint\":\"rustc\"}\n", + InputFormat::Jsonl, + ) + .expect("jsonl diagnostics"); + assert_eq!(jsonl.len(), 1); + assert_eq!(jsonl[0].severity, Severity::Warning); + assert!(severity_matches(Severity::Warning, SeverityFilter::All)); + assert!(!severity_matches(Severity::Note, SeverityFilter::Error)); + + let temp = tempdir().expect("tempdir"); + let source_path = temp.path().join("demo.rs"); + fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("fixture"); + let attached = attach_source( + Diagnostic { + path: source_path.display().to_string(), + line: 2, + column: 1, + severity: Severity::Warning, + code: None, + message: "unused".to_string(), + tool_hint: "rustc".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }, + 1, + ) + .expect("source attach"); + assert_eq!( + attached.source.as_ref().map(|source| source.start_line), + Some(1) + ); + } + + #[test] + fn run_reports_usage_errors_without_input() { + let error = run(&Cli { + common: common_args(false, InputFormat::Auto), + with_snippet: false, + with_definition: false, + context: 1, + limit: None, + severity: SeverityFilter::All, + allow_empty: false, + paths: Vec::new(), + }) + .expect_err("missing input should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("provide at least one log path") + )); + + let rendered = render_text(&[Diagnostic { + path: "demo.rs".to_string(), + line: 4, + column: 2, + severity: Severity::Error, + code: Some("E1".to_string()), + message: "boom".to_string(), + tool_hint: "rustc".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }]); + assert!(rendered.contains("severity=error")); + } + + #[test] + fn json_and_text_parsers_cover_error_and_note_paths() { + let note = parse_text_diagnostics("note: try a different value\n --> src/main.rs:8:3\n"); + assert_eq!(note.len(), 1); + assert_eq!(note[0].severity, Severity::Note); + assert_eq!(severity_label(Severity::Note), "note"); + assert_eq!(parse_severity("boom"), None); + + let invalid_json = parse_input("{\"path\":\"demo.rs\"}\n", InputFormat::Jsonl) + .expect_err("missing fields should fail"); + assert!(matches!( + invalid_json, + CliError::Runtime(message) if message.contains("missing line") + )); + + let non_object = + parse_json_diagnostic(&serde_json::json!("demo"), 4).expect_err("string should fail"); + assert!(matches!( + non_object, + CliError::Runtime(message) if message.contains("must be an object") + )); + + let invalid_capture = + parse_text_diagnostics("demo.cs(9,nope): error CS0103: Missing column value\n"); + assert!(invalid_capture.is_empty()); + } + + #[test] + fn attach_source_render_text_and_run_cover_more_branches() { + let temp = tempdir().expect("tempdir"); + let source_path = temp.path().join("demo.rs"); + fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("fixture"); + + let attached = attach_source( + Diagnostic { + path: source_path.display().to_string(), + line: 2, + column: 1, + severity: Severity::Warning, + code: Some("W1".to_string()), + message: "unused".to_string(), + tool_hint: "rustc".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }, + 1, + ) + .expect("source attach"); + let rendered = render_text(std::slice::from_ref(&attached)); + assert!(rendered.contains("source_lines=1:2")); + assert!(rendered.contains("2: let value = 1;")); + + let missing = attach_source( + Diagnostic { + path: temp.path().join("missing.rs").display().to_string(), + line: 1, + column: 1, + severity: Severity::Error, + code: None, + message: "missing".to_string(), + tool_hint: "rustc".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }, + 2, + ) + .expect("missing path should be ignored"); + assert!(missing.source.is_none()); + assert!(missing.context_warning.is_some()); + + let log_path = temp.path().join("diag.log"); + fs::write( + &log_path, + "warning: unused variable\n --> src/main.rs:8:3\n", + ) + .expect("log"); + let exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + with_snippet: false, + with_definition: false, + context: 1, + limit: Some(1), + severity: SeverityFilter::Error, + allow_empty: false, + paths: vec![log_path], + }) + .expect("warning filtered out"); + assert_eq!(exit, ExitCode::NoResults); + + let empty_render = render_text(&[]); + assert!(empty_render.is_empty()); + assert!(empty_diagnostics_message(false).contains("logshape")); + assert!(empty_diagnostics_message(false).contains("fileprobe")); + assert!(empty_diagnostics_message(false).contains("--allow-empty")); + assert!(!empty_diagnostics_message(true).contains("--allow-empty")); + } + + #[test] + fn allow_empty_promotes_filtered_zero_results_to_success() { + let temp = tempdir().expect("tempdir"); + let log_path = temp.path().join("diag.log"); + fs::write( + &log_path, + "warning: unused variable\n --> src/main.rs:8:3\n", + ) + .expect("log"); + let exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + with_snippet: false, + with_definition: false, + context: 1, + limit: None, + severity: SeverityFilter::Error, + allow_empty: true, + paths: vec![log_path], + }) + .expect("allow empty"); + assert_eq!(exit, ExitCode::Success); + } + + #[test] + fn parse_cli_rejects_zero_limit() { + let error = parse_cli_from(["diagpick", "--limit", "0", "demo.log"]) + .expect_err("zero limit should fail"); + + assert!(matches!( + error, + CliError::Usage(message) if message.contains("--limit must be greater than 0") + )); + } + + #[test] + fn run_applies_limit_before_source_context_attachment() { + let temp = tempdir().expect("tempdir"); + let source = temp.path().join("first.rs"); + let unreadable = temp.path().join("second.rs"); + fs::write(&source, "fn first() {}\n").expect("source"); + fs::create_dir(&unreadable).expect("unreadable directory"); + let log_path = temp.path().join("diag.log"); + fs::write( + &log_path, + format!( + "error: first\n --> {}:1:1\nerror: second\n --> {}:1:1\n", + source.display(), + unreadable.display() + ), + ) + .expect("log"); + + let exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + with_snippet: true, + with_definition: false, + context: 1, + limit: Some(1), + severity: SeverityFilter::All, + allow_empty: false, + paths: vec![log_path], + }) + .expect("limited source attachment"); + + assert_eq!(exit, ExitCode::Success); + } + + #[test] + fn parse_cli_accepts_aliases_and_jsonl_shortcut() { + let (_, cli) = parse_cli_from([ + "diagpick", + "--with-snippet", + "--with-definition", + "--jsonl", + "demo.log", + ]) + .expect("cli"); + assert!(cli.with_snippet); + assert!(cli.with_definition); + assert_eq!(cli.common.input_format, InputFormat::Jsonl); + assert_eq!(cli.paths, vec![PathBuf::from("demo.log")]); + } + + #[test] + fn resolve_relative_paths_walks_log_ancestors() { + let temp = tempdir().expect("tempdir"); + let repo_root = temp.path().join("repo"); + let source_path = repo_root.join("src").join("main.rs"); + fs::create_dir_all(source_path.parent().expect("parent")).expect("src dir"); + fs::create_dir_all(repo_root.join("logs")).expect("logs dir"); + fs::write(&source_path, "fn main() {}\n").expect("source"); + let log_path = repo_root.join("logs").join("build.log"); + fs::write(&log_path, "warning: demo\n --> src/main.rs:1:1\n").expect("log"); + + let mut diagnostics = vec![Diagnostic { + path: "src/main.rs".to_string(), + line: 1, + column: 1, + severity: Severity::Warning, + code: None, + message: "demo".to_string(), + tool_hint: "rustc".to_string(), + source: None, + snippet: None, + enclosing_definition: None, + context_warning: None, + }]; + resolve_relative_paths(&mut diagnostics, &log_path); + assert_eq!(PathBuf::from(&diagnostics[0].path), source_path); + } + + #[test] + fn tool_fallback_and_windows_path_normalization_are_stable() { + let parsed = parse_text_diagnostics( + "error: package ID specification `missing-crate` did not match any packages\n", + ); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].tool_hint, "cargo"); + assert_eq!(parsed[0].path, ""); + assert_eq!(parsed[0].line, 1); + assert_eq!(parsed[0].column, 1); + + let normalized = normalize_path_string(r"\\?\C:/repo/src/main.rs"); + if cfg!(windows) { + assert_eq!(normalized, r"C:\repo\src\main.rs"); + } else { + assert_eq!(normalized, "C:/repo/src/main.rs"); + } + } +} diff --git a/crates/diagpick/src/main.rs b/crates/diagpick/src/main.rs new file mode 100644 index 0000000..9248406 --- /dev/null +++ b/crates/diagpick/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `diagpick`. + +fn main() { + std::process::exit(diagpick::main_entry()); +} diff --git a/crates/diagpick/tests/diagpick_cli.rs b/crates/diagpick/tests/diagpick_cli.rs new file mode 100644 index 0000000..42da4a6 --- /dev/null +++ b/crates/diagpick/tests/diagpick_cli.rs @@ -0,0 +1,178 @@ +//! Integration tests for the `diagpick` command. + +use std::fs; +use std::path::PathBuf; +use tempfile::tempdir; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("diagpick").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn fixture_relative(path: &str) -> PathBuf { + PathBuf::from("fixtures").join(path) +} + +fn fixture(path: &str) -> PathBuf { + workspace_root().join(fixture_relative(path)) +} + +fn relative_to_workspace(path: &str) -> String { + fixture_relative(path).display().to_string() +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn extracts_rust_diagnostics_in_text_mode() { + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg(fixture("diag/rust-errors.txt")) + .assert() + .success() + .stdout(predicate::str::contains("severity=error code=E0425")) + .stdout(predicate::str::contains("sample.rs line=25 column=11")) + .stdout(predicate::str::contains("severity=warning code=-")) + .stdout(predicate::str::contains("sample.rs line=33 column=4")); +} + +#[test] +fn filters_unity_diagnostics_and_attaches_source_as_json() { + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg("--json") + .arg("--severity") + .arg("error") + .arg("--with-source") + .arg("--context") + .arg("1") + .arg(fixture("diag/unity-errors.txt")) + .assert() + .success() + .stdout(predicate::str::contains("\"severity\":\"error\"")) + .stdout(predicate::str::contains("\"code\":\"CS0103\"")) + .stdout(predicate::str::contains("\"path\":\"")) + .stdout(predicate::str::contains("sample.cs")) + .stdout(predicate::str::contains("\"start_line\":8")) + .stdout(predicate::str::contains("ComputeScore")) + .stdout(predicate::str::contains("CS0168").not()); +} + +#[test] +fn supports_powershell_pipeline_input() { + let binary = assert_cmd::cargo::cargo_bin("diagpick"); + let input = relative_to_workspace("diag/unity-errors.txt"); + let script = format!( + "[System.IO.File]::ReadLines('{}') | & '{}' --json", + input, + binary.display() + ); + + let mut command = pwsh_command(script); + command + .current_dir(workspace_root()) + .assert() + .success() + .stdout(predicate::str::contains("\"code\":\"CS0103\"")) + .stdout(predicate::str::contains("\"severity\":\"warning\"")); +} + +#[test] +fn utf8_bom_stdin_still_extracts_first_diagnostic() { + let mut command = cargo_command(); + command + .write_stdin( + "\u{feff}error[E0425]: cannot find value `missing` in this scope\n --> sample.rs:25:11\n", + ) + .assert() + .success() + .stdout(predicate::str::contains("severity=error code=E0425")) + .stdout(predicate::str::contains("sample.rs line=25 column=11")); +} + +#[test] +fn utf8_bom_log_file_still_extracts_diagnostics() { + let dir = tempdir().expect("tempdir"); + let log_path = dir.path().join("bom-rust-errors.txt"); + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend(fs::read(fixture("diag/rust-errors.txt")).expect("fixture")); + fs::write(&log_path, bytes).expect("log fixture"); + + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg(&log_path) + .assert() + .success() + .stdout(predicate::str::contains("severity=error code=E0425")) + .stdout(predicate::str::contains("sample.rs line=25 column=11")); +} + +#[test] +fn invalid_utf8_log_file_reports_read_error() { + let dir = tempdir().expect("tempdir"); + let log_path = dir.path().join("invalid-utf8.log"); + fs::write(&log_path, [0x66, 0x6F, 0x80, 0x6F]).expect("log fixture"); + + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg(&log_path) + .assert() + .failure() + .stderr(predicate::str::contains("failed to read")) + .stderr(predicate::str::contains("invalid-utf8.log")); +} + +#[test] +fn help_includes_diagnostic_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--with-source")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains( + "diagpick .\\fixtures\\diag\\rust-errors.txt", + )); +} + +#[test] +fn emits_tool_fallback_for_actionable_cargo_errors() { + let temp = tempdir().expect("tempdir"); + let log = temp.path().join("cargo.log"); + fs::write( + &log, + "error: package ID specification `missing-crate` did not match any packages\n", + ) + .expect("fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(&log) + .assert() + .success() + .stdout(predicate::str::contains("\"tool_hint\":\"cargo\"")) + .stdout(predicate::str::contains("\"path\":\"\"")); +} diff --git a/crates/dotnetshape/Cargo.toml b/crates/dotnetshape/Cargo.toml new file mode 100644 index 0000000..c94ffbc --- /dev/null +++ b/crates/dotnetshape/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "dotnetshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect .NET project graphs, package references, and MSBuild configuration shape." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +ignore.workspace = true +lexopt.workspace = true +quick-xml.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/dotnetshape/src/lib.rs b/crates/dotnetshape/src/lib.rs new file mode 100644 index 0000000..838b625 --- /dev/null +++ b/crates/dotnetshape/src/lib.rs @@ -0,0 +1,1566 @@ +//! The `dotnetshape` command summarizes static .NET project configuration. +#![allow(clippy::multiple_crate_versions)] + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, +}; +use ignore::WalkBuilder; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; +use serde::Serialize; + +const EVALUATION_MODE: &str = "static_ancestor_merge"; + +/// Command help text. +pub const HELP: &str = "\ +Inspect .NET project graphs, package references, and MSBuild configuration shape. + +Usage: + dotnetshape [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --max-depth Optional traversal depth limit + --hidden Include hidden files and directories + -h, --help Show this help text + -V, --version Show the command version + +Examples: + dotnetshape . + dotnetshape . --json | ConvertFrom-Json + dotnetshape C:\\src\\repo --max-depth 5 +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + max_depth: Option, + include_hidden: bool, + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct DotnetSummary { + root: String, + evaluation_mode: &'static str, + solution_manifests: Vec, + projects: Vec, + project_references: Vec, + reference_hints: Vec, + package_references: Vec, + diagnostics: Vec, + build_bypass_hints: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct DotnetProject { + path: String, + sdk: Option, + target_frameworks: Vec, + assembly_name: String, + output_type: Option, + nullable: Option, + treat_warnings_as_errors: Option, + analysis_mode: Option, + kind: ProjectKind, + kind_reasons: Vec, + output_paths: Vec, + conditioned_properties: Vec, + project_references: Vec, + reference_hints: Vec, + package_references: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ProjectKind { + Production, + Test, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct OutputPathRecord { + kind: String, + path: String, + explicit: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ConditionedProperty { + name: String, + value: String, + condition: String, + source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ProjectReferenceEdge { + from: String, + include: String, + to: String, + resolved: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ReferenceHintEdge { + from: String, + include: String, + hint_path: String, + private: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PackageReferenceRecord { + project: String, + include: String, + version: Option, + version_source: VersionSource, + private_assets: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum VersionSource { + Inline, + Central, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Diagnostic { + kind: String, + severity: String, + message: String, + path: Option, + related_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct BuildBypassHint { + kind: String, + path: String, + line: Option, + evidence: String, +} + +#[derive(Debug, Clone, Default)] +struct MsbuildDocument { + sdk: Option, + properties: BTreeMap, + conditioned_properties: Vec, + project_references: Vec, + reference_hints: Vec, + package_references: Vec, + package_versions: BTreeMap, + csc_task: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawProjectReference { + include: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawReferenceHint { + include: String, + hint_path: Option, + private: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawPackageReference { + include: String, + version: Option, + private_assets: Option, +} + +#[derive(Debug, Clone)] +struct ScanInput { + root: PathBuf, + project_paths: Vec, + solution_manifests: Vec, + solution_exists: bool, + script_paths: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("dotnetshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + max_depth: None, + include_hidden: false, + path: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => cli.common.quiet = true, + Long("hidden") => cli.include_hidden = true, + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("max-depth") => { + cli.max_depth = Some(parse_usize_flag( + "--max-depth", + &parser_value_string(&mut parser, "--max-depth")?, + )?); + } + ArgValue(path) if cli.path.is_none() => cli.path = Some(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + let summary = inspect_dotnet(cli)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&summary)?, + RenderMode::Toon => print_structured(&summary, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_summary(&summary)), + } + Ok(ExitCode::Success) +} + +fn inspect_dotnet(cli: &Cli) -> Result { + let requested = cli + .path + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + if !requested.exists() { + return Err(CliError::runtime(format!( + "dotnet repository path does not exist: {}", + requested.display() + ))); + } + + let input = discover_scan_input(&requested, cli.max_depth, cli.include_hidden)?; + let mut projects = Vec::new(); + let mut project_references = Vec::new(); + let mut reference_hints = Vec::new(); + let mut package_references = Vec::new(); + let mut diagnostics = Vec::new(); + let mut build_bypass_hints = scan_build_bypass_hints( + &input.root, + &input.script_paths, + input.solution_exists, + cli.include_hidden, + ); + + for project_path in &input.project_paths { + let (project, mut project_hints) = inspect_project(&input.root, project_path)?; + project_references.extend(project.project_references.clone()); + reference_hints.extend(project.reference_hints.clone()); + package_references.extend(project.package_references.clone()); + build_bypass_hints.append(&mut project_hints); + projects.push(project); + } + + add_repository_diagnostics( + &input.root, + &projects, + &project_references, + &package_references, + &mut diagnostics, + ); + + projects.sort_by(|left, right| left.path.cmp(&right.path)); + project_references.sort_by(|left, right| { + left.from + .cmp(&right.from) + .then_with(|| left.to.cmp(&right.to)) + .then_with(|| left.include.cmp(&right.include)) + }); + reference_hints.sort_by(|left, right| { + left.from + .cmp(&right.from) + .then_with(|| left.include.cmp(&right.include)) + .then_with(|| left.hint_path.cmp(&right.hint_path)) + }); + package_references.sort_by(|left, right| { + left.project + .cmp(&right.project) + .then_with(|| left.include.cmp(&right.include)) + }); + diagnostics.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.message.cmp(&right.message)) + }); + build_bypass_hints.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.kind.cmp(&right.kind)) + }); + + Ok(DotnetSummary { + root: input.root.display().to_string(), + evaluation_mode: EVALUATION_MODE, + solution_manifests: input.solution_manifests, + projects, + project_references, + reference_hints, + package_references, + diagnostics, + build_bypass_hints, + }) +} + +fn discover_scan_input( + requested: &Path, + max_depth: Option, + include_hidden: bool, +) -> Result { + if requested.is_file() { + let root = requested + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let file_name = requested + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if has_extension_case_insensitive(file_name, "csproj") { + return Ok(ScanInput { + root, + project_paths: vec![requested.to_path_buf()], + solution_manifests: Vec::new(), + solution_exists: false, + script_paths: Vec::new(), + }); + } + if has_extension_case_insensitive(file_name, "sln") + || has_extension_case_insensitive(file_name, "slnx") + { + let solution = relative_to_root(&root, requested); + return discover_directory(&root, max_depth, include_hidden, Some(solution)); + } + return Err(CliError::usage(format!( + "dotnetshape input file must be .csproj, .sln, or .slnx: {}", + requested.display() + ))); + } + + discover_directory(requested, max_depth, include_hidden, None) +} + +fn discover_directory( + root: &Path, + max_depth: Option, + include_hidden: bool, + forced_solution: Option, +) -> Result { + let mut builder = WalkBuilder::new(root); + builder.hidden(!include_hidden); + builder.git_ignore(true); + builder.git_global(true); + builder.git_exclude(true); + if let Some(depth) = max_depth { + builder.max_depth(Some(depth)); + } + + let mut project_paths = Vec::new(); + let mut solution_manifests = forced_solution.into_iter().collect::>(); + let mut script_paths = Vec::new(); + + for entry in builder.build() { + let entry = entry.map_err(|error| { + CliError::runtime(format!("failed to walk dotnet repository: {error}")) + })?; + if !entry.path().is_file() { + continue; + } + let Some(file_name) = entry.path().file_name().and_then(|name| name.to_str()) else { + continue; + }; + if has_extension_case_insensitive(file_name, "csproj") { + project_paths.push(entry.path().to_path_buf()); + } else if has_extension_case_insensitive(file_name, "sln") + || has_extension_case_insensitive(file_name, "slnx") + { + solution_manifests.push(relative_to_root(root, entry.path())); + } else if is_script_or_msbuild_path(file_name) { + script_paths.push(entry.path().to_path_buf()); + } + } + + project_paths.sort(); + solution_manifests.sort(); + solution_manifests.dedup(); + script_paths.sort(); + Ok(ScanInput { + root: root.to_path_buf(), + project_paths, + solution_exists: !solution_manifests.is_empty(), + solution_manifests, + script_paths, + }) +} + +fn inspect_project( + root: &Path, + project_path: &Path, +) -> Result<(DotnetProject, Vec), CliError> { + let project_rel = relative_to_root(root, project_path); + let project_dir = project_path + .parent() + .map_or_else(|| root.to_path_buf(), Path::to_path_buf); + let mut effective_properties = BTreeMap::::new(); + let mut conditioned_properties = Vec::new(); + let mut central_versions = BTreeMap::::new(); + + for path in ancestor_msbuild_files(root, &project_dir, "Directory.Build.props") { + let doc = parse_msbuild_document(root, &path)?; + merge_properties(&mut effective_properties, &doc.properties); + conditioned_properties.extend(doc.conditioned_properties); + } + for path in ancestor_msbuild_files(root, &project_dir, "Directory.Build.targets") { + let doc = parse_msbuild_document(root, &path)?; + merge_properties(&mut effective_properties, &doc.properties); + conditioned_properties.extend(doc.conditioned_properties); + } + for path in ancestor_msbuild_files(root, &project_dir, "Directory.Packages.props") { + let doc = parse_msbuild_document(root, &path)?; + central_versions.extend(doc.package_versions); + } + + let project_doc = parse_msbuild_document(root, project_path)?; + merge_properties(&mut effective_properties, &project_doc.properties); + conditioned_properties.extend(project_doc.conditioned_properties.clone()); + + let target_frameworks = target_frameworks(&effective_properties); + let assembly_name = effective_properties + .get("AssemblyName") + .cloned() + .unwrap_or_else(|| { + project_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("unknown") + .to_string() + }); + let output_paths = output_paths(&effective_properties, &target_frameworks); + let project_references = project_doc + .project_references + .iter() + .map(|reference| resolve_project_reference(root, &project_dir, &project_rel, reference)) + .collect::>(); + let reference_hints = project_doc + .reference_hints + .iter() + .filter_map(|reference| resolve_reference_hint(&project_rel, reference)) + .collect::>(); + let package_references = project_doc + .package_references + .iter() + .map(|package| resolve_package_reference(&project_rel, package, ¢ral_versions)) + .collect::>(); + let (kind, kind_reasons) = + classify_project(&project_rel, &effective_properties, &package_references); + let mut bypass_hints = Vec::new(); + if project_doc.csc_task { + bypass_hints.push(BuildBypassHint { + kind: "csc_task".to_string(), + path: project_rel.clone(), + line: None, + evidence: "".to_string(), + }); + } + + Ok(( + DotnetProject { + path: project_rel, + sdk: project_doc.sdk, + target_frameworks, + assembly_name, + output_type: effective_properties.get("OutputType").cloned(), + nullable: effective_properties.get("Nullable").cloned(), + treat_warnings_as_errors: effective_properties.get("TreatWarningsAsErrors").cloned(), + analysis_mode: effective_properties.get("AnalysisMode").cloned(), + kind, + kind_reasons, + output_paths, + conditioned_properties, + project_references, + reference_hints, + package_references, + }, + bypass_hints, + )) +} + +fn merge_properties(target: &mut BTreeMap, source: &BTreeMap) { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } +} + +fn parse_msbuild_document(root: &Path, path: &Path) -> Result { + let source = relative_to_root(root, path); + let xml = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + parse_msbuild_xml(&xml, &source) +} + +#[allow(clippy::too_many_lines)] +fn parse_msbuild_xml(xml: &str, source: &str) -> Result { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + let mut buffer = Vec::new(); + let mut document = MsbuildDocument::default(); + let mut inside_property_group = false; + let mut property_group_condition: Option = None; + let mut current_text: Option = None; + let mut text_buffer = String::new(); + let mut current_reference: Option = None; + let mut current_package: Option = None; + + loop { + match reader.read_event_into(&mut buffer).map_err(|error| { + CliError::runtime(format!("failed to parse XML in {source}: {error}")) + })? { + Event::Start(start) => { + let name = local_name(start.name().as_ref()); + match name.as_str() { + "Project" if document.sdk.is_none() => { + document.sdk = attr_value(&reader, &start, "Sdk")?; + } + "Project" => {} + "PropertyGroup" => { + inside_property_group = true; + property_group_condition = attr_value(&reader, &start, "Condition")?; + } + "ProjectReference" => { + if let Some(include) = attr_value(&reader, &start, "Include")? { + document + .project_references + .push(RawProjectReference { include }); + } + } + "Reference" => { + current_reference = + attr_value(&reader, &start, "Include")?.map(|include| { + RawReferenceHint { + include, + hint_path: None, + private: None, + } + }); + } + "PackageReference" => { + if let Some(include) = item_identity(&reader, &start)? { + current_package = Some(RawPackageReference { + include, + version: attr_value(&reader, &start, "Version")?, + private_assets: attr_value(&reader, &start, "PrivateAssets")?, + }); + } + } + "PackageVersion" => { + if let (Some(include), Some(version)) = ( + item_identity(&reader, &start)?, + attr_value(&reader, &start, "Version")?, + ) { + document.package_versions.insert(include, version); + } + } + "HintPath" if current_reference.is_some() => { + begin_text(&mut current_text, &mut text_buffer, TextTarget::HintPath); + } + "Private" if current_reference.is_some() => { + begin_text(&mut current_text, &mut text_buffer, TextTarget::Private); + } + "Version" if current_package.is_some() => { + begin_text( + &mut current_text, + &mut text_buffer, + TextTarget::PackageVersion, + ); + } + "PrivateAssets" if current_package.is_some() => { + begin_text( + &mut current_text, + &mut text_buffer, + TextTarget::PrivateAssets, + ); + } + "Csc" => document.csc_task = true, + _ if inside_property_group => { + let condition = attr_value(&reader, &start, "Condition")? + .or_else(|| property_group_condition.clone()); + begin_text( + &mut current_text, + &mut text_buffer, + TextTarget::Property { + name, + condition, + source: source.to_string(), + }, + ); + } + _ => {} + } + } + Event::Empty(empty) => { + let name = local_name(empty.name().as_ref()); + match name.as_str() { + "Project" if document.sdk.is_none() => { + document.sdk = attr_value(&reader, &empty, "Sdk")?; + } + "ProjectReference" => { + if let Some(include) = attr_value(&reader, &empty, "Include")? { + document + .project_references + .push(RawProjectReference { include }); + } + } + "Reference" => { + if let Some(include) = attr_value(&reader, &empty, "Include")? { + document.reference_hints.push(RawReferenceHint { + include, + hint_path: attr_value(&reader, &empty, "HintPath")?, + private: attr_value(&reader, &empty, "Private")? + .as_deref() + .and_then(parse_boolish), + }); + } + } + "PackageReference" => { + if let Some(include) = item_identity(&reader, &empty)? { + document.package_references.push(RawPackageReference { + include, + version: attr_value(&reader, &empty, "Version")?, + private_assets: attr_value(&reader, &empty, "PrivateAssets")?, + }); + } + } + "PackageVersion" => { + if let (Some(include), Some(version)) = ( + item_identity(&reader, &empty)?, + attr_value(&reader, &empty, "Version")?, + ) { + document.package_versions.insert(include, version); + } + } + "Csc" => document.csc_task = true, + _ if inside_property_group => { + let condition = attr_value(&reader, &empty, "Condition")? + .or_else(|| property_group_condition.clone()); + let value = attr_value(&reader, &empty, "Value")?.unwrap_or_default(); + record_property(&mut document, name, value, condition, source); + } + _ => {} + } + } + Event::Text(text) if current_text.is_some() => { + let decoded = text.decode().map_err(|error| { + CliError::runtime(format!("failed to decode XML text in {source}: {error}")) + })?; + text_buffer.push_str(&decoded); + } + Event::End(end) => { + let name = local_name(end.name().as_ref()); + if let Some(target) = current_text.take() { + finish_text_target( + &mut document, + &mut current_reference, + &mut current_package, + target, + text_buffer.trim().to_string(), + source, + ); + text_buffer.clear(); + } + match name.as_str() { + "PropertyGroup" => { + inside_property_group = false; + property_group_condition = None; + } + "Reference" => { + if let Some(reference) = current_reference.take() { + if reference.hint_path.is_some() { + document.reference_hints.push(reference); + } + } + } + "PackageReference" => { + if let Some(package) = current_package.take() { + document.package_references.push(package); + } + } + _ => {} + } + } + Event::Eof => break, + _ => {} + } + buffer.clear(); + } + + Ok(document) +} + +#[derive(Debug, Clone)] +enum TextTarget { + Property { + name: String, + condition: Option, + source: String, + }, + HintPath, + Private, + PackageVersion, + PrivateAssets, +} + +fn begin_text(target: &mut Option, buffer: &mut String, next: TextTarget) { + *target = Some(next); + buffer.clear(); +} + +fn finish_text_target( + document: &mut MsbuildDocument, + current_reference: &mut Option, + current_package: &mut Option, + target: TextTarget, + value: String, + source: &str, +) { + match target { + TextTarget::Property { + name, + condition, + source, + } => record_property(document, name, value, condition, &source), + TextTarget::HintPath => { + if let Some(reference) = current_reference { + reference.hint_path = Some(normalize_slashes(&value)); + } + } + TextTarget::Private => { + if let Some(reference) = current_reference { + reference.private = parse_boolish(&value); + } + } + TextTarget::PackageVersion => { + if let Some(package) = current_package { + package.version = Some(value); + } + } + TextTarget::PrivateAssets => { + if let Some(package) = current_package { + package.private_assets = Some(value); + } + } + } + let _ = source; +} + +fn record_property( + document: &mut MsbuildDocument, + name: String, + value: String, + condition: Option, + source: &str, +) { + if let Some(condition) = condition.filter(|item| !item.trim().is_empty()) { + document.conditioned_properties.push(ConditionedProperty { + name, + value, + condition, + source: source.to_string(), + }); + } else { + document.properties.insert(name, value); + } +} + +fn attr_value( + reader: &Reader<&[u8]>, + start: &BytesStart<'_>, + name: &str, +) -> Result, CliError> { + for attr in start.attributes() { + let attr = + attr.map_err(|error| CliError::runtime(format!("invalid XML attribute: {error}")))?; + if local_name(attr.key.as_ref()).eq_ignore_ascii_case(name) { + let value = attr + .decode_and_unescape_value(reader.decoder()) + .map_err(|error| { + CliError::runtime(format!("invalid XML attribute value: {error}")) + })?; + return Ok(Some(value.into_owned())); + } + } + Ok(None) +} + +fn item_identity( + reader: &Reader<&[u8]>, + start: &BytesStart<'_>, +) -> Result, CliError> { + Ok(attr_value(reader, start, "Include")?.or(attr_value(reader, start, "Update")?)) +} + +fn local_name(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + text.rsplit_once(':') + .map_or_else(|| text.to_string(), |(_, local)| local.to_string()) +} + +fn target_frameworks(properties: &BTreeMap) -> Vec { + if let Some(value) = properties.get("TargetFrameworks") { + return split_msbuild_list(value); + } + properties + .get("TargetFramework") + .map_or_else(Vec::new, |value| split_msbuild_list(value)) +} + +fn split_msbuild_list(value: &str) -> Vec { + value + .split(';') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn output_paths( + properties: &BTreeMap, + target_frameworks: &[String], +) -> Vec { + let mut paths = Vec::new(); + if let Some(value) = properties.get("OutputPath") { + paths.push(OutputPathRecord { + kind: "OutputPath".to_string(), + path: normalize_slashes(value), + explicit: true, + }); + } + if let Some(value) = properties.get("BaseOutputPath") { + paths.push(OutputPathRecord { + kind: "BaseOutputPath".to_string(), + path: normalize_slashes(value), + explicit: true, + }); + } + if paths.iter().all(|path| path.kind != "OutputPath") { + let tfm = target_frameworks + .first() + .map_or("$(TargetFramework)", String::as_str); + paths.push(OutputPathRecord { + kind: "computed".to_string(), + path: format!("bin/$(Configuration)/{tfm}/"), + explicit: false, + }); + } + paths +} + +fn resolve_project_reference( + root: &Path, + project_dir: &Path, + project_rel: &str, + reference: &RawProjectReference, +) -> ProjectReferenceEdge { + let target = normalize_path(&project_dir.join(&reference.include)); + ProjectReferenceEdge { + from: project_rel.to_string(), + include: normalize_slashes(&reference.include), + to: relative_to_root(root, &target), + resolved: target.exists(), + } +} + +fn resolve_reference_hint( + project_rel: &str, + reference: &RawReferenceHint, +) -> Option { + reference + .hint_path + .as_ref() + .map(|hint_path| ReferenceHintEdge { + from: project_rel.to_string(), + include: reference.include.clone(), + hint_path: normalize_slashes(hint_path), + private: reference.private, + }) +} + +fn resolve_package_reference( + project_rel: &str, + package: &RawPackageReference, + central_versions: &BTreeMap, +) -> PackageReferenceRecord { + if let Some(version) = &package.version { + return PackageReferenceRecord { + project: project_rel.to_string(), + include: package.include.clone(), + version: Some(version.clone()), + version_source: VersionSource::Inline, + private_assets: package.private_assets.clone(), + }; + } + if let Some(version) = central_versions.get(&package.include) { + return PackageReferenceRecord { + project: project_rel.to_string(), + include: package.include.clone(), + version: Some(version.clone()), + version_source: VersionSource::Central, + private_assets: package.private_assets.clone(), + }; + } + PackageReferenceRecord { + project: project_rel.to_string(), + include: package.include.clone(), + version: None, + version_source: VersionSource::Missing, + private_assets: package.private_assets.clone(), + } +} + +fn classify_project( + project_rel: &str, + properties: &BTreeMap, + packages: &[PackageReferenceRecord], +) -> (ProjectKind, Vec) { + let mut reasons = Vec::new(); + if properties + .get("IsTestProject") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + reasons.push("IsTestProject=true".to_string()); + } + for package in packages { + if is_test_package(&package.include) { + reasons.push(format!("PackageReference {}", package.include)); + } + } + let lower_path = project_rel.to_ascii_lowercase(); + if lower_path.contains(".tests/") + || lower_path.contains(".test/") + || lower_path.contains("/tests/") + || lower_path.ends_with(".tests.csproj") + || lower_path.ends_with(".test.csproj") + { + reasons.push("test-like project path".to_string()); + } + + if reasons.is_empty() { + (ProjectKind::Production, reasons) + } else { + reasons.sort(); + reasons.dedup(); + (ProjectKind::Test, reasons) + } +} + +fn is_test_package(package: &str) -> bool { + matches!( + package.to_ascii_lowercase().as_str(), + "microsoft.net.test.sdk" | "xunit" | "nunit" | "mstest.testframework" + ) +} + +fn add_repository_diagnostics( + root: &Path, + projects: &[DotnetProject], + references: &[ProjectReferenceEdge], + packages: &[PackageReferenceRecord], + diagnostics: &mut Vec, +) { + let mut assembly_paths = BTreeMap::>::new(); + let mut output_paths = BTreeMap::>::new(); + for project in projects { + assembly_paths + .entry(project.assembly_name.to_ascii_lowercase()) + .or_default() + .push(project.path.clone()); + for output in &project.output_paths { + if output.explicit && output.kind == "OutputPath" { + output_paths + .entry(normalize_output_path(root, &output.path)) + .or_default() + .push(project.path.clone()); + } + } + } + + for (assembly, paths) in assembly_paths { + if paths.len() > 1 { + diagnostics.push(Diagnostic { + kind: "duplicate_assembly_name".to_string(), + severity: "warning".to_string(), + message: format!("duplicate effective AssemblyName '{assembly}'"), + path: None, + related_paths: paths, + }); + } + } + for (output_path, paths) in output_paths { + if paths.len() > 1 { + diagnostics.push(Diagnostic { + kind: "shared_output_path".to_string(), + severity: "warning".to_string(), + message: format!("multiple projects output to '{output_path}'"), + path: None, + related_paths: paths, + }); + } + } + for reference in references.iter().filter(|reference| !reference.resolved) { + diagnostics.push(Diagnostic { + kind: "unresolved_project_reference".to_string(), + severity: "warning".to_string(), + message: format!( + "{} references missing project {}", + reference.from, reference.to + ), + path: Some(reference.from.clone()), + related_paths: vec![reference.to.clone()], + }); + } + for package in packages + .iter() + .filter(|package| package.version_source == VersionSource::Missing) + { + diagnostics.push(Diagnostic { + kind: "missing_package_version".to_string(), + severity: "warning".to_string(), + message: format!( + "{} has PackageReference {} without inline or central version", + package.project, package.include + ), + path: Some(package.project.clone()), + related_paths: Vec::new(), + }); + } +} + +fn normalize_output_path(root: &Path, value: &str) -> String { + let path = Path::new(value); + if path.is_absolute() { + relative_to_root(root, path) + } else { + normalize_slashes(value.trim_end_matches(['/', '\\'])) + } +} + +fn scan_build_bypass_hints( + root: &Path, + script_paths: &[PathBuf], + solution_exists: bool, + include_hidden: bool, +) -> Vec { + let mut hints = Vec::new(); + for path in script_paths { + let relative = relative_to_root(root, path); + if !include_hidden && relative.split('/').any(|part| part.starts_with('.')) { + continue; + } + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("rsp")) + { + hints.push(BuildBypassHint { + kind: "compiler_response_file".to_string(), + path: relative, + line: None, + evidence: ".rsp".to_string(), + }); + continue; + } + let Ok(contents) = fs::read_to_string(path) else { + continue; + }; + for (index, line) in contents.lines().enumerate() { + let lower = line.to_ascii_lowercase(); + let line_number = index + 1; + if lower.contains("csc.exe") { + hints.push(BuildBypassHint { + kind: "csc_exe".to_string(), + path: relative.clone(), + line: Some(line_number), + evidence: line.trim().to_string(), + }); + } + if lower.contains("dotnet exec") && lower.contains("csc.dll") { + hints.push(BuildBypassHint { + kind: "csc_dll".to_string(), + path: relative.clone(), + line: Some(line_number), + evidence: line.trim().to_string(), + }); + } + if lower.contains("vbc") { + hints.push(BuildBypassHint { + kind: "vbc".to_string(), + path: relative.clone(), + line: Some(line_number), + evidence: line.trim().to_string(), + }); + } + if lower.contains("mcs") { + hints.push(BuildBypassHint { + kind: "mcs".to_string(), + path: relative.clone(), + line: Some(line_number), + evidence: line.trim().to_string(), + }); + } + if solution_exists && lower.contains("dotnet build") && lower.contains(".csproj") { + hints.push(BuildBypassHint { + kind: "direct_project_build".to_string(), + path: relative.clone(), + line: Some(line_number), + evidence: line.trim().to_string(), + }); + } + } + } + hints +} + +fn ancestor_msbuild_files(root: &Path, project_dir: &Path, file_name: &str) -> Vec { + let root = normalize_path(root); + let project_dir = normalize_path(project_dir); + let Ok(relative) = project_dir.strip_prefix(&root) else { + let candidate = project_dir.join(file_name); + return candidate + .exists() + .then_some(candidate) + .into_iter() + .collect(); + }; + let mut dirs = vec![root.clone()]; + let mut current = root; + for component in relative.components() { + current.push(component.as_os_str()); + dirs.push(current.clone()); + } + dirs.into_iter() + .map(|dir| dir.join(file_name)) + .filter(|path| path.exists()) + .collect() +} + +fn render_summary(summary: &DotnetSummary) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "dotnetshape {} projects={} diagnostics={} bypass_hints={}", + summary.root, + summary.projects.len(), + summary.diagnostics.len(), + summary.build_bypass_hints.len() + ); + let _ = writeln!(output, "evaluation: {}", summary.evaluation_mode); + if !summary.solution_manifests.is_empty() { + let _ = writeln!( + output, + "solutions: {}", + summary.solution_manifests.join(", ") + ); + } + let _ = writeln!(output, "projects:"); + for project in &summary.projects { + let frameworks = if project.target_frameworks.is_empty() { + "?".to_string() + } else { + project.target_frameworks.join(";") + }; + let _ = writeln!( + output, + " {} [{}] assembly={} kind={:?}", + project.path, frameworks, project.assembly_name, project.kind + ); + } + if summary.diagnostics.is_empty() { + let _ = writeln!(output, "diagnostics: none"); + } else { + let _ = writeln!(output, "diagnostics:"); + for diagnostic in &summary.diagnostics { + let _ = writeln!(output, " {}: {}", diagnostic.kind, diagnostic.message); + } + } + if !summary.build_bypass_hints.is_empty() { + let _ = writeln!(output, "build_bypass_hints:"); + for hint in &summary.build_bypass_hints { + let line = hint.line.map_or(String::new(), |line| format!(":{line}")); + let _ = writeln!(output, " {}{} {}", hint.path, line, hint.kind); + } + } + output +} + +fn has_extension_case_insensitive(file_name: &str, extension: &str) -> bool { + Path::new(file_name) + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(extension)) +} + +fn is_script_or_msbuild_path(file_name: &str) -> bool { + ["ps1", "cmd", "bat", "sh", "targets", "props", "rsp"] + .iter() + .any(|extension| has_extension_case_insensitive(file_name, extension)) +} + +fn parse_boolish(value: &str) -> Option { + if value.eq_ignore_ascii_case("true") { + Some(true) + } else if value.eq_ignore_ascii_case("false") { + Some(false) + } else { + None + } +} + +fn normalize_slashes(value: &str) -> String { + value.replace('\\', "/") +} + +fn relative_to_root(root: &Path, path: &Path) -> String { + let root = normalize_path(root); + let path = normalize_path(path); + let relative = path.strip_prefix(&root).unwrap_or(&path); + normalize_slashes(&relative.to_string_lossy()) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::Normal(part) => normalized.push(part), + } + } + normalized +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use tempfile::TempDir; + + use super::*; + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent directory"); + } + fs::write(path, contents).expect("write fixture"); + } + + fn fixture_repo() -> TempDir { + let temp = tempfile::tempdir().expect("tempdir"); + write_file( + &temp.path().join("Directory.Build.props"), + r" + + enable + true + artifacts/bin/ + +", + ); + write_file( + &temp.path().join("Directory.Packages.props"), + r#" + + + +"#, + ); + write_file( + &temp.path().join("repo.sln"), + "Microsoft Visual Studio Solution File\n", + ); + write_file( + &temp.path().join("src").join("App").join("App.csproj"), + r#" + + net8.0 + SharedAssembly + artifacts/shared/ + disable + + + + + + ..\lib\Legacy.dll + false + + + + + + + +"#, + ); + write_file( + &temp.path().join("src").join("Lib").join("Lib.csproj"), + r#" + + netstandard2.0;net8.0 + SharedAssembly + artifacts/shared/ + + + + +"#, + ); + write_file( + &temp.path().join("build.ps1"), + "Write-Host build\ncsc.exe Program.cs\ndotnet build src/App/App.csproj\n", + ); + temp + } + + #[test] + fn parser_accepts_help_version_and_common_flags() { + let (help, _) = parse_cli_from(["dotnetshape", "--help"]).expect("help"); + assert_eq!(help, ParseOutcome::Help); + let (version, _) = parse_cli_from(["dotnetshape", "--version"]).expect("version"); + assert_eq!(version, ParseOutcome::Version); + let (run, cli) = + parse_cli_from(["dotnetshape", "--json", "--hidden", "--max-depth", "3", "."]) + .expect("run"); + assert_eq!(run, ParseOutcome::Run); + assert_eq!(cli.common.render_mode(), RenderMode::Json); + assert!(cli.include_hidden); + assert_eq!(cli.max_depth, Some(3)); + } + + #[test] + fn parser_rejects_invalid_depth() { + let error = + parse_cli_from(["dotnetshape", "--max-depth", "nope"]).expect_err("invalid depth"); + assert!(error.to_string().contains("invalid --max-depth value")); + } + + #[test] + fn msbuild_parser_keeps_conditioned_properties_out_of_effective_set() { + let document = parse_msbuild_xml( + r#" + + enable + disable + +"#, + "demo.csproj", + ) + .expect("parse"); + assert_eq!(document.sdk.as_deref(), Some("Microsoft.NET.Sdk")); + assert_eq!( + document.properties.get("Nullable").map(String::as_str), + Some("enable") + ); + assert_eq!(document.conditioned_properties.len(), 1); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn inspect_dotnet_reports_static_graph_diagnostics_and_bypass_hints() { + let temp = fixture_repo(); + let cli = Cli { + common: CommonArgs::default(), + max_depth: None, + include_hidden: false, + path: Some(temp.path().to_path_buf()), + }; + + let summary = inspect_dotnet(&cli).expect("summary"); + + assert_eq!(summary.evaluation_mode, EVALUATION_MODE); + assert_eq!(summary.solution_manifests, vec!["repo.sln"]); + assert_eq!(summary.projects.len(), 2); + let app = summary + .projects + .iter() + .find(|project| project.path == "src/App/App.csproj") + .expect("app project"); + assert_eq!(app.sdk.as_deref(), Some("Microsoft.NET.Sdk")); + assert_eq!(app.target_frameworks, vec!["net8.0"]); + assert_eq!(app.assembly_name, "SharedAssembly"); + assert_eq!(app.nullable.as_deref(), Some("enable")); + assert_eq!(app.treat_warnings_as_errors.as_deref(), Some("true")); + assert_eq!(app.output_paths.len(), 2); + assert!( + app.conditioned_properties + .iter() + .any(|property| property.name == "Nullable" + && property.value == "disable" + && property.condition.contains("Release")) + ); + assert!( + summary + .project_references + .iter() + .any(|edge| edge.from == "src/App/App.csproj" + && edge.to == "src/Lib/Lib.csproj" + && edge.resolved) + ); + assert!( + summary + .project_references + .iter() + .any(|edge| edge.from == "src/App/App.csproj" + && edge.to == "src/Missing/Missing.csproj" + && !edge.resolved) + ); + assert!( + summary + .reference_hints + .iter() + .any(|edge| edge.include == "Legacy" + && edge.hint_path == "../lib/Legacy.dll" + && edge.private == Some(false)) + ); + assert!( + summary + .package_references + .iter() + .any(|package| package.include == "Newtonsoft.Json" + && package.version.as_deref() == Some("13.0.3") + && package.version_source == VersionSource::Central) + ); + assert!( + summary + .package_references + .iter() + .any(|package| package.include == "Dapper" + && package.version.as_deref() == Some("2.1.66") + && package.version_source == VersionSource::Inline) + ); + assert!( + summary + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == "unresolved_project_reference") + ); + assert!( + summary + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == "duplicate_assembly_name") + ); + assert!( + summary + .build_bypass_hints + .iter() + .any(|hint| hint.kind == "csc_exe") + ); + assert!( + summary + .build_bypass_hints + .iter() + .any(|hint| hint.kind == "csc_task" && hint.path == "src/App/App.csproj") + ); + + let rendered = render_summary(&summary); + assert!(rendered.contains("dotnetshape ")); + assert!(rendered.contains("projects=2")); + assert!(rendered.contains("src/App/App.csproj")); + assert!(rendered.contains("diagnostic")); + assert!(rendered.contains("build_bypass")); + } +} diff --git a/crates/dotnetshape/src/main.rs b/crates/dotnetshape/src/main.rs new file mode 100644 index 0000000..1cee811 --- /dev/null +++ b/crates/dotnetshape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `dotnetshape`. + +fn main() { + std::process::exit(dotnetshape::main_entry()); +} diff --git a/crates/dotnetshape/tests/dotnetshape_cli.rs b/crates/dotnetshape/tests/dotnetshape_cli.rs new file mode 100644 index 0000000..0c55b48 --- /dev/null +++ b/crates/dotnetshape/tests/dotnetshape_cli.rs @@ -0,0 +1,386 @@ +//! Integration tests for the `dotnetshape` command. + +use std::fs; +use std::path::Path; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::TempDir; + +fn cargo_command() -> Command { + Command::cargo_bin("dotnetshape").expect("binary") +} + +fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent directory"); + } + fs::write(path, contents).expect("write fixture"); +} + +fn json_report(root: &Path) -> Value { + let output = Command::cargo_bin("dotnetshape") + .expect("binary") + .arg("--json") + .arg(root) + .output() + .expect("run dotnetshape"); + assert!( + output.status.success(), + "dotnetshape failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json output") +} + +fn fixture_repo() -> TempDir { + let temp = tempfile::tempdir().expect("tempdir"); + write_file( + &temp.path().join("Directory.Build.props"), + r" + + enable + true + AllEnabledByDefault + artifacts/bin/ + InheritedName + +", + ); + write_file( + &temp.path().join("Directory.Build.targets"), + r" + + Exe + +", + ); + write_file( + &temp.path().join("Directory.Packages.props"), + r#" + + + + +"#, + ); + write_file( + &temp.path().join("repo.sln"), + "Microsoft Visual Studio Solution File\n", + ); + write_file( + &temp.path().join("src").join("App").join("App.csproj"), + r#" + + net8.0 + SharedAssembly + Library + artifacts/shared/ + disable + + + + + + ..\lib\Legacy.dll + false + + + ..\lib\Implicit.dll + + + + + + + + +"#, + ); + write_file( + &temp.path().join("src").join("Lib").join("Lib.csproj"), + r#" + + netstandard2.0;net8.0 + SharedAssembly + artifacts/shared/ + + + + ..\lib\PrivateLegacy.dll + true + + + + +"#, + ); + write_file( + &temp + .path() + .join("tests") + .join("Unit.Tests") + .join("Unit.Tests.csproj"), + r#" + + net8.0 + true + +"#, + ); + write_file( + &temp.path().join("build.ps1"), + "Write-Host build\ncsc.exe Program.cs\ndotnet build src/App/App.csproj\n", + ); + temp +} + +#[test] +fn help_lists_shared_and_repo_options() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--format ")) + .stdout(predicate::str::contains("--json")) + .stdout(predicate::str::contains("--toon")) + .stdout(predicate::str::contains("--max-depth ")) + .stdout(predicate::str::contains("--hidden")) + .stdout(predicate::str::contains("dotnetshape . --json")); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn json_reports_static_project_shape_and_diagnostics() { + let temp = fixture_repo(); + let report = json_report(temp.path()); + + assert_eq!(report["evaluation_mode"], "static_ancestor_merge"); + let projects = report["projects"].as_array().expect("projects"); + assert_eq!(projects.len(), 3); + + let app = projects + .iter() + .find(|project| project["path"] == "src/App/App.csproj") + .expect("app project"); + assert_eq!(app["sdk"], "Microsoft.NET.Sdk"); + assert_eq!(app["target_frameworks"], serde_json::json!(["net8.0"])); + assert_eq!(app["assembly_name"], "SharedAssembly"); + assert_eq!(app["output_type"], "Library"); + assert_eq!(app["nullable"], "enable"); + assert_eq!(app["treat_warnings_as_errors"], "true"); + assert_eq!(app["analysis_mode"], "AllEnabledByDefault"); + assert_eq!(app["kind"], "production"); + assert!( + app["conditioned_properties"] + .as_array() + .expect("conditioned properties") + .iter() + .any(|property| property["name"] == "Nullable" + && property["value"] == "disable" + && property["condition"] + .as_str() + .is_some_and(|condition| condition.contains("Release"))) + ); + + let references = report["project_references"] + .as_array() + .expect("project refs"); + assert!( + references + .iter() + .any(|edge| edge["from"] == "src/App/App.csproj" + && edge["to"] == "src/Lib/Lib.csproj" + && edge["resolved"] == true) + ); + assert!( + references + .iter() + .any(|edge| edge["from"] == "src/App/App.csproj" + && edge["to"] == "src/Missing/Missing.csproj" + && edge["resolved"] == false) + ); + + let hint_refs = report["reference_hints"].as_array().expect("hint refs"); + assert!(hint_refs.iter().any(|edge| edge["include"] == "Legacy" + && edge["hint_path"] == "../lib/Legacy.dll" + && edge["private"] == false)); + assert!( + hint_refs + .iter() + .any(|edge| edge["include"] == "Implicit" && edge["private"].is_null()) + ); + assert!( + hint_refs + .iter() + .any(|edge| edge["include"] == "PrivateLegacy" && edge["private"] == true) + ); + + let packages = report["package_references"].as_array().expect("packages"); + assert!( + packages + .iter() + .any(|package| package["include"] == "Newtonsoft.Json" + && package["version"] == "13.0.3" + && package["version_source"] == "central") + ); + assert!(packages.iter().any(|package| package["include"] == "Dapper" + && package["version"] == "2.1.66" + && package["version_source"] == "inline")); + assert!( + packages + .iter() + .any(|package| package["include"] == "Missing.Version" + && package["version"].is_null() + && package["version_source"] == "missing") + ); + assert!(packages.iter().any(|package| package["include"] == "xunit" + && package["version"] == "2.9.2" + && package["version_source"] == "central")); + + assert!(projects.iter().any(|project| { + project["path"] == "src/Lib/Lib.csproj" + && project["kind"] == "test" + && project["kind_reasons"] + .as_array() + .expect("kind reasons") + .iter() + .any(|reason| { + reason + .as_str() + .is_some_and(|value| value.contains("Microsoft.NET.Test.Sdk")) + }) + })); + assert!(projects.iter().any(|project| { + project["path"] == "tests/Unit.Tests/Unit.Tests.csproj" + && project["kind"] == "test" + && project["kind_reasons"] + .as_array() + .expect("kind reasons") + .iter() + .any(|reason| { + reason + .as_str() + .is_some_and(|value| value.contains("IsTestProject")) + }) + })); + + let diagnostics = report["diagnostics"].as_array().expect("diagnostics"); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic["kind"] == "duplicate_assembly_name") + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic["kind"] == "shared_output_path") + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic["kind"] == "unresolved_project_reference") + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic["kind"] == "missing_package_version") + ); + + let bypass_hints = report["build_bypass_hints"] + .as_array() + .expect("bypass hints"); + assert!( + bypass_hints + .iter() + .any(|hint| hint["kind"] == "csc_task" && hint["path"] == "src/App/App.csproj") + ); + assert!( + bypass_hints.iter().any(|hint| hint["kind"] == "csc_exe" + && hint["path"] == "build.ps1" + && hint["line"] == 2) + ); + assert!( + bypass_hints + .iter() + .any(|hint| hint["kind"] == "direct_project_build" + && hint["path"] == "build.ps1" + && hint["line"] == 3) + ); +} + +#[test] +fn max_depth_and_hidden_control_project_discovery() { + let temp = tempfile::tempdir().expect("tempdir"); + write_file( + &temp.path().join("Root.csproj"), + r"net8.0", + ); + write_file( + &temp.path().join("deep").join("Nested.csproj"), + r"net8.0", + ); + write_file( + &temp.path().join(".hidden").join("Hidden.csproj"), + r"net8.0", + ); + + let shallow = json_report_with_args(temp.path(), &["--max-depth", "1"]); + assert_eq!(shallow["projects"].as_array().expect("projects").len(), 1); + assert!( + shallow["projects"] + .as_array() + .expect("projects") + .iter() + .all(|project| project["path"] != "deep/Nested.csproj" + && project["path"] != ".hidden/Hidden.csproj") + ); + + let hidden = json_report_with_args(temp.path(), &["--hidden"]); + assert!( + hidden["projects"] + .as_array() + .expect("projects") + .iter() + .any(|project| project["path"] == ".hidden/Hidden.csproj") + ); +} + +#[test] +fn text_and_toon_outputs_are_structured() { + let temp = fixture_repo(); + let mut text = cargo_command(); + text.arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("dotnetshape")) + .stdout(predicate::str::contains("projects:")) + .stdout(predicate::str::contains("diagnostics:")); + + let mut toon = cargo_command(); + toon.arg("--toon") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("evaluation_mode")) + .stdout(predicate::str::contains("projects")); +} + +fn json_report_with_args(root: &Path, args: &[&str]) -> Value { + let mut command = cargo_command(); + command.arg("--json"); + for arg in args { + command.arg(arg); + } + let output = command.arg(root).output().expect("run dotnetshape"); + assert!( + output.status.success(), + "dotnetshape failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json output") +} diff --git a/crates/envdiff/Cargo.toml b/crates/envdiff/Cargo.toml new file mode 100644 index 0000000..10a8aed --- /dev/null +++ b/crates/envdiff/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "envdiff" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Capture and compare environment variable state for AI-friendly debugging." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +serde.workspace = true +serde_json.workspace = true +windowsupport = { path = "../windowsupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/envdiff/src/lib.rs b/crates/envdiff/src/lib.rs new file mode 100644 index 0000000..c0e7aca --- /dev/null +++ b/crates/envdiff/src/lib.rs @@ -0,0 +1,1624 @@ +//! The `envdiff` command captures and compares environment-variable snapshots. +#![allow(clippy::multiple_crate_versions)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs::{self, OpenOptions}; +use std::io::Write as IoWrite; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Instant; +use std::time::{SystemTime, UNIX_EPOCH}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_format_choice, print_json, + print_quick_help_error, print_structured, +}; +use serde::Serialize; +use windowsupport::{ + EnvironmentDiff, EnvironmentSnapshot, capture_environment, diff_environments, + read_environment_file, +}; + +const HELP: &str = "\ +Capture and compare environment-variable state with compact AI-friendly output. + +Windows only. + +Usage: + envdiff [OPTIONS] snapshot [--output ] + envdiff [OPTIONS] diff + envdiff [OPTIONS] run --shell cmd|pwsh|raw -- + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + envdiff snapshot --output before.json + envdiff diff before.json after.json --json | ConvertFrom-Json + envdiff run --shell cmd -- .\\build.cmd + envdiff run --shell pwsh -- .\\script.ps1 + envdiff run --shell pwsh -- '& { $env:FOO=''bar'' }' + +Notes: + Place envdiff flags before '--'; everything after '--' is passed to the child command unchanged. +"; + +const CMD_WRAPPER_NOISE_NAMES: &[&str] = &["MERCURY_ENV_EXITCODE", "PROMPT"]; +const OUTPUT_TAIL_BYTES: usize = 4096; +const PATH_LIKE_VALUE_PREVIEW_LIMIT: usize = 6; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: CommandMode, +} + +#[derive(Debug, Clone)] +enum CommandMode { + Snapshot { + output: Option, + }, + Diff { + before: PathBuf, + after: PathBuf, + }, + Run { + shell: ShellMode, + command: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShellMode { + Cmd, + Pwsh, + Raw, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SnapshotFile { + #[serde(flatten)] + values: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RunCommandOutput { + shell: String, + command: String, + exit_code: Option, + duration_ms: u128, + stdout_tail: String, + stderr_tail: String, + ok: bool, + capture_ok: bool, + capture_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RunDiffOutput { + #[serde(flatten)] + diff: EnvironmentDiff, + run: RunCommandOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CapturedRun { + snapshot: Option, + exit_code: Option, + stdout: Vec, + stderr: Vec, + duration_ms: u128, + capture_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CapturedCommandOutput { + exit_code: Option, + stdout: Vec, + stderr: Vec, + duration_ms: u128, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("envdiff {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut arguments = args.into_iter().map(Into::into).collect::>(); + if !arguments.is_empty() { + let _ = arguments.remove(0); + } + let mut common = CommonArgs::default(); + let mut index = 0_usize; + while index < arguments.len() { + let text = os_to_string(arguments[index].clone(), "argument")?; + match text.as_str() { + "-h" | "--help" => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + command: CommandMode::Snapshot { output: None }, + }, + )); + } + "-V" | "--version" => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + command: CommandMode::Snapshot { output: None }, + }, + )); + } + "--json" => { + common.set_render_mode(RenderMode::Json); + index += 1; + } + "--toon" => { + common.set_render_mode(RenderMode::Toon); + index += 1; + } + "--format" => { + let Some(value) = arguments.get(index + 1).cloned() else { + return Err(CliError::usage("--format requires a value")); + }; + common.set_render_mode(parse_format_choice(&os_to_string(value, "--format")?)?); + index += 2; + } + "--quiet" => { + common.quiet = true; + index += 1; + } + "--color" => { + let Some(value) = arguments.get(index + 1).cloned() else { + return Err(CliError::usage("--color requires a value")); + }; + common.color = common::parse_color_choice(&os_to_string(value, "--color")?)?; + index += 2; + } + _ => break, + } + } + + let remaining = arguments.split_off(index); + if remaining.is_empty() { + return Err(CliError::usage( + "provide a subcommand: snapshot, diff, or run", + )); + } + + let command = parse_command_mode(remaining, &mut common)?; + Ok((ParseOutcome::Run, Cli { common, command })) +} + +fn parse_command_mode( + arguments: Vec, + common: &mut CommonArgs, +) -> Result { + let mut iter = arguments.into_iter(); + let mut subcommand = None::; + let mut pending = Vec::::new(); + + while let Some(argument) = iter.next() { + let text = os_to_string(argument.clone(), "subcommand")?; + match text.as_str() { + "--json" => common.set_render_mode(RenderMode::Json), + "--toon" => common.set_render_mode(RenderMode::Toon), + "--format" => { + let Some(value) = iter.next() else { + return Err(CliError::usage("--format requires a value")); + }; + common.set_render_mode(parse_format_choice(&os_to_string(value, "--format")?)?); + } + "--quiet" => common.quiet = true, + "--color" => { + let Some(value) = iter.next() else { + return Err(CliError::usage("--color requires a value")); + }; + common.color = common::parse_color_choice(&os_to_string(value, "--color")?)?; + } + "snapshot" | "diff" | "run" if subcommand.is_none() => { + subcommand = Some(text); + pending.extend(iter); + break; + } + _ if subcommand.is_none() => { + return Err(CliError::usage( + "unknown subcommand; expected snapshot, diff, or run", + )); + } + _ => pending.push(argument), + } + } + + let subcommand = subcommand + .ok_or_else(|| CliError::usage("provide a subcommand: snapshot, diff, or run"))?; + + match subcommand.as_str() { + "snapshot" => parse_snapshot_mode(pending, common), + "diff" => parse_diff_mode(pending, common), + "run" => parse_run_mode(pending, common), + _ => Err(CliError::usage( + "unknown subcommand; expected snapshot, diff, or run", + )), + } +} + +fn apply_common_flag( + common: &mut CommonArgs, + text: &str, + iter: &mut impl Iterator, +) -> Result { + match text { + "--json" => { + common.set_render_mode(RenderMode::Json); + Ok(true) + } + "--toon" => { + common.set_render_mode(RenderMode::Toon); + Ok(true) + } + "--format" => { + let Some(value) = iter.next() else { + return Err(CliError::usage("--format requires a value")); + }; + common.set_render_mode(parse_format_choice(&os_to_string(value, "--format")?)?); + Ok(true) + } + "--quiet" => { + common.quiet = true; + Ok(true) + } + "--color" => { + let Some(value) = iter.next() else { + return Err(CliError::usage("--color requires a value")); + }; + common.color = common::parse_color_choice(&os_to_string(value, "--color")?)?; + Ok(true) + } + _ => Ok(false), + } +} + +fn parse_snapshot_mode( + arguments: Vec, + common: &mut CommonArgs, +) -> Result { + let mut output = None; + let mut iter = arguments.into_iter(); + while let Some(argument) = iter.next() { + let text = os_to_string(argument, "snapshot argument")?; + if apply_common_flag(common, &text, &mut iter)? { + continue; + } + if text == "--output" { + let Some(path) = iter.next() else { + return Err(CliError::usage("--output requires a file path")); + }; + output = Some(PathBuf::from(path)); + continue; + } + return Err(CliError::usage("snapshot only supports --output ")); + } + Ok(CommandMode::Snapshot { output }) +} + +fn parse_diff_mode( + arguments: Vec, + common: &mut CommonArgs, +) -> Result { + let mut paths = Vec::new(); + let mut iter = arguments.into_iter(); + while let Some(argument) = iter.next() { + let text = os_to_string(argument.clone(), "diff argument")?; + if apply_common_flag(common, &text, &mut iter)? { + continue; + } + paths.push(PathBuf::from(argument)); + } + if paths.len() != 2 { + return Err(CliError::usage("diff only accepts two snapshot paths")); + } + Ok(CommandMode::Diff { + before: paths.remove(0), + after: paths.remove(0), + }) +} + +fn parse_run_mode( + arguments: Vec, + common: &mut CommonArgs, +) -> Result { + let mut shell = ShellMode::Cmd; + let mut remaining = arguments; + let mut command_start = None; + let mut index = 0_usize; + while index < remaining.len() { + let text = os_to_string(remaining[index].clone(), "run argument")?; + match text.as_str() { + "--json" => { + common.set_render_mode(RenderMode::Json); + index += 1; + } + "--toon" => { + common.set_render_mode(RenderMode::Toon); + index += 1; + } + "--format" => { + let Some(value) = remaining.get(index + 1).cloned() else { + return Err(CliError::usage("--format requires a value")); + }; + common.set_render_mode(parse_format_choice(&os_to_string(value, "--format")?)?); + index += 2; + } + "--quiet" => { + common.quiet = true; + index += 1; + } + "--color" => { + let Some(value) = remaining.get(index + 1).cloned() else { + return Err(CliError::usage("--color requires a value")); + }; + common.color = common::parse_color_choice(&os_to_string(value, "--color")?)?; + index += 2; + } + "--shell" => { + let Some(value) = remaining.get(index + 1).cloned() else { + return Err(CliError::usage("--shell requires a value")); + }; + shell = parse_shell_mode(&os_to_string(value, "--shell")?)?; + index += 2; + } + "--" => { + command_start = Some(index + 1); + break; + } + _ => { + return Err(CliError::usage( + "run requires --shell -- ", + )); + } + } + } + let start = command_start + .ok_or_else(|| CliError::usage("run requires --shell -- "))?; + let command = remaining.split_off(start); + if command.is_empty() { + return Err(CliError::usage("run requires a command after --")); + } + Ok(CommandMode::Run { shell, command }) +} + +fn os_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_shell_mode(value: &str) -> Result { + match value { + "cmd" => Ok(ShellMode::Cmd), + "pwsh" => Ok(ShellMode::Pwsh), + "raw" => Ok(ShellMode::Raw), + other => Err(CliError::usage(format!( + "invalid --shell value '{other}'; expected cmd, pwsh, or raw" + ))), + } +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + CommandMode::Snapshot { output } => run_snapshot(cli, output.as_deref()), + CommandMode::Diff { before, after } => { + let diff = diff_snapshot_files(before, after)?; + emit_diff(cli, &diff) + } + CommandMode::Run { shell, command } => { + let before = sanitize_snapshot(*shell, capture_environment()); + let captured = run_and_capture_after(*shell, command)?; + let after = captured.snapshot.clone().unwrap_or_else(|| before.clone()); + let diff = sanitize_diff(*shell, diff_environments(&before, &after)); + let report = RunCommandOutput { + shell: shell_label(*shell).to_string(), + command: render_command(command), + exit_code: captured.exit_code, + duration_ms: captured.duration_ms, + stdout_tail: tail_bytes_to_string(&captured.stdout, OUTPUT_TAIL_BYTES), + stderr_tail: tail_bytes_to_string(&captured.stderr, OUTPUT_TAIL_BYTES), + ok: captured.exit_code == Some(0), + capture_ok: captured.capture_error.is_none(), + capture_error: captured.capture_error, + }; + emit_run_output(cli, &diff, &report) + } + } +} + +fn run_snapshot(cli: &Cli, output: Option<&Path>) -> Result { + let snapshot = capture_environment(); + let payload = SnapshotFile { + values: snapshot.values, + }; + if let Some(path) = output { + let json = serde_json::to_string_pretty(&payload) + .map_err(|error| CliError::runtime(format!("failed to serialize snapshot: {error}")))?; + fs::write(path, json).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + if !cli.common.quiet && matches!(cli.common.render_mode(), RenderMode::Text) { + println!("{}", path.display()); + } + } else { + match cli.common.render_mode() { + RenderMode::Json => print_json(&payload.values)?, + RenderMode::Toon => print_structured(&payload.values, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_snapshot(&payload.values)), + } + } + + Ok(ExitCode::Success) +} + +fn diff_snapshot_files(before: &Path, after: &Path) -> Result { + let before_content = fs::read_to_string(before).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", before.display())) + })?; + let after_content = fs::read_to_string(after).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", after.display())) + })?; + let before_map = + serde_json::from_str::>(&before_content).map_err(|error| { + CliError::runtime(format!("failed to parse {}: {error}", before.display())) + })?; + let after_map = + serde_json::from_str::>(&after_content).map_err(|error| { + CliError::runtime(format!("failed to parse {}: {error}", after.display())) + })?; + Ok(diff_environments( + &EnvironmentSnapshot { values: before_map }, + &EnvironmentSnapshot { values: after_map }, + )) +} + +fn run_and_capture_after(shell: ShellMode, command: &[OsString]) -> Result { + let capture_dir = if matches!(shell, ShellMode::Raw) { + None + } else { + let path = unique_temp_path("envdiff-capture", "dir"); + create_temp_dir_exclusive(&path, "envdiff capture directory")?; + Some(path) + }; + let env_path = capture_dir + .as_ref() + .map(|path| path.join("environment.txt")); + let captured = match shell { + ShellMode::Cmd => run_cmd_wrapper( + command, + env_path + .as_deref() + .expect("capture path is created for cmd shell"), + )?, + ShellMode::Pwsh => run_pwsh_wrapper( + command, + env_path + .as_deref() + .expect("capture path is created for pwsh shell"), + )?, + ShellMode::Raw => run_raw_command(command)?, + }; + let (snapshot, capture_error) = if matches!(shell, ShellMode::Raw) { + (Some(capture_environment()), None) + } else { + let env_path = env_path.expect("capture path is created for wrapped shells"); + let result = match read_environment_file(&env_path) { + Ok(snapshot) => (Some(snapshot), None), + Err(error) => ( + None, + Some(format!( + "failed to read captured environment from {} after {} run (exit_code={}): {}{}", + env_path.display(), + shell_label(shell), + format_optional_i32(captured.exit_code), + error, + render_output_tail_suffix(&captured.stderr, " stderr_tail=") + )), + ), + }; + let _ = fs::remove_file(&env_path); + result + }; + if let Some(capture_dir) = capture_dir { + let _ = fs::remove_dir(capture_dir); + } + Ok(CapturedRun { + snapshot: snapshot.map(|snapshot| sanitize_snapshot(shell, snapshot)), + exit_code: captured.exit_code, + stdout: captured.stdout, + stderr: captured.stderr, + duration_ms: captured.duration_ms, + capture_error, + }) +} + +fn run_cmd_wrapper( + command: &[OsString], + env_path: &Path, +) -> Result { + let wrapper = unique_temp_path("envdiff-cmd", "cmd"); + let body = format!( + "@echo off\r\ncall %*\r\nset MERCURY_ENV_EXITCODE=%ERRORLEVEL%\r\nchcp 65001 > nul\r\nset > \"{}\"\r\nexit /b %MERCURY_ENV_EXITCODE%\r\n", + env_path.display() + ); + write_temp_file_exclusive(&wrapper, &body, "envdiff cmd wrapper")?; + + let started = Instant::now(); + let output = Command::new("cmd") + .arg("/d") + .arg("/s") + .arg("/c") + .arg(&wrapper) + .args(command) + .stdin(Stdio::null()) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch cmd wrapper: {error}")))?; + let _ = fs::remove_file(&wrapper); + Ok(CapturedCommandOutput { + exit_code: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + duration_ms: started.elapsed().as_millis(), + }) +} + +fn run_pwsh_wrapper( + command: &[OsString], + env_path: &Path, +) -> Result { + let wrapper = unique_temp_path("envdiff-pwsh", "ps1"); + let body = pwsh_wrapper_body(env_path); + write_temp_file_exclusive(&wrapper, &body, "envdiff pwsh wrapper")?; + + let started = Instant::now(); + let output = Command::new("pwsh") + .arg("-NoProfile") + .arg("-File") + .arg(&wrapper) + .args(command) + .stdin(Stdio::null()) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch pwsh wrapper: {error}")))?; + let _ = fs::remove_file(&wrapper); + Ok(CapturedCommandOutput { + exit_code: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + duration_ms: started.elapsed().as_millis(), + }) +} + +fn pwsh_wrapper_body(env_path: &Path) -> String { + const TEMPLATE: &str = "\ +$command = @($args)\r\n\ +if ($command.Length -eq 0) { exit 0 }\r\n\ +$program = [string]$command[0]\r\n\ +$childArgs = @()\r\n\ +if ($command.Length -gt 1) {\r\n\ + foreach ($item in $command[1..($command.Length - 1)]) {\r\n\ + $childArgs += [string]$item\r\n\ + }\r\n\ +}\r\n\ +try {\r\n\ + & $program @childArgs\r\n\ + if ($null -ne $LASTEXITCODE) {\r\n\ + $code = $LASTEXITCODE\r\n\ + } elseif ($?) {\r\n\ + $code = 0\r\n\ + } else {\r\n\ + $code = 1\r\n\ + }\r\n\ +} catch {\r\n\ + [Console]::Error.WriteLine($_)\r\n\ + $code = if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }\r\n\ +}\r\n\ +Get-ChildItem Env: | Sort-Object Name | ForEach-Object { '{0}={1}' -f $_.Name, $_.Value } | Set-Content -Encoding utf8 -LiteralPath __ENV_PATH__\r\n\ +exit $code\r\n"; + TEMPLATE.replace( + "__ENV_PATH__", + "e_pwsh_literal(&env_path.display().to_string()), + ) +} + +fn quote_pwsh_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn write_temp_file_exclusive(path: &Path, body: &str, label: &str) -> Result<(), CliError> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| { + CliError::runtime(format!( + "refusing to replace existing {label} {}: {error}", + path.display() + )) + })?; + file.write_all(body.as_bytes()).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + file.flush() + .map_err(|error| CliError::runtime(format!("failed to flush {}: {error}", path.display()))) +} + +fn create_temp_dir_exclusive(path: &Path, label: &str) -> Result<(), CliError> { + fs::create_dir(path).map_err(|error| { + CliError::runtime(format!( + "refusing to reuse existing {label} {}: {error}", + path.display() + )) + }) +} + +fn run_raw_command(command: &[OsString]) -> Result { + let Some(program) = command.first() else { + return Err(CliError::usage("raw run requires a command")); + }; + let started = Instant::now(); + let output = Command::new(program) + .args(&command[1..]) + .stdin(Stdio::null()) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch raw command: {error}")))?; + Ok(CapturedCommandOutput { + exit_code: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + duration_ms: started.elapsed().as_millis(), + }) +} + +fn unique_temp_path(prefix: &str, extension: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |value| value.as_nanos()); + std::env::temp_dir().join(format!( + "{prefix}-{}-{unique}.{extension}", + std::process::id() + )) +} + +fn emit_diff(cli: &Cli, diff: &EnvironmentDiff) -> Result { + match cli.common.render_mode() { + RenderMode::Json => print_json(diff)?, + RenderMode::Toon => print_structured(diff, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_diff(diff)), + } + Ok(ExitCode::Success) +} + +fn emit_run_output( + cli: &Cli, + diff: &EnvironmentDiff, + report: &RunCommandOutput, +) -> Result { + match cli.common.render_mode() { + RenderMode::Json => print_json(&RunDiffOutput { + diff: diff.clone(), + run: report.clone(), + })?, + RenderMode::Toon => print_structured( + &RunDiffOutput { + diff: diff.clone(), + run: report.clone(), + }, + RenderMode::Toon, + )?, + RenderMode::Text => print!("{}", render_run_output(diff, report)), + } + Ok(ExitCode::Success) +} + +fn sanitize_snapshot(shell: ShellMode, mut snapshot: EnvironmentSnapshot) -> EnvironmentSnapshot { + match shell { + ShellMode::Cmd => { + snapshot.values.retain(|name, _| { + !name.is_empty() + && !name.starts_with('=') + && !CMD_WRAPPER_NOISE_NAMES.contains(&name.as_str()) + }); + } + ShellMode::Pwsh => { + snapshot.values.retain(|name, _| !is_pwsh_noise_name(name)); + } + ShellMode::Raw => {} + } + snapshot +} + +fn sanitize_diff(shell: ShellMode, mut diff: EnvironmentDiff) -> EnvironmentDiff { + if shell == ShellMode::Pwsh { + diff.added.retain(|item| !is_pwsh_noise_name(&item.name)); + diff.removed.retain(|item| !is_pwsh_noise_name(&item.name)); + diff.changed.retain(|item| !is_pwsh_noise_name(&item.name)); + diff.path_like_changes + .retain(|item| !is_pwsh_noise_name(&item.name)); + } + for item in &mut diff.added { + item.name = canonical_env_name(&item.name); + compact_path_like_value_in_place(&item.name, &mut item.value); + } + for item in &mut diff.removed { + item.name = canonical_env_name(&item.name); + compact_path_like_value_in_place(&item.name, &mut item.value); + } + for item in &mut diff.changed { + item.name = canonical_env_name(&item.name); + compact_path_like_value_in_place(&item.name, &mut item.before); + compact_path_like_value_in_place(&item.name, &mut item.after); + } + for item in &mut diff.path_like_changes { + item.name = canonical_env_name(&item.name); + } + diff +} + +fn render_snapshot(values: &BTreeMap) -> String { + let mut rendered = String::new(); + for (name, value) in values { + writeln!(rendered, "{name}={value}").expect("writing to a String cannot fail"); + } + rendered +} + +fn render_diff(diff: &EnvironmentDiff) -> String { + if diff.added.is_empty() + && diff.removed.is_empty() + && diff.changed.is_empty() + && diff.path_like_changes.is_empty() + { + return "no_changes=true\n".to_string(); + } + + let mut rendered = String::new(); + for item in &diff.added { + writeln!(rendered, "added name={} value={}", item.name, item.value) + .expect("writing to a String cannot fail"); + } + for item in &diff.removed { + writeln!(rendered, "removed name={} value={}", item.name, item.value) + .expect("writing to a String cannot fail"); + } + for item in &diff.changed { + if is_path_like_name(&item.name) { + let segment_counts = diff + .path_like_changes + .iter() + .find(|change| change.name.eq_ignore_ascii_case(&item.name)) + .map_or_else( + || { + ( + path_like_segment_count(&item.before), + path_like_segment_count(&item.after), + ) + }, + |change| (change.before_segment_count, change.after_segment_count), + ); + writeln!( + rendered, + "changed name={} before_segments_total={} after_segments_total={}", + item.name, segment_counts.0, segment_counts.1 + ) + .expect("writing to a String cannot fail"); + } else { + writeln!( + rendered, + "changed name={} before={} after={}", + item.name, item.before, item.after + ) + .expect("writing to a String cannot fail"); + } + } + for item in &diff.path_like_changes { + writeln!( + rendered, + "path_like name={} added={} removed={}", + item.name, + item.added_segments.join("|"), + item.removed_segments.join("|") + ) + .expect("writing to a String cannot fail"); + } + rendered +} + +fn render_run_output(diff: &EnvironmentDiff, report: &RunCommandOutput) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "run shell={} exit_code={} ok={} capture_ok={} duration_ms={} command={}", + report.shell, + format_optional_i32(report.exit_code), + report.ok, + report.capture_ok, + report.duration_ms, + report.command + ) + .expect("writing to a String cannot fail"); + if let Some(error) = &report.capture_error { + let _ = writeln!(rendered, "capture_error: {error}"); + } + if !report.stderr_tail.is_empty() { + rendered.push_str("stderr_tail:\n"); + rendered.push_str(&report.stderr_tail); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + } + if !report.stdout_tail.is_empty() { + rendered.push_str("stdout_tail:\n"); + rendered.push_str(&report.stdout_tail); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + } + rendered.push_str(&render_diff(diff)); + rendered +} + +fn is_path_like_name(name: &str) -> bool { + ["PATH", "PSMODULEPATH", "LIB", "INCLUDE"] + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +fn canonical_env_name(name: &str) -> String { + if is_path_like_name(name) { + name.to_ascii_uppercase() + } else { + name.to_string() + } +} + +fn path_like_segment_count(value: &str) -> usize { + split_path_like_segments(value).len() +} + +fn split_path_like_segments(value: &str) -> Vec<&str> { + value + .split(';') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .collect() +} + +fn compact_path_like_value_in_place(name: &str, value: &mut String) { + if is_path_like_name(name) { + *value = compact_path_like_value(value); + } +} + +fn compact_path_like_value(value: &str) -> String { + let segments = split_path_like_segments(value); + let omitted = segments.len().saturating_sub(PATH_LIKE_VALUE_PREVIEW_LIMIT); + let duplicate_count = count_duplicate_segments(&segments); + + let mut summary = format!("segments={}", segments.len()); + if let Some((first, rest)) = segments.split_first() { + summary.push_str(" preview="); + summary.push_str(first); + for segment in rest.iter().take(PATH_LIKE_VALUE_PREVIEW_LIMIT - 1) { + summary.push('|'); + summary.push_str(segment); + } + } + if omitted > 0 { + let _ = write!(summary, " omitted={omitted}"); + } + if duplicate_count > 0 { + let _ = write!(summary, " dup={duplicate_count}"); + } + summary +} + +fn count_duplicate_segments(segments: &[&str]) -> usize { + let mut seen = BTreeSet::new(); + let mut duplicates = BTreeSet::new(); + for segment in segments { + let normalized = segment.to_ascii_lowercase(); + if !seen.insert(normalized) { + duplicates.insert((*segment).to_string()); + } + } + duplicates.len() +} + +fn is_pwsh_noise_name(name: &str) -> bool { + [ + "PSEXECUTIONPOLICYPREFERENCE", + "POWERSHELL_DISTRIBUTION_CHANNEL", + "PROCESSOR_ARCHITECTURE", + "PROCESSOR_ARCHITEW6432", + "PROGRAMFILES", + "COMMONPROGRAMFILES", + "PROGRAMW6432", + ] + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +const fn shell_label(shell: ShellMode) -> &'static str { + match shell { + ShellMode::Cmd => "cmd", + ShellMode::Pwsh => "pwsh", + ShellMode::Raw => "raw", + } +} + +fn format_optional_i32(value: Option) -> String { + value.map_or_else(|| "none".to_string(), |item| item.to_string()) +} + +fn tail_bytes_to_string(bytes: &[u8], limit: usize) -> String { + let start = bytes.len().saturating_sub(limit); + String::from_utf8_lossy(&bytes[start..]).to_string() +} + +fn render_command(command: &[OsString]) -> String { + let mut rendered = String::new(); + for item in command { + if !rendered.is_empty() { + rendered.push(' '); + } + rendered.push_str("e_for_text(&item.to_string_lossy())); + } + rendered +} + +fn quote_for_text(value: &str) -> String { + if value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value.to_string() + } +} + +fn render_output_tail_suffix(bytes: &[u8], label: &str) -> String { + let tail = tail_bytes_to_string(bytes, OUTPUT_TAIL_BYTES); + if tail.is_empty() { + String::new() + } else { + format!("{label}{tail}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parse_run_mode_and_render_diff_work() { + let mut common = CommonArgs::default(); + let command = parse_command_mode( + vec![ + OsString::from("run"), + OsString::from("--shell"), + OsString::from("cmd"), + OsString::from("--"), + OsString::from("demo.cmd"), + ], + &mut common, + ) + .expect("command"); + + assert!(matches!( + command, + CommandMode::Run { + shell: ShellMode::Cmd, + .. + } + )); + + let text = render_diff(&EnvironmentDiff { + added: vec![], + removed: vec![], + changed: vec![], + path_like_changes: vec![], + }); + assert_eq!(text, "no_changes=true\n"); + } + + #[test] + fn parse_snapshot_and_diff_modes_accept_common_flags() { + let mut common = CommonArgs::default(); + let snapshot = parse_command_mode( + vec![ + OsString::from("snapshot"), + OsString::from("--quiet"), + OsString::from("--output"), + OsString::from("before.json"), + ], + &mut common, + ) + .expect("snapshot"); + assert!(common.quiet); + assert!(matches!( + snapshot, + CommandMode::Snapshot { + output: Some(path) + } if path == std::path::Path::new("before.json") + )); + + let mut common = CommonArgs::default(); + let diff = parse_command_mode( + vec![ + OsString::from("diff"), + OsString::from("--json"), + OsString::from("before.json"), + OsString::from("after.json"), + ], + &mut common, + ) + .expect("diff"); + assert!(common.json); + assert!(matches!( + diff, + CommandMode::Diff { before, after } + if before == std::path::Path::new("before.json") + && after == std::path::Path::new("after.json") + )); + } + + #[test] + fn snapshot_and_run_argument_validation_report_usage_errors() { + let mut common = CommonArgs::default(); + assert!(matches!( + parse_command_mode(vec![OsString::from("snapshot"), OsString::from("--output")], &mut common), + Err(CliError::Usage(message)) if message.contains("--output requires") + )); + + let mut common = CommonArgs::default(); + assert!(matches!( + parse_command_mode(vec![OsString::from("diff"), OsString::from("before.json")], &mut common), + Err(CliError::Usage(message)) if message.contains("two snapshot paths") + )); + + let mut common = CommonArgs::default(); + assert!(matches!( + parse_command_mode( + vec![ + OsString::from("run"), + OsString::from("--shell"), + OsString::from("cmd") + ], + &mut common + ), + Err(CliError::Usage(message)) if message.contains("requires --shell") + )); + assert!(matches!( + parse_shell_mode("bad"), + Err(CliError::Usage(message)) if message.contains("invalid --shell value") + )); + } + + #[test] + fn diff_snapshot_files_and_render_snapshot_cover_io_paths() { + let temp = unique_temp_path("envdiff-test", "json"); + let before = temp.with_file_name("before.json"); + let after = temp.with_file_name("after.json"); + fs::write(&before, r#"{"PATH":"C:\\A","KEEP":"old"}"#).expect("before"); + fs::write(&after, r#"{"PATH":"C:\\A;C:\\B","KEEP":"new","ADD":"x"}"#).expect("after"); + + let diff = diff_snapshot_files(&before, &after).expect("diff"); + let snapshot_text = + render_snapshot(&BTreeMap::from([("KEEP".to_string(), "value".to_string())])); + + assert_eq!(diff.added.len(), 1); + assert_eq!(diff.changed.len(), 2); + assert!(snapshot_text.contains("KEEP=value")); + + let _ = fs::remove_file(before); + let _ = fs::remove_file(after); + } + + #[test] + fn wrapper_commands_cover_success_failure_and_temp_paths() { + let env_path = unique_temp_path("envdiff-wrapper", "txt"); + let cmd_output = run_cmd_wrapper( + &[ + OsString::from("cmd"), + OsString::from("/c"), + OsString::from("exit 0"), + ], + &env_path, + ) + .expect("cmd wrapper"); + assert_eq!(cmd_output.exit_code, Some(0)); + assert!(env_path.exists()); + + let pwsh_output = run_pwsh_wrapper( + &[ + OsString::from("pwsh"), + OsString::from("-NoProfile"), + OsString::from("-Command"), + OsString::from("$env:WRAPPED='yes'"), + ], + &env_path, + ) + .expect("pwsh wrapper"); + assert_eq!(pwsh_output.exit_code, Some(0)); + let wrapped = read_environment_file(&env_path).expect("wrapped env"); + assert!(!wrapped.values.is_empty()); + + let pwsh_cmdlet_output = run_pwsh_wrapper( + &[OsString::from("Write-Output"), OsString::from("cmdlet-ok")], + &env_path, + ) + .expect("pwsh cmdlet args"); + assert_eq!(pwsh_cmdlet_output.exit_code, Some(0)); + + let inline_output = run_pwsh_wrapper( + &[OsString::from("& { $env:WRAPPED_INLINE='yes' }")], + &env_path, + ) + .expect("inline pwsh script is treated as argv"); + assert_ne!(inline_output.exit_code, Some(0)); + let wrapped_inline = read_environment_file(&env_path).expect("wrapped inline env"); + assert_eq!( + wrapped_inline + .values + .get("WRAPPED_INLINE") + .map(String::as_str), + None + ); + + let raw_output = run_raw_command(&[ + OsString::from("cmd"), + OsString::from("/c"), + OsString::from("exit 0"), + ]) + .expect("raw command"); + assert_eq!(raw_output.exit_code, Some(0)); + let raw_failure = run_raw_command(&[ + OsString::from("cmd"), + OsString::from("/c"), + OsString::from("exit 3"), + ]) + .expect("raw failure"); + assert_eq!(raw_failure.exit_code, Some(3)); + assert!( + unique_temp_path("envdiff", "txt") + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) + ); + + let _ = fs::remove_file(env_path); + } + + #[test] + fn temp_file_writer_refuses_preexisting_paths() { + let path = unique_temp_path("envdiff-existing-wrapper", "cmd"); + fs::write(&path, "original").expect("preexisting wrapper"); + + let error = write_temp_file_exclusive(&path, "replacement", "envdiff wrapper") + .expect_err("preexisting path refused"); + + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("refusing to replace existing envdiff wrapper") + )); + assert_eq!( + fs::read_to_string(&path).expect("preserved content"), + "original" + ); + let _ = fs::remove_file(path); + } + + #[test] + fn temp_directory_creator_refuses_preexisting_paths() { + let path = unique_temp_path("envdiff-existing-capture", "dir"); + fs::create_dir(&path).expect("preexisting capture dir"); + + let error = create_temp_dir_exclusive(&path, "envdiff capture directory") + .expect_err("preexisting directory refused"); + + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("refusing to reuse existing envdiff capture directory") + )); + assert!(path.is_dir()); + let _ = fs::remove_dir(path); + } + + #[test] + fn sanitize_snapshot_and_path_like_rendering_remove_wrapper_noise() { + let snapshot = sanitize_snapshot( + ShellMode::Cmd, + EnvironmentSnapshot { + values: BTreeMap::from([ + ("PROMPT".to_string(), "$P$G".to_string()), + ("MERCURY_ENV_EXITCODE".to_string(), "0".to_string()), + ("=C:".to_string(), "C:\\Temp".to_string()), + ("KEEP".to_string(), "value".to_string()), + ]), + }, + ); + assert_eq!( + snapshot.values, + BTreeMap::from([("KEEP".to_string(), "value".to_string())]) + ); + let pwsh_snapshot = sanitize_snapshot( + ShellMode::Pwsh, + EnvironmentSnapshot { + values: BTreeMap::from([ + ( + "POWERSHELL_DISTRIBUTION_CHANNEL".to_string(), + "MSI".to_string(), + ), + ("KEEP".to_string(), "value".to_string()), + ]), + }, + ); + assert_eq!( + pwsh_snapshot.values, + BTreeMap::from([("KEEP".to_string(), "value".to_string())]) + ); + assert!(is_path_like_name("Path")); + assert_eq!(path_like_segment_count("A;B;;"), 2); + + let rendered = render_diff(&EnvironmentDiff { + added: Vec::new(), + removed: Vec::new(), + changed: vec![windowsupport::EnvChanged { + name: "PATH".to_string(), + before: "A;B".to_string(), + after: "A;B;C".to_string(), + }], + path_like_changes: vec![windowsupport::PathLikeChange { + name: "PATH".to_string(), + before_segment_count: 2, + after_segment_count: 3, + added_segments: vec!["C".to_string()], + removed_segments: Vec::new(), + }], + }); + assert!( + rendered.contains("changed name=PATH before_segments_total=2 after_segments_total=3") + ); + assert!(rendered.contains("path_like name=PATH added=C removed=")); + let sanitized = sanitize_diff( + ShellMode::Pwsh, + EnvironmentDiff { + added: vec![windowsupport::EnvAdded { + name: "PROCESSOR_ARCHITEW6432".to_string(), + value: "MSI".to_string(), + }], + removed: Vec::new(), + changed: vec![ + windowsupport::EnvChanged { + name: "ProgramFiles".to_string(), + before: "C:\\Program Files".to_string(), + after: "C:\\Program Files (x86)".to_string(), + }, + windowsupport::EnvChanged { + name: "Path".to_string(), + before: "C:\\Tools".to_string(), + after: "C:\\Tools;C:\\More".to_string(), + }, + ], + path_like_changes: Vec::new(), + }, + ); + assert!(sanitized.added.is_empty()); + assert_eq!(sanitized.changed.len(), 1); + assert_eq!(sanitized.changed[0].name, "PATH"); + assert_eq!(sanitized.changed[0].before, "segments=1 preview=C:\\Tools"); + assert_eq!( + sanitized.changed[0].after, + "segments=2 preview=C:\\Tools|C:\\More" + ); + } + + #[test] + fn parse_cli_from_covers_help_version_and_top_level_flags() { + let (help, _) = parse_cli_from(["envdiff", "--help"]).expect("help"); + assert_eq!(help, ParseOutcome::Help); + + let (version, _) = parse_cli_from(["envdiff", "--version"]).expect("version"); + assert_eq!(version, ParseOutcome::Version); + + let (_, cli) = parse_cli_from([ + "envdiff", "--json", "run", "--quiet", "--shell", "raw", "--", "cmd", "/c", "exit 0", + ]) + .expect("cli"); + assert!(cli.common.json); + assert!(cli.common.quiet); + assert!(matches!( + cli.command, + CommandMode::Run { + shell: ShellMode::Raw, + .. + } + )); + } + + #[test] + fn run_and_emit_helpers_cover_raw_and_no_result_paths() { + let snapshot = run_and_capture_after( + ShellMode::Raw, + &[ + OsString::from("cmd"), + OsString::from("/c"), + OsString::from("exit 0"), + ], + ) + .expect("raw snapshot"); + assert!( + !snapshot + .snapshot + .as_ref() + .expect("raw snapshot present") + .values + .is_empty() + ); + + let diff = EnvironmentDiff { + added: Vec::new(), + removed: Vec::new(), + changed: Vec::new(), + path_like_changes: Vec::new(), + }; + let cli = Cli { + common: CommonArgs { + json: false, + format: None, + input_format: common::InputFormat::Lines, + color: common::ColorChoice::Never, + quiet: true, + }, + command: CommandMode::Snapshot { output: None }, + }; + assert_eq!(emit_diff(&cli, &diff).expect("emit"), ExitCode::Success); + } + + #[test] + fn run_capture_preserves_child_output_without_polluting_json() { + let script = unique_temp_path("envdiff-child", "ps1"); + fs::write( + &script, + "$env:CHILD_OK='1'\nWrite-Output 'child-output'\nthrow 'boom'\n", + ) + .expect("child script"); + let captured = run_and_capture_after(ShellMode::Pwsh, &[script.as_os_str().to_os_string()]) + .expect("captured run"); + let _ = fs::remove_file(script); + assert_eq!(captured.exit_code, Some(1)); + assert!(String::from_utf8_lossy(&captured.stdout).contains("child-output")); + assert!(String::from_utf8_lossy(&captured.stderr).contains("boom")); + assert_eq!( + captured + .snapshot + .as_ref() + .expect("pwsh snapshot present") + .values + .get("CHILD_OK") + .map(String::as_str), + Some("1") + ); + assert_eq!(captured.capture_error, None); + } + + #[test] + fn run_output_rendering_is_compact_and_includes_tails() { + let report = RunCommandOutput { + shell: "pwsh".to_string(), + command: "\"& { Write-Output done }\"".to_string(), + exit_code: Some(1), + duration_ms: 12, + stdout_tail: "done\r\n".to_string(), + stderr_tail: "boom".to_string(), + ok: false, + capture_ok: true, + capture_error: None, + }; + let rendered = render_run_output( + &EnvironmentDiff { + added: Vec::new(), + removed: Vec::new(), + changed: Vec::new(), + path_like_changes: Vec::new(), + }, + &report, + ); + assert!(rendered.contains("run shell=pwsh exit_code=1 ok=false")); + assert!(rendered.contains("stderr_tail:")); + assert!(rendered.contains("stdout_tail:")); + assert!(rendered.contains("no_changes=true")); + assert_eq!(shell_label(ShellMode::Raw), "raw"); + assert_eq!(format_optional_i32(None), "none"); + assert_eq!( + render_command(&[OsString::from("two words")]), + "\"two words\"" + ); + assert_eq!(tail_bytes_to_string(b"abcdef", 3), "def"); + assert_eq!(quote_for_text("plain"), "plain"); + + let success = render_run_output( + &EnvironmentDiff { + added: Vec::new(), + removed: Vec::new(), + changed: Vec::new(), + path_like_changes: Vec::new(), + }, + &RunCommandOutput { + ok: true, + exit_code: Some(0), + stderr_tail: String::new(), + ..report + }, + ); + assert!(success.contains("stdout_tail:")); + } + + #[test] + fn run_capture_keeps_snapshot_for_nonzero_pwsh_exit() { + let script = unique_temp_path("envdiff-exit", "ps1"); + fs::write(&script, "exit 7\n").expect("exit script"); + let captured = run_and_capture_after(ShellMode::Pwsh, &[script.as_os_str().to_os_string()]) + .expect("captured run"); + let _ = fs::remove_file(script); + assert_eq!(captured.exit_code, Some(7)); + assert!(captured.snapshot.is_some()); + assert_eq!(captured.capture_error, None); + } + + #[test] + fn render_and_snapshot_helpers_cover_populated_paths() { + let diff = EnvironmentDiff { + added: vec![windowsupport::EnvAdded { + name: "ADD".to_string(), + value: "x".to_string(), + }], + removed: vec![windowsupport::EnvRemoved { + name: "OLD".to_string(), + value: "gone".to_string(), + }], + changed: vec![windowsupport::EnvChanged { + name: "KEEP".to_string(), + before: "a".to_string(), + after: "b".to_string(), + }], + path_like_changes: vec![windowsupport::PathLikeChange { + name: "PATH".to_string(), + before_segment_count: 2, + after_segment_count: 2, + added_segments: vec!["C:\\Tools".to_string()], + removed_segments: vec!["C:\\Old".to_string()], + }], + }; + let rendered = render_diff(&diff); + assert!(rendered.contains("added name=ADD value=x")); + assert!(rendered.contains("removed name=OLD value=gone")); + assert!(rendered.contains("changed name=KEEP before=a after=b")); + assert!(rendered.contains("path_like name=PATH added=C:\\Tools removed=C:\\Old")); + + let output = unique_temp_path("envdiff-snapshot", "json"); + let cli = Cli { + common: CommonArgs { + json: false, + format: None, + input_format: common::InputFormat::Lines, + color: common::ColorChoice::Never, + quiet: true, + }, + command: CommandMode::Snapshot { + output: Some(output.clone()), + }, + }; + assert_eq!( + run_snapshot(&cli, Some(&output)).expect("snapshot file"), + ExitCode::Success + ); + let written = fs::read_to_string(&output).expect("snapshot text"); + assert!(written.contains('{')); + let _ = fs::remove_file(output); + } + + #[test] + fn compact_path_like_value_preserves_summary_signal_without_raw_dump() { + let compacted = compact_path_like_value("C:\\A;C:\\B;C:\\A;C:\\C;C:\\D;C:\\E;C:\\F"); + assert!(compacted.contains("segments=7")); + assert!(compacted.contains("preview=C:\\A|C:\\B|C:\\A|C:\\C|C:\\D|C:\\E")); + assert!(compacted.contains("omitted=1")); + assert!(compacted.contains("dup=1")); + } + + #[test] + fn diff_and_run_helpers_cover_error_paths_and_dispatch() { + let missing = unique_temp_path("envdiff-missing", "json"); + assert!(matches!( + diff_snapshot_files(&missing, &missing), + Err(CliError::Runtime(message)) if message.contains("failed to read") + )); + + let before = unique_temp_path("envdiff-before", "json"); + let after = unique_temp_path("envdiff-after", "json"); + fs::write(&before, "{\"OK\":\"1\"}").expect("before"); + fs::write(&after, "{not json").expect("after"); + assert!(matches!( + diff_snapshot_files(&before, &after), + Err(CliError::Runtime(message)) if message.contains("failed to parse") + )); + + fs::write(&after, "{\"OK\":\"2\",\"ADD\":\"x\"}").expect("after fixed"); + let cli = Cli { + common: CommonArgs { + json: false, + format: None, + input_format: common::InputFormat::Lines, + color: common::ColorChoice::Never, + quiet: true, + }, + command: CommandMode::Diff { + before: before.clone(), + after: after.clone(), + }, + }; + assert_eq!(run(&cli).expect("diff run"), ExitCode::Success); + + assert!(matches!( + run_raw_command(&[]), + Err(CliError::Usage(message)) if message.contains("requires a command") + )); + + let _ = fs::remove_file(before); + let _ = fs::remove_file(after); + } +} diff --git a/crates/envdiff/src/main.rs b/crates/envdiff/src/main.rs new file mode 100644 index 0000000..262e483 --- /dev/null +++ b/crates/envdiff/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `envdiff`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(envdiff::main_entry()); +} diff --git a/crates/envdiff/tests/envdiff_cli.rs b/crates/envdiff/tests/envdiff_cli.rs new file mode 100644 index 0000000..76c2bae --- /dev/null +++ b/crates/envdiff/tests/envdiff_cli.rs @@ -0,0 +1,80 @@ +//! Integration tests for the `envdiff` command. + +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("envdiff").expect("binary") +} + +struct TempTestDir { + path: PathBuf, +} + +impl TempTestDir { + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for TempTestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn runs_cmd_and_reports_environment_diff_as_json() { + let temp = unique_temp_dir(); + let script = temp.path().join("mutate.cmd"); + fs::write( + &script, + "@echo off\r\nset TEST_FLAG=enabled\r\nset PATH=%PATH%;C:\\Mercury\\Bin\r\n", + ) + .expect("script"); + + let mut command = cargo_command(); + command + .arg("run") + .arg("--json") + .arg("--shell") + .arg("cmd") + .arg("--") + .arg(&script) + .assert() + .success() + .stdout(predicate::str::contains("\"added\"")) + .stdout(predicate::str::contains("\"name\":\"TEST_FLAG\"")) + .stdout(predicate::str::contains("\"path_like_changes\"")); +} + +#[test] +fn help_includes_envdiff_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("snapshot")) + .stdout(predicate::str::contains("run --shell cmd")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} + +fn unique_temp_dir() -> TempTestDir { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + loop { + let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("envdiff-test-{}-{unique}", std::process::id())); + match fs::create_dir(&path) { + Ok(()) => return TempTestDir { path }, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => panic!("tempdir: {error}"), + } + } +} diff --git a/crates/fileprobe/Cargo.toml b/crates/fileprobe/Cargo.toml new file mode 100644 index 0000000..0afcd50 --- /dev/null +++ b/crates/fileprobe/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "fileprobe" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Probe file type and usefulness heuristics for AI-friendly workflows." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +humantime.workspace = true +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/fileprobe/src/lib.rs b/crates/fileprobe/src/lib.rs new file mode 100644 index 0000000..155c940 --- /dev/null +++ b/crates/fileprobe/src/lib.rs @@ -0,0 +1,921 @@ +//! The `fileprobe` command classifies files with lightweight heuristics. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Component, Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +const HELP: &str = "\ +Probe file type and usefulness heuristics for AI-friendly workflows. + +Usage: + fileprobe [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + fileprobe .\\src\\main.rs + fileprobe .\\dist\\bundle.min.js --json | ConvertFrom-Json + fileprobe .\\samples\\*.json --toon + fd -t f . .\\src | fileprobe --input-format lines --json | ConvertFrom-Json + fd -t f . .\\samples | fileprobe --input-format lines --toon +"; + +/// CLI arguments for the `fileprobe` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Explicit files to inspect when stdin is empty. + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum FileFamily { + Directory, + Source, + Config, + Data, + Text, + Binary, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct TextStats { + line_count: usize, + blank_lines: usize, + longest_line: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LineEndingCounts { + lf: usize, + crlf: usize, + cr: usize, +} + +#[allow( + clippy::struct_excessive_bools, + reason = "the JSON contract intentionally exposes fixed heuristic toggles as stable booleans" +)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct FileReport { + path: String, + extension: Option, + size_bytes: u64, + modified_rfc3339: String, + family: FileFamily, + is_directory: bool, + language_hint: Option, + container_hint: Option, + is_binary: bool, + encoding_hint: Option, + bom: Option, + newline_style: Option, + mixed_newlines: Option, + line_ending_counts: Option, + line_count: Option, + blank_lines: Option, + longest_line: Option, + likely_generated: bool, + likely_minified: bool, + likely_lockfile: bool, + likely_test: bool, + likely_vendor: bool, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("fileprobe {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn run(cli: &Cli) -> Result { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let mut reports = Vec::new(); + for path in &paths { + reports.push(inspect_path(path)?); + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&reports)?, + RenderMode::Toon => print_structured(&reports, RenderMode::Toon)?, + RenderMode::Text => { + for report in &reports { + println!("{}", render_text_report(report)); + } + } + } + + Ok(map_result_count(reports.len())) +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let paths = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !paths.is_empty() { + return Ok(paths); + } + } + + common::expand_input_patterns(&cli.paths, "fileprobe") +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "fileprobe")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn inspect_path(path: &Path) -> Result { + let path_text = path.display().to_string(); + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!("failed to read metadata for {path_text}: {error}")) + })?; + let modified = metadata.modified().map_err(|error| { + CliError::runtime(format!( + "failed to read modified time for {path_text}: {error}" + )) + })?; + if metadata.is_dir() { + return Ok(FileReport { + path: path_text, + extension: None, + size_bytes: 0, + modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(), + family: FileFamily::Directory, + is_directory: true, + language_hint: None, + container_hint: Some("directory".to_string()), + is_binary: false, + encoding_hint: None, + bom: None, + newline_style: None, + mixed_newlines: None, + line_ending_counts: None, + line_count: None, + blank_lines: None, + longest_line: None, + likely_generated: false, + likely_minified: false, + likely_lockfile: false, + likely_test: detect_test_path(path), + likely_vendor: detect_vendor_path(path), + }); + } + let bytes = fs::read(path) + .map_err(|error| CliError::runtime(format!("failed to read {path_text}: {error}")))?; + + let extension = extension_label(path); + let container_hint = detect_container_hint(&bytes); + let bom = detect_bom(&bytes); + let is_binary = is_binary_blob(&bytes, container_hint.as_deref(), bom.as_deref()); + let language_hint = detect_language_hint(path); + let family = classify_family(is_binary, language_hint.as_deref()); + let decoded_text = (!is_binary) + .then(|| decode_text(&bytes, bom.as_deref())) + .flatten(); + let text_stats = decoded_text.as_deref().map(summarize_text); + let encoding_hint = decoded_text + .as_ref() + .and_then(|_| detect_encoding_hint(&bytes, bom.as_deref())); + let line_ending_counts = decoded_text.as_deref().map(count_line_endings); + let newline_style = line_ending_counts.as_ref().and_then(detect_newline_style); + let mixed_newlines = line_ending_counts + .as_ref() + .map(|counts| distinct_line_endings(counts) > 1); + let likely_lockfile = detect_lockfile(path); + let likely_generated = detect_generated(path, &bytes, likely_lockfile); + let likely_minified = text_stats + .as_ref() + .zip(decoded_text.as_deref()) + .is_some_and(|(stats, text)| detect_minified(text, stats)); + + Ok(FileReport { + path: path_text, + extension, + size_bytes: metadata.len(), + modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(), + family, + is_directory: false, + language_hint, + container_hint, + is_binary, + encoding_hint, + bom, + newline_style, + mixed_newlines, + line_ending_counts, + line_count: text_stats.as_ref().map(|stats| stats.line_count), + blank_lines: text_stats.as_ref().map(|stats| stats.blank_lines), + longest_line: text_stats.as_ref().map(|stats| stats.longest_line), + likely_generated, + likely_minified, + likely_lockfile, + likely_test: detect_test_path(path), + likely_vendor: detect_vendor_path(path), + }) +} + +fn extension_label(path: &Path) -> Option { + path.extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) +} + +fn detect_container_hint(bytes: &[u8]) -> Option { + magic_container_hint(bytes).map(str::to_owned) +} + +fn magic_container_hint(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"MZ") || bytes.windows(2).take(8).any(|window| window == b"MZ") { + Some("pe") + } else if bytes.starts_with(&[0x7f, b'E', b'L', b'F']) { + Some("elf") + } else if bytes.starts_with(&[0xfe, 0xed, 0xfa, 0xce]) + || bytes.starts_with(&[0xfe, 0xed, 0xfa, 0xcf]) + || bytes.starts_with(&[0xce, 0xfa, 0xed, 0xfe]) + || bytes.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) + || bytes.starts_with(&[0xca, 0xfe, 0xba, 0xbe]) + { + Some("mach") + } else if bytes.starts_with(b"!\n") { + Some("archive") + } else if bytes.starts_with(b"PK\x03\x04") { + Some("zip") + } else if bytes.starts_with(b"SQLite format 3\0") { + Some("sqlite") + } else if bytes.starts_with(b"%PDF-") { + Some("pdf") + } else { + None + } +} + +fn is_binary_blob(bytes: &[u8], container_hint: Option<&str>, bom: Option<&str>) -> bool { + if container_hint.is_some() { + return true; + } + if bom.is_some() { + return false; + } + if bytes.contains(&0) { + return true; + } + std::str::from_utf8(bytes).is_err() +} + +fn detect_bom(bytes: &[u8]) -> Option { + if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { + Some("utf-8".to_owned()) + } else if bytes.starts_with(&[0xff, 0xfe]) { + Some("utf-16le".to_owned()) + } else if bytes.starts_with(&[0xfe, 0xff]) { + Some("utf-16be".to_owned()) + } else { + None + } +} + +fn detect_encoding_hint(bytes: &[u8], bom: Option<&str>) -> Option { + if let Some(bom) = bom { + return Some(bom.to_string()); + } + std::str::from_utf8(bytes).ok().map(|_| "utf-8".to_string()) +} + +fn detect_language_hint(path: &Path) -> Option { + match path.extension().and_then(|value| value.to_str()) { + Some("rs") => Some("rust".to_string()), + Some("cs") => Some("csharp".to_string()), + Some("js" | "mjs" | "cjs") => Some("javascript".to_string()), + Some("ts" | "tsx") => Some("typescript".to_string()), + Some("json") => Some("json".to_string()), + Some("jsonl") => Some("jsonl".to_string()), + Some("csv") => Some("csv".to_string()), + Some("tsv") => Some("tsv".to_string()), + Some("toml") => Some("toml".to_string()), + Some("yaml" | "yml") => Some("yaml".to_string()), + Some("ps1") => Some("powershell".to_string()), + Some("py") => Some("python".to_string()), + Some("md") => Some("markdown".to_string()), + Some("xml") => Some("xml".to_string()), + Some("html" | "htm") => Some("html".to_string()), + Some("css") => Some("css".to_string()), + Some("lock") => Some("lockfile".to_string()), + _ => None, + } +} + +fn classify_family(is_binary: bool, language_hint: Option<&str>) -> FileFamily { + if is_binary { + return FileFamily::Binary; + } + + match language_hint { + Some( + "rust" | "csharp" | "javascript" | "typescript" | "powershell" | "python" | "xml" + | "html" | "css", + ) => FileFamily::Source, + Some("json" | "toml" | "yaml" | "lockfile") => FileFamily::Config, + Some("jsonl" | "csv" | "tsv") => FileFamily::Data, + Some(_) | None => FileFamily::Text, + } +} + +fn decode_text(bytes: &[u8], bom: Option<&str>) -> Option { + match bom { + Some("utf-8") => Some(String::from_utf8_lossy(&bytes[3..]).to_string()), + Some("utf-16le") => Some(decode_utf16(&bytes[2..], true)), + Some("utf-16be") => Some(decode_utf16(&bytes[2..], false)), + Some(_) => None, + None => Some(String::from_utf8_lossy(bytes).to_string()), + } +} + +fn decode_utf16(bytes: &[u8], little_endian: bool) -> String { + let units = bytes + .chunks_exact(2) + .map(|chunk| { + if little_endian { + u16::from_le_bytes([chunk[0], chunk[1]]) + } else { + u16::from_be_bytes([chunk[0], chunk[1]]) + } + }) + .collect::>(); + String::from_utf16_lossy(&units) +} + +fn summarize_text(text: &str) -> TextStats { + let mut line_count = 0_usize; + let mut blank_lines = 0_usize; + let mut longest_line = 0_usize; + + for line in text.lines() { + line_count += 1; + if line.trim().is_empty() { + blank_lines += 1; + } + longest_line = longest_line.max(line.len()); + } + + TextStats { + line_count, + blank_lines, + longest_line, + } +} + +fn detect_generated(path: &Path, bytes: &[u8], likely_lockfile: bool) -> bool { + if likely_lockfile { + return true; + } + + let file_name = file_name_lower(path); + if is_known_generated_artifact(path, &file_name) { + return true; + } + if file_name.contains(".designer.") || file_name.contains(".generated.") { + return true; + } + + contains_generated_marker(&bytes[..bytes.len().min(512)]) +} + +fn is_known_generated_artifact(path: &Path, file_name: &str) -> bool { + if matches!( + file_name, + "project.assets.json" + | "project.nuget.cache" + | ".netcoreapp,version=v1.0.assemblyattributes.cs" + | ".netframework,version=v4.8.assemblyattributes.cs" + ) { + return true; + } + + let in_obj_dir = path.components().any(|component| match component { + Component::Normal(value) => value.to_string_lossy().eq_ignore_ascii_case("obj"), + _ => false, + }); + in_obj_dir + && (file_name.ends_with(".assemblyinfo.cs") + || file_name.ends_with(".assemblyattributes.cs")) +} + +fn contains_generated_marker(preview: &[u8]) -> bool { + const MARKERS: [&[u8]; 4] = [ + b"@generated", + b"generated by", + b"auto-generated", + b"automatically generated", + ]; + MARKERS + .iter() + .any(|marker| contains_ascii_case_insensitive(preview, marker)) +} + +fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +fn count_line_endings(text: &str) -> LineEndingCounts { + let mut counts = LineEndingCounts { + lf: 0, + crlf: 0, + cr: 0, + }; + let bytes = text.as_bytes(); + let mut index = 0_usize; + while index < bytes.len() { + match bytes[index] { + b'\r' if bytes.get(index + 1) == Some(&b'\n') => { + counts.crlf += 1; + index += 2; + } + b'\r' => { + counts.cr += 1; + index += 1; + } + b'\n' => { + counts.lf += 1; + index += 1; + } + _ => index += 1, + } + } + counts +} + +fn detect_newline_style(counts: &LineEndingCounts) -> Option { + if counts.crlf > 0 && counts.lf == 0 && counts.cr == 0 { + Some("crlf".to_owned()) + } else if counts.lf > 0 && counts.crlf == 0 && counts.cr == 0 { + Some("lf".to_owned()) + } else if counts.cr > 0 && counts.lf == 0 && counts.crlf == 0 { + Some("cr".to_owned()) + } else if distinct_line_endings(counts) > 1 { + Some("mixed".to_owned()) + } else { + None + } +} + +fn distinct_line_endings(counts: &LineEndingCounts) -> usize { + usize::from(counts.lf > 0) + usize::from(counts.crlf > 0) + usize::from(counts.cr > 0) +} + +fn detect_minified(text: &str, stats: &TextStats) -> bool { + if stats.line_count == 0 || stats.line_count > 3 || stats.longest_line < 80 { + return false; + } + + let mut non_newline_len = 0usize; + let mut whitespace_chars = 0usize; + let mut has_open_brace = false; + let mut has_semicolon = false; + + for ch in text.chars() { + if matches!(ch, '\n' | '\r') { + continue; + } + non_newline_len += 1; + if ch.is_whitespace() { + whitespace_chars += 1; + } + has_open_brace |= ch == '{'; + has_semicolon |= ch == ';'; + } + + if non_newline_len == 0 { + return false; + } + + whitespace_chars.saturating_mul(100) < non_newline_len.saturating_mul(12) + && has_open_brace + && has_semicolon +} + +fn detect_lockfile(path: &Path) -> bool { + let file_name = file_name_lower(path); + Path::new(&file_name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("lock")) + || matches!( + file_name.as_str(), + "package-lock.json" + | "pnpm-lock.yaml" + | "yarn.lock" + | "bun.lockb" + | "composer.lock" + | "poetry.lock" + | "uv.lock" + ) +} + +fn detect_test_path(path: &Path) -> bool { + let lower_path = path.display().to_string().to_ascii_lowercase(); + lower_path.contains("\\tests\\") + || lower_path.contains("/tests/") + || lower_path.contains(".test.") + || lower_path.contains("_test.") + || lower_path.contains(".spec.") + || lower_path.contains("_spec.") +} + +fn detect_vendor_path(path: &Path) -> bool { + path.components().any(|component| { + let Component::Normal(segment) = component else { + return false; + }; + let lower = segment.to_string_lossy().to_ascii_lowercase(); + matches!( + lower.as_str(), + "vendor" | "node_modules" | "third_party" | "packages" + ) + }) +} + +fn file_name_lower(path: &Path) -> String { + path.file_name() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default() +} + +fn render_text_report(report: &FileReport) -> String { + let mut line = format!( + "path={} family={} language={} binary={} size={} mtime={}", + report.path, + family_label(report.family), + report.language_hint.as_deref().unwrap_or("-"), + report.is_binary, + report.size_bytes, + report.modified_rfc3339 + ); + + if let Some(extension) = &report.extension { + write!(line, " ext={extension}").expect("writing to a String cannot fail"); + } + write!(line, " directory={}", report.is_directory).expect("writing to a String cannot fail"); + if let Some(container_hint) = &report.container_hint { + write!(line, " container={container_hint}").expect("writing to a String cannot fail"); + } + if let Some(encoding_hint) = &report.encoding_hint { + write!(line, " encoding={encoding_hint}").expect("writing to a String cannot fail"); + } + if let Some(bom) = &report.bom { + write!(line, " bom={bom}").expect("writing to a String cannot fail"); + } + if let Some(newline_style) = &report.newline_style { + write!(line, " newline={newline_style}").expect("writing to a String cannot fail"); + } + if let Some(mixed_newlines) = report.mixed_newlines { + write!(line, " mixed_newlines={mixed_newlines}").expect("writing to a String cannot fail"); + } + if let Some(line_count) = report.line_count { + write!(line, " lines={line_count}").expect("writing to a String cannot fail"); + } + if let Some(blank_lines) = report.blank_lines { + write!(line, " blank={blank_lines}").expect("writing to a String cannot fail"); + } + if let Some(longest_line) = report.longest_line { + write!(line, " longest={longest_line}").expect("writing to a String cannot fail"); + } + write!( + line, + " generated={} minified={} lockfile={} test={} vendor={}", + report.likely_generated, + report.likely_minified, + report.likely_lockfile, + report.likely_test, + report.likely_vendor + ) + .expect("writing to a String cannot fail"); + + line +} + +const fn family_label(family: FileFamily) -> &'static str { + match family { + FileFamily::Directory => "directory", + FileFamily::Source => "source", + FileFamily::Config => "config", + FileFamily::Data => "data", + FileFamily::Text => "text", + FileFamily::Binary => "binary", + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_paths_supports_line_and_json_inputs() { + let temp = tempdir().expect("tempdir"); + let line_path = temp.path().join("a.rs"); + let jsonl_one = temp.path().join("b.cs"); + let jsonl_two = temp.path().join("c.js"); + let auto_path = temp.path().join("d.toml"); + fs::write(&line_path, "a").expect("line file"); + fs::write(&jsonl_one, "b").expect("jsonl one"); + fs::write(&jsonl_two, "c").expect("jsonl two"); + fs::write(&auto_path, "d").expect("auto file"); + + assert_eq!( + parse_paths_from_string(&format!("{}\n", line_path.display()), InputFormat::Lines) + .expect("lines"), + vec![line_path] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&jsonl_one.display().to_string()).expect("jsonl one"), + serde_json::to_string(&jsonl_two.display().to_string()).expect("jsonl two"), + ), + InputFormat::Jsonl, + ) + .expect("jsonl"), + vec![jsonl_one, jsonl_two] + ); + assert_eq!( + parse_paths_from_string(&format!("{}\n", auto_path.display()), InputFormat::Auto) + .expect("auto"), + vec![auto_path] + ); + } + + #[test] + fn parse_paths_reports_invalid_jsonl() { + let error = + parse_paths_from_string("nope\n", InputFormat::Jsonl).expect_err("invalid jsonl"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("stdin JSONL path line 1 is not valid JSON") + )); + } + + #[test] + fn container_language_and_path_heuristics_are_stable() { + assert_eq!( + detect_container_hint(b"MZ\x00\x01payload"), + Some("pe".to_string()) + ); + assert_eq!(detect_container_hint(b"%PDF-1.7"), Some("pdf".to_string())); + assert!(is_binary_blob(b"\x00\x01\xff", None, None)); + assert!(!is_binary_blob(b"plain text", None, None)); + assert_eq!( + detect_language_hint(Path::new("demo.rs")), + Some("rust".to_string()) + ); + assert_eq!( + detect_language_hint(Path::new("events.jsonl")), + Some("jsonl".to_string()) + ); + assert_eq!( + classify_family(false, Some("javascript")), + FileFamily::Source + ); + assert_eq!(family_label(FileFamily::Directory), "directory"); + assert_eq!(classify_family(false, Some("toml")), FileFamily::Config); + assert_eq!(classify_family(false, Some("jsonl")), FileFamily::Data); + assert_eq!(classify_family(true, Some("rust")), FileFamily::Binary); + assert!(detect_lockfile(Path::new("Cargo.lock"))); + assert!(detect_test_path(Path::new("C:\\repo\\tests\\probe.rs"))); + assert!(detect_vendor_path(Path::new("C:\\repo\\vendor\\lib.rs"))); + } + + #[test] + fn generated_and_minified_heuristics_use_content_markers() { + let stats = summarize_text( + "function boot(){const state={ready:true,mode:\"fast\"};if(state.ready){console.log(state.mode);}}\n", + ); + assert!(detect_minified( + "function boot(){const state={ready:true,mode:\"fast\"};if(state.ready){console.log(state.mode);}}\n", + &stats, + )); + + assert!(detect_generated( + Path::new("generated.lock"), + b"# This file is automatically @generated by the build system.\n", + true, + )); + assert!(detect_generated( + Path::new("demo.rs"), + b"// auto-generated by tool\nfn run() {}\n", + false, + )); + assert!(detect_generated( + Path::new("C:\\repo\\obj\\project.assets.json"), + b"{\"version\":3}\n", + false, + )); + } + + #[test] + fn inspect_path_and_render_text_cover_text_and_binary_reports() { + let temp = tempdir().expect("tempdir"); + let source_path = temp.path().join("sample.rs"); + let binary_path = temp.path().join("sample.bin"); + fs::write(&source_path, b"pub fn run() {}\n").expect("source fixture"); + fs::write(&binary_path, b"MZ\x00\x01\xff").expect("binary fixture"); + + let source_report = inspect_path(&source_path).expect("source report"); + assert_eq!(source_report.family, FileFamily::Source); + assert_eq!(source_report.language_hint.as_deref(), Some("rust")); + assert_eq!(source_report.line_count, Some(1)); + assert_eq!(source_report.encoding_hint.as_deref(), Some("utf-8")); + assert!(render_text_report(&source_report).contains("family=source")); + + let binary_report = inspect_path(&binary_path).expect("binary report"); + assert_eq!(binary_report.family, FileFamily::Binary); + assert_eq!(binary_report.container_hint.as_deref(), Some("pe")); + assert!(binary_report.is_binary); + assert!(render_text_report(&binary_report).contains("container=pe")); + } + + #[test] + fn inspect_path_reports_directories_without_raw_os_errors() { + let temp = tempdir().expect("tempdir"); + let report = inspect_path(temp.path()).expect("directory report"); + assert_eq!(report.family, FileFamily::Directory); + assert!(report.is_directory); + assert_eq!(report.container_hint.as_deref(), Some("directory")); + } + + #[test] + fn detects_bom_and_mixed_newlines() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("bom.txt"); + fs::write(&path, [0xef, 0xbb, 0xbf, b'a', b'\r', b'\n', b'b', b'\n']).expect("fixture"); + + let report = inspect_path(&path).expect("report"); + + assert_eq!(report.bom.as_deref(), Some("utf-8")); + assert_eq!(report.newline_style.as_deref(), Some("mixed")); + assert_eq!(report.mixed_newlines, Some(true)); + assert_eq!( + report.line_ending_counts, + Some(LineEndingCounts { + lf: 1, + crlf: 1, + cr: 0, + }) + ); + } + + #[test] + fn run_accepts_explicit_paths_for_text_and_json_output() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.cs"); + fs::write(&path, b"class Demo {}\n").expect("fixture"); + + let text_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + paths: vec![path.clone()], + }) + .expect("text run"); + assert_eq!(text_exit, ExitCode::Success); + + let json_exit = run(&Cli { + common: common_args(true, InputFormat::Lines), + paths: vec![path], + }) + .expect("json run"); + assert_eq!(json_exit, ExitCode::Success); + } +} diff --git a/crates/fileprobe/src/main.rs b/crates/fileprobe/src/main.rs new file mode 100644 index 0000000..ba1dadb --- /dev/null +++ b/crates/fileprobe/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `fileprobe`. + +fn main() { + std::process::exit(fileprobe::main_entry()); +} diff --git a/crates/fileprobe/tests/fileprobe_cli.rs b/crates/fileprobe/tests/fileprobe_cli.rs new file mode 100644 index 0000000..5fb30e5 --- /dev/null +++ b/crates/fileprobe/tests/fileprobe_cli.rs @@ -0,0 +1,163 @@ +//! Integration tests for the `fileprobe` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("fileprobe").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn probes_source_files_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.rs")) + .assert() + .success() + .stdout(predicate::str::contains("family=source")) + .stdout(predicate::str::contains("language=rust")) + .stdout(predicate::str::contains("binary=false")); +} + +#[test] +fn probes_binary_files_as_json() { + let mut command = cargo_command(); + command + .arg("--json") + .arg(fixture("reading/binary.bin")) + .assert() + .success() + .stdout(predicate::str::contains("\"is_binary\":true")) + .stdout(predicate::str::contains("\"family\":\"binary\"")) + .stdout(predicate::str::contains("\"container_hint\":\"pe\"")); +} + +#[test] +fn flags_generated_minified_and_locklike_files() { + let mut minified = cargo_command(); + minified + .arg("--json") + .arg(fixture("reading/minified.js")) + .assert() + .success() + .stdout(predicate::str::contains("\"likely_minified\":true")) + .stdout(predicate::str::contains("\"language_hint\":\"javascript\"")); + + let mut generated = cargo_command(); + generated + .arg("--json") + .arg(fixture("reading/generated.lock")) + .assert() + .success() + .stdout(predicate::str::contains("\"likely_generated\":true")) + .stdout(predicate::str::contains("\"likely_lockfile\":true")); +} + +#[test] +fn recognizes_jsonl_as_data_for_pipeline_handoffs() { + let mut command = cargo_command(); + command + .arg("--json") + .arg(fixture("jsonl/events.jsonl")) + .assert() + .success() + .stdout(predicate::str::contains("\"family\":\"data\"")) + .stdout(predicate::str::contains("\"language_hint\":\"jsonl\"")); +} + +#[test] +fn supports_powershell_path_pipeline() { + let binary = assert_cmd::cargo::cargo_bin("fileprobe"); + let input = fixture("reading/sample.cs"); + let script = format!("'{}' | & '{}' --json", input.display(), binary.display()); + + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("\"language_hint\":\"csharp\"")) + .stdout(predicate::str::contains("\"family\":\"source\"")); +} + +#[test] +fn expands_literal_globs_passed_by_powershell() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write(temp.path().join("one.json"), "{}\n").expect("one"); + std::fs::write(temp.path().join("two.json"), "{}\n").expect("two"); + std::fs::write(temp.path().join("skip.txt"), "notes\n").expect("skip"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(temp.path().join("*.json")) + .assert() + .success() + .stdout(predicate::str::contains("one.json")) + .stdout(predicate::str::contains("two.json")) + .stdout(predicate::str::contains("skip.txt").not()); +} + +#[test] +fn unmatched_glob_suggests_fd_pipeline_for_powershell() { + let temp = tempfile::tempdir().expect("tempdir"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(temp.path().join("*.missing")) + .assert() + .failure() + .stderr(predicate::str::contains("glob pattern")) + .stderr(predicate::str::contains("PowerShell")) + .stderr(predicate::str::contains("fd -t f .")) + .stderr(predicate::str::contains("fileprobe --input-format lines")); +} + +#[test] +fn reports_directories_as_directories_instead_of_raw_access_errors() { + let mut command = cargo_command(); + command + .arg("--json") + .arg( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("crates"), + ) + .assert() + .success() + .stdout(predicate::str::contains("\"family\":\"directory\"")) + .stdout(predicate::str::contains("\"is_directory\":true")); +} + +#[test] +fn help_includes_probe_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("fileprobe .\\src\\main.rs")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("--toon")) + .stdout(predicate::str::contains("--format ")); +} diff --git a/crates/gitshape/Cargo.toml b/crates/gitshape/Cargo.toml new file mode 100644 index 0000000..74324e5 --- /dev/null +++ b/crates/gitshape/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "gitshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Summarize Git repository state into compact branch and change counters." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +configsupport = { path = "../configsupport" } +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/gitshape/src/lib.rs b/crates/gitshape/src/lib.rs new file mode 100644 index 0000000..06ec2ad --- /dev/null +++ b/crates/gitshape/src/lib.rs @@ -0,0 +1,1368 @@ +//! The `gitshape` command summarizes Git repository state. +#![allow(clippy::multiple_crate_versions)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::path::PathBuf; +use std::process::Command; + +use configsupport::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +const HELP: &str = "\ +Summarize Git status and diff shape with compact AI-friendly output. + +Usage: + gitshape [OPTIONS] [PATH] + gitshape [OPTIONS] status [PATH] + gitshape [OPTIONS] diff [REVISION_A] [REVISION_B] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --repo Target repository root (defaults to current directory) + --ignored Include ignored-file counts in status output + --staged Diff staged changes (diff only) + -h, --help Show this help text + -V, --version Show the command version + +Examples: + gitshape . + gitshape status + gitshape status --repo C:\\src\\repo --json | ConvertFrom-Json + gitshape diff --staged --json + gitshape diff HEAD~1 HEAD --repo C:\\src\\repo +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: CommandMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandMode { + Status { + repo: PathBuf, + include_ignored: bool, + }, + Diff { + repo: PathBuf, + staged: bool, + revisions: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct GitSummary { + root: String, + repository_present: bool, + branch: Option, + head: String, + upstream: Option, + ahead: usize, + behind: usize, + staged: usize, + modified: usize, + deleted: usize, + renamed: usize, + untracked: usize, + conflicted: usize, + ignored: usize, + clean: bool, + error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct GitDiffSummary { + root: String, + mode: String, + revisions: Vec, + files_changed: usize, + added_files: usize, + deleted_files: usize, + modified_files: usize, + renamed_files: usize, + copied_files: usize, + type_changed_files: usize, + unmerged_files: usize, + line_additions: usize, + line_deletions: usize, + touched_symbols: Vec, + files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct GitDiffFile { + path: String, + previous_path: Option, + status: String, + additions: Option, + deletions: Option, + symbols: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StatusCode { + path: String, + previous_path: Option, + kind: FileChangeKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileChangeKind { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, + Unmerged, + Unknown, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("gitshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +#[allow( + clippy::too_many_lines, + reason = "manual CLI parsing keeps the contract explicit and aligned with sibling crates" +)] +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut repo = None::; + let mut include_ignored = false; + let mut staged = false; + let mut subcommand = None::; + let mut positional = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + command: CommandMode::Status { + repo: PathBuf::from("."), + include_ignored, + }, + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + command: CommandMode::Status { + repo: PathBuf::from("."), + include_ignored, + }, + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("ignored") => include_ignored = true, + Long("staged") => staged = true, + Long("repo") => { + repo = Some(PathBuf::from(parser_value_string(&mut parser, "--repo")?)); + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + ArgValue(value) => { + let text = os_to_string(value, "argument")?; + if subcommand.is_none() { + if matches!(text.as_str(), "status" | "diff") { + subcommand = Some(text); + } else { + subcommand = Some("status".to_string()); + positional.push(text); + } + } else { + positional.push(text); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let command_name = subcommand.unwrap_or_else(|| "status".to_string()); + let command = match command_name.as_str() { + "status" => { + let resolved_repo = resolve_repo_argument(repo, &positional, "status")?; + CommandMode::Status { + repo: resolved_repo, + include_ignored, + } + } + "diff" => { + if include_ignored { + return Err(CliError::usage("--ignored is only valid with status")); + } + if staged && !positional.is_empty() { + return Err(CliError::usage( + "diff --staged does not accept explicit revisions", + )); + } + if positional.len() > 2 { + return Err(CliError::usage( + "diff accepts at most two revisions: [REVISION_A] [REVISION_B]", + )); + } + CommandMode::Diff { + repo: repo.unwrap_or_else(|| PathBuf::from(".")), + staged, + revisions: positional, + } + } + _ => { + return Err(CliError::usage( + "unknown subcommand; expected status or diff", + )); + } + }; + + Ok((ParseOutcome::Run, Cli { common, command })) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn os_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn resolve_repo_argument( + repo: Option, + positional: &[String], + subcommand: &str, +) -> Result { + match (repo, positional) { + (Some(_), [_]) => Err(CliError::usage(format!( + "{subcommand} accepts either --repo or positional [PATH], not both" + ))), + (Some(path), []) => Ok(path), + (None, []) => Ok(PathBuf::from(".")), + (None, [path]) => Ok(PathBuf::from(path)), + (None, _) => Err(CliError::usage(format!( + "{subcommand} accepts at most one repository path" + ))), + (Some(_), _) => Err(CliError::usage(format!( + "{subcommand} accepts no extra positional arguments" + ))), + } +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + CommandMode::Status { + repo, + include_ignored, + } => { + let summary = inspect_repository(repo, *include_ignored)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&summary)?, + RenderMode::Toon => print_structured(&summary, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_summary(&summary)), + } + Ok(ExitCode::Success) + } + CommandMode::Diff { + repo, + staged, + revisions, + } => { + let summary = inspect_diff(repo, *staged, revisions)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&summary)?, + RenderMode::Toon => print_structured(&summary, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_diff_summary(&summary)), + } + Ok(ExitCode::Success) + } + } +} + +fn inspect_repository(target: &PathBuf, include_ignored: bool) -> Result { + let root = match run_git_capture(target, ["rev-parse", "--show-toplevel"]) { + Ok(root) => root, + Err(CliError::Runtime(message)) if is_missing_repository_message(&message) => { + return Ok(missing_repository_summary(target, message)); + } + Err(error) => return Err(error), + }; + + let mut status_args = vec!["status", "--porcelain=v2", "--branch"]; + if include_ignored { + status_args.push("--ignored=matching"); + } + let status = run_git_capture_owned(target, &status_args)?; + parse_status(root, &status) +} + +fn run_git_capture(path: &PathBuf, args: [&str; N]) -> Result { + run_git_capture_owned(path, &args) +} + +fn run_git_capture_owned(path: &PathBuf, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch git: {error}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(CliError::runtime(if stderr.is_empty() { + "git command failed".to_string() + } else { + stderr + })); + } + String::from_utf8(output.stdout) + .map(|text| text.trim().to_string()) + .map_err(|error| CliError::runtime(format!("git output was not UTF-8: {error}"))) +} + +fn is_missing_repository_message(message: &str) -> bool { + message.contains("not a git repository") +} + +fn inspect_diff( + repo: &PathBuf, + staged: bool, + revisions: &[String], +) -> Result { + let root = run_git_capture(repo, ["rev-parse", "--show-toplevel"])?; + for revision in revisions { + ensure_revision_exists(repo, revision)?; + } + let mut diff_args = vec!["diff", "--name-status", "-M"]; + append_diff_selector(&mut diff_args, staged, revisions); + let name_status = run_git_capture_owned(repo, &diff_args)?; + + let mut numstat_args = vec!["diff", "--numstat", "-M"]; + append_diff_selector(&mut numstat_args, staged, revisions); + let numstat = run_git_capture_owned(repo, &numstat_args)?; + + let mut patch_args = vec!["diff", "--no-color", "--unified=0", "-M"]; + append_diff_selector(&mut patch_args, staged, revisions); + let patch = run_git_capture_owned(repo, &patch_args)?; + + parse_diff_output(root, staged, revisions, &name_status, &numstat, &patch) +} + +fn ensure_revision_exists(repo: &PathBuf, revision: &str) -> Result<(), CliError> { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", "--quiet", revision]) + .output() + .map_err(|error| CliError::runtime(format!("failed to launch git: {error}")))?; + if output.status.success() { + Ok(()) + } else { + Err(CliError::usage(format!( + "unknown revision '{revision}'; run `git rev-parse --verify {revision}` to confirm it exists" + ))) + } +} + +fn append_diff_selector<'a>(args: &mut Vec<&'a str>, staged: bool, revisions: &'a [String]) { + if staged { + args.push("--cached"); + return; + } + for revision in revisions { + args.push(revision.as_str()); + } +} + +fn parse_status(root: String, status: &str) -> Result { + let mut summary = GitSummary { + root, + repository_present: true, + branch: None, + head: "-".to_string(), + upstream: None, + ahead: 0, + behind: 0, + staged: 0, + modified: 0, + deleted: 0, + renamed: 0, + untracked: 0, + conflicted: 0, + ignored: 0, + clean: true, + error: None, + }; + + for line in status.lines() { + if let Some(oid) = line.strip_prefix("# branch.oid ") { + if !matches!(oid, "(initial)" | "(unknown)") { + summary.head = short_head(oid); + } + continue; + } + if let Some(branch) = line.strip_prefix("# branch.head ") { + if branch != "(detached)" { + summary.branch = Some(branch.to_string()); + } + continue; + } + if let Some(upstream) = line.strip_prefix("# branch.upstream ") { + summary.upstream = Some(upstream.to_string()); + continue; + } + if let Some(ab) = line.strip_prefix("# branch.ab ") { + let mut parts = ab.split_whitespace(); + if let Some(ahead) = parts.next().and_then(|value| value.strip_prefix('+')) { + summary.ahead = ahead.parse::().map_err(|error| { + CliError::runtime(format!("failed to parse ahead count: {error}")) + })?; + } + if let Some(behind) = parts.next().and_then(|value| value.strip_prefix('-')) { + summary.behind = behind.parse::().map_err(|error| { + CliError::runtime(format!("failed to parse behind count: {error}")) + })?; + } + continue; + } + if line.starts_with("1 ") || line.starts_with("2 ") { + let xy = line + .split_whitespace() + .nth(1) + .ok_or_else(|| CliError::runtime("malformed porcelain v2 status line"))?; + update_xy_counters(xy, &mut summary); + continue; + } + if line.starts_with("u ") { + summary.conflicted += 1; + continue; + } + if line.starts_with("? ") { + summary.untracked += 1; + continue; + } + if line.starts_with("! ") { + summary.ignored += 1; + } + } + + summary.clean = summary.staged == 0 + && summary.modified == 0 + && summary.deleted == 0 + && summary.renamed == 0 + && summary.untracked == 0 + && summary.conflicted == 0; + Ok(summary) +} + +fn missing_repository_summary(target: &std::path::Path, message: String) -> GitSummary { + GitSummary { + root: target.display().to_string(), + repository_present: false, + branch: None, + head: "-".to_string(), + upstream: None, + ahead: 0, + behind: 0, + staged: 0, + modified: 0, + deleted: 0, + renamed: 0, + untracked: 0, + conflicted: 0, + ignored: 0, + clean: true, + error: Some(message), + } +} + +fn short_head(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.len() <= 12 { + trimmed.to_string() + } else { + trimmed[..12].to_string() + } +} + +fn update_xy_counters(xy: &str, summary: &mut GitSummary) { + let mut characters = xy.chars(); + let index = characters.next().unwrap_or('.'); + let worktree = characters.next().unwrap_or('.'); + + if index != '.' { + summary.staged += 1; + } + if worktree != '.' { + summary.modified += 1; + } + if matches!(index, 'D') || matches!(worktree, 'D') { + summary.deleted += 1; + } + if matches!(index, 'R') || matches!(worktree, 'R') { + summary.renamed += 1; + } +} + +fn render_summary(summary: &GitSummary) -> String { + format!( + "root={} repository_present={} branch={} head={} upstream={} ahead={} behind={} staged={} modified={} deleted={} renamed={} untracked={} conflicted={} ignored={} clean={} error={}", + summary.root, + summary.repository_present, + summary.branch.as_deref().unwrap_or("-"), + summary.head, + summary.upstream.as_deref().unwrap_or("-"), + summary.ahead, + summary.behind, + summary.staged, + summary.modified, + summary.deleted, + summary.renamed, + summary.untracked, + summary.conflicted, + summary.ignored, + summary.clean, + summary.error.as_deref().unwrap_or("-") + ) + "\n" +} + +fn parse_diff_output( + root: String, + staged: bool, + revisions: &[String], + name_status: &str, + numstat: &str, + patch: &str, +) -> Result { + let codes = parse_name_status(name_status)?; + let mut file_map = BTreeMap::::new(); + let mut added_files = 0_usize; + let mut deleted_files = 0_usize; + let mut modified_files = 0_usize; + let mut renamed_files = 0_usize; + let mut copied_files = 0_usize; + let mut type_changed_files = 0_usize; + let mut unmerged_files = 0_usize; + + for code in &codes { + match code.kind { + FileChangeKind::Added => added_files += 1, + FileChangeKind::Deleted => deleted_files += 1, + FileChangeKind::Modified | FileChangeKind::Unknown => modified_files += 1, + FileChangeKind::Renamed => renamed_files += 1, + FileChangeKind::Copied => copied_files += 1, + FileChangeKind::TypeChanged => type_changed_files += 1, + FileChangeKind::Unmerged => unmerged_files += 1, + } + file_map.insert( + code.path.clone(), + GitDiffFile { + path: code.path.clone(), + previous_path: code.previous_path.clone(), + status: change_kind_name(code.kind).to_string(), + additions: None, + deletions: None, + symbols: Vec::new(), + }, + ); + } + + let mut line_additions = 0_usize; + let mut line_deletions = 0_usize; + for (path, additions, deletions) in parse_numstat(numstat) { + if let Some(value) = additions { + line_additions += value; + } + if let Some(value) = deletions { + line_deletions += value; + } + if let Some(file) = file_map.get_mut(&path) { + file.additions = additions; + file.deletions = deletions; + } + } + + let symbols_by_file = parse_symbols_by_file(patch); + let mut touched_symbols = BTreeSet::::new(); + for (path, symbols) in symbols_by_file { + if let Some(file) = file_map.get_mut(&path) { + file.symbols = symbols.iter().take(8).cloned().collect::>(); + } + for symbol in symbols { + touched_symbols.insert(symbol); + } + } + + let files = file_map.into_values().collect::>(); + let mode = if staged { + "staged".to_string() + } else if revisions.is_empty() { + "working_tree".to_string() + } else { + "revisions".to_string() + }; + + Ok(GitDiffSummary { + root, + mode, + revisions: revisions.to_vec(), + files_changed: files.len(), + added_files, + deleted_files, + modified_files, + renamed_files, + copied_files, + type_changed_files, + unmerged_files, + line_additions, + line_deletions, + touched_symbols: touched_symbols.into_iter().collect(), + files, + }) +} + +fn parse_name_status(input: &str) -> Result, CliError> { + let mut codes = Vec::new(); + for line in input.lines() { + if line.trim().is_empty() { + continue; + } + let columns = line.split('\t').collect::>(); + let Some(raw_code) = columns.first() else { + return Err(CliError::runtime("malformed name-status line")); + }; + let kind = parse_change_kind(raw_code); + let (path, previous_path) = + if matches!(kind, FileChangeKind::Renamed | FileChangeKind::Copied) { + let Some(before) = columns.get(1) else { + return Err(CliError::runtime(format!( + "malformed rename/copy line: {line}" + ))); + }; + let Some(after) = columns.get(2) else { + return Err(CliError::runtime(format!( + "malformed rename/copy line: {line}" + ))); + }; + ((*after).to_string(), Some((*before).to_string())) + } else { + let Some(path) = columns.get(1) else { + return Err(CliError::runtime(format!("malformed diff line: {line}"))); + }; + ((*path).to_string(), None) + }; + codes.push(StatusCode { + path, + previous_path, + kind, + }); + } + Ok(codes) +} + +fn parse_change_kind(raw: &str) -> FileChangeKind { + match raw.chars().next() { + Some('A') => FileChangeKind::Added, + Some('D') => FileChangeKind::Deleted, + Some('M') => FileChangeKind::Modified, + Some('R') => FileChangeKind::Renamed, + Some('C') => FileChangeKind::Copied, + Some('T') => FileChangeKind::TypeChanged, + Some('U') => FileChangeKind::Unmerged, + _ => FileChangeKind::Unknown, + } +} + +const fn change_kind_name(kind: FileChangeKind) -> &'static str { + match kind { + FileChangeKind::Added => "added", + FileChangeKind::Deleted => "deleted", + FileChangeKind::Modified => "modified", + FileChangeKind::Renamed => "renamed", + FileChangeKind::Copied => "copied", + FileChangeKind::TypeChanged => "type_changed", + FileChangeKind::Unmerged => "unmerged", + FileChangeKind::Unknown => "unknown", + } +} + +fn parse_numstat(input: &str) -> Vec<(String, Option, Option)> { + let mut rows = Vec::new(); + for line in input.lines() { + if line.trim().is_empty() { + continue; + } + let mut parts = line.splitn(3, '\t'); + let Some(additions_raw) = parts.next() else { + continue; + }; + let Some(deletions_raw) = parts.next() else { + continue; + }; + let Some(path_raw) = parts.next() else { + continue; + }; + let path = normalize_numstat_path(path_raw); + rows.push(( + path, + parse_optional_count(additions_raw), + parse_optional_count(deletions_raw), + )); + } + rows +} + +fn parse_optional_count(raw: &str) -> Option { + raw.parse::().ok() +} + +fn normalize_numstat_path(raw: &str) -> String { + if !raw.contains("=>") { + return raw.to_string(); + } + if let Some((_, right)) = raw.split_once("=>") { + let trimmed = right.trim().trim_end_matches('}').trim(); + return trimmed.to_string(); + } + raw.to_string() +} + +fn parse_symbols_by_file(patch: &str) -> BTreeMap> { + let mut symbols = BTreeMap::>::new(); + let mut current_path = None::; + + for line in patch.lines() { + if let Some(path) = parse_diff_header_path(line) { + current_path = Some(path); + continue; + } + if !line.starts_with("@@") { + continue; + } + let Some(current_file_path) = ¤t_path else { + continue; + }; + let Some(symbol) = parse_hunk_symbol(line) else { + continue; + }; + if symbol.is_empty() { + continue; + } + symbols + .entry(current_file_path.clone()) + .or_default() + .insert(symbol); + } + + symbols +} + +fn parse_diff_header_path(line: &str) -> Option { + if !line.starts_with("diff --git ") { + return None; + } + let path = line.split_whitespace().nth(3)?; + path.strip_prefix("b/") + .map(str::to_string) + .or_else(|| Some(path.to_string())) +} + +fn parse_hunk_symbol(line: &str) -> Option { + let mut parts = line.splitn(3, "@@"); + let _ = parts.next(); + let _ = parts.next(); + parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(120).collect::()) +} + +fn render_diff_summary(summary: &GitDiffSummary) -> String { + let mut output = format!( + "root={} mode={} files={} added={} deleted={} modified={} renamed={} copied={} type_changed={} unmerged={} line_additions={} line_deletions={} symbols={}\n", + summary.root, + summary.mode, + summary.files_changed, + summary.added_files, + summary.deleted_files, + summary.modified_files, + summary.renamed_files, + summary.copied_files, + summary.type_changed_files, + summary.unmerged_files, + summary.line_additions, + summary.line_deletions, + summary.touched_symbols.len(), + ); + for file in &summary.files { + output.push_str(&render_diff_file_line(file)); + } + output +} + +fn render_diff_file_line(file: &GitDiffFile) -> String { + let additions = file + .additions + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let deletions = file + .deletions + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let symbols = if file.symbols.is_empty() { + "-".to_string() + } else { + file.symbols.join("|") + }; + let mut line = String::new(); + let _ = writeln!( + line, + "status={} path={} previous={} additions={} deletions={} symbols={}", + file.status, + file.path, + file.previous_path.as_deref().unwrap_or("-"), + additions, + deletions, + symbols + ); + line +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn parse_cli_supports_status_and_diff_modes() { + let (_, cli) = parse_cli_from([ + "gitshape", + "--json", + "status", + "--repo", + "demo", + "--ignored", + ]) + .expect("status cli"); + assert!(cli.common.json); + assert!(matches!( + cli.command, + CommandMode::Status { + include_ignored: true, + .. + } + )); + + let (_, diff_cli) = + parse_cli_from(["gitshape", "diff", "--repo", "demo", "HEAD~1", "HEAD"]) + .expect("diff cli"); + assert!(matches!( + diff_cli.command, + CommandMode::Diff { + staged: false, + ref revisions, + .. + } if revisions == &vec!["HEAD~1".to_string(), "HEAD".to_string()] + )); + } + + #[test] + fn status_parser_tracks_branch_and_changes() { + let summary = parse_status( + "C:/repo".to_string(), + "# branch.oid abc123\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +2 -1\n1 .M N... 100644 100644 100644 abc abc file.txt\n? new.txt\n! target/\n", + ) + .expect("summary"); + + assert_eq!(summary.branch.as_deref(), Some("main")); + assert_eq!(summary.upstream.as_deref(), Some("origin/main")); + assert_eq!(summary.ahead, 2); + assert_eq!(summary.behind, 1); + assert_eq!(summary.modified, 1); + assert_eq!(summary.untracked, 1); + assert_eq!(summary.ignored, 1); + assert!(!summary.clean); + } + + #[test] + fn parse_name_status_and_hunk_symbols_are_compact() { + let codes = parse_name_status("M\tsrc/lib.rs\nR100\told.rs\tnew.rs\n").expect("codes"); + assert_eq!(codes.len(), 2); + assert_eq!(codes[1].previous_path.as_deref(), Some("old.rs")); + assert_eq!(codes[1].path, "new.rs"); + + let symbols = parse_symbols_by_file( + "diff --git a/src/lib.rs b/src/lib.rs\n@@ -10,2 +10,2 @@ fn old_name() {\n", + ); + let entries = symbols.get("src/lib.rs").expect("symbols for src/lib.rs"); + assert!(entries.contains("fn old_name() {")); + } + + #[test] + fn parse_status_tracks_many_xy_variants() { + let summary = parse_status( + "C:/repo".to_string(), + "\ +# branch.oid deadbee +# branch.head (detached) +# branch.upstream origin/main +# branch.ab +3 -2 +1 M. N... 100644 100644 100644 abc abc staged_only.txt +1 .M N... 100644 100644 100644 abc abc modified_only.txt +1 D. N... 100644 100644 100644 abc abc staged_delete.txt +1 .D N... 100644 100644 100644 abc abc worktree_delete.txt +2 R. N... 100644 100644 100644 abc abc R100 old_name.rs\tnew_name.rs +2 .R N... 100644 100644 100644 abc abc R100 old_rt.rs\tnew_rt.rs +u UU N... 100644 100644 100644 100644 abc abc abc conflict.txt +? scratch.txt +! target/ +", + ) + .expect("summary"); + + assert_eq!(summary.branch, None); + assert_eq!(summary.upstream.as_deref(), Some("origin/main")); + assert_eq!(summary.ahead, 3); + assert_eq!(summary.behind, 2); + assert_eq!(summary.staged, 3); + assert_eq!(summary.modified, 3); + assert_eq!(summary.deleted, 2); + assert_eq!(summary.renamed, 2); + assert_eq!(summary.untracked, 1); + assert_eq!(summary.conflicted, 1); + assert_eq!(summary.ignored, 1); + assert!(!summary.clean); + } + + #[test] + fn parse_status_reports_malformed_branch_ahead_count() { + let error = parse_status( + "C:/repo".to_string(), + "# branch.oid abc1234\n# branch.ab +nope -1\n", + ) + .expect_err("ahead parse should fail"); + assert!(error.to_string().contains("failed to parse ahead count")); + } + + #[test] + fn parse_status_reports_malformed_porcelain_line() { + let error = parse_status("C:/repo".to_string(), "# branch.oid abc1234\n1 \n") + .expect_err("missing xy should fail"); + assert!( + error + .to_string() + .contains("malformed porcelain v2 status line") + ); + } + + #[test] + fn parse_name_status_supports_rename_copy_typechange_unmerged_and_unknown() { + let codes = parse_name_status( + "\ +A\tadded.rs +D\tdeleted.rs +M\tmodified.rs +R087\tbefore.rs\tafter.rs +C100\tsource.rs\tcopy.rs +T\ttype.bin +U\tconflict.rs +X\tmystery.rs +", + ) + .expect("codes"); + + assert_eq!(codes.len(), 8); + assert!(matches!(codes[0].kind, FileChangeKind::Added)); + assert!(matches!(codes[1].kind, FileChangeKind::Deleted)); + assert!(matches!(codes[2].kind, FileChangeKind::Modified)); + assert!(matches!(codes[3].kind, FileChangeKind::Renamed)); + assert_eq!(codes[3].previous_path.as_deref(), Some("before.rs")); + assert_eq!(codes[3].path, "after.rs"); + assert!(matches!(codes[4].kind, FileChangeKind::Copied)); + assert_eq!(codes[4].previous_path.as_deref(), Some("source.rs")); + assert_eq!(codes[4].path, "copy.rs"); + assert!(matches!(codes[5].kind, FileChangeKind::TypeChanged)); + assert!(matches!(codes[6].kind, FileChangeKind::Unmerged)); + assert!(matches!(codes[7].kind, FileChangeKind::Unknown)); + } + + #[test] + fn parse_name_status_rejects_malformed_lines() { + let malformed_rename = + parse_name_status("R100\tonly-before.rs\n").expect_err("rename should need two paths"); + assert!( + malformed_rename + .to_string() + .contains("malformed rename/copy line") + ); + + let malformed_plain = + parse_name_status("M\n").expect_err("non-rename should include a path"); + assert!(malformed_plain.to_string().contains("malformed diff line")); + } + + #[test] + fn parse_numstat_and_path_normalization_handle_binary_and_renames() { + let rows = parse_numstat( + "\ +5\t3\tsrc/lib.rs +-\t-\tbinary.dat +1\t0\tsrc/{before.rs => after.rs} +2\t1\tbefore.txt => after.txt +broken\tline +", + ); + + assert_eq!( + rows, + vec![ + ("src/lib.rs".to_string(), Some(5), Some(3)), + ("binary.dat".to_string(), None, None), + ("after.rs".to_string(), Some(1), Some(0)), + ("after.txt".to_string(), Some(2), Some(1)) + ] + ); + assert_eq!(normalize_numstat_path("no-rename.txt"), "no-rename.txt"); + } + + #[test] + fn parse_symbols_by_file_collects_symbols_and_truncates_long_hunk_names() { + let long_symbol = format!("fn {}", "x".repeat(150)); + let patch = format!( + "\ +diff --git a/src/lib.rs b/src/lib.rs +@@ -1 +1 @@ fn alpha() {{ +@@ -4 +4 @@ fn beta() {{ +diff --git a/docs/readme.md b/docs/readme.md +@@ -1 +1 @@ +@@ -2 +2 @@ {long_symbol} +" + ); + + let symbols = parse_symbols_by_file(&patch); + let src = symbols.get("src/lib.rs").expect("src symbols"); + assert!(src.contains("fn alpha() {")); + assert!(src.contains("fn beta() {")); + + let docs = symbols.get("docs/readme.md").expect("docs symbols"); + assert_eq!(docs.len(), 1); + let only = docs.iter().next().expect("symbol"); + assert_eq!(only.len(), 120); + assert!(only.starts_with("fn ")); + } + + #[test] + fn parse_diff_output_counts_change_kinds_lines_and_symbols() { + let name_status = "\ +A\tadded.rs +D\tremoved.rs +M\tmod.rs +R100\told_name.rs\tnew_name.rs +C100\tsource.rs\tcopied.rs +T\tmode.bin +U\tconflict.rs +X\tmystery.rs +"; + let numstat = "\ +10\t0\tadded.rs +0\t4\tremoved.rs +3\t2\tmod.rs +1\t1\told_name.rs => new_name.rs +-\t-\tcopied.rs +0\t0\tmode.bin +8\t5\tmystery.rs +"; + let patch = "\ +diff --git a/mod.rs b/mod.rs +@@ -1 +1 @@ fn touched_mod() { +diff --git a/new_name.rs b/new_name.rs +@@ -1 +1 @@ fn renamed_fn() { +diff --git a/copied.rs b/copied.rs +@@ -1 +1 @@ fn copied_fn() { +diff --git a/mystery.rs b/mystery.rs +@@ -1 +1 @@ fn symbol_01() { +@@ -2 +2 @@ fn symbol_02() { +@@ -3 +3 @@ fn symbol_03() { +@@ -4 +4 @@ fn symbol_04() { +@@ -5 +5 @@ fn symbol_05() { +@@ -6 +6 @@ fn symbol_06() { +@@ -7 +7 @@ fn symbol_07() { +@@ -8 +8 @@ fn symbol_08() { +@@ -9 +9 @@ fn symbol_09() { +"; + + let summary = parse_diff_output( + "C:/repo".to_string(), + false, + &Vec::new(), + name_status, + numstat, + patch, + ) + .expect("summary"); + + assert_eq!(summary.mode, "working_tree"); + assert_eq!(summary.files_changed, 8); + assert_eq!(summary.added_files, 1); + assert_eq!(summary.deleted_files, 1); + assert_eq!(summary.modified_files, 2); + assert_eq!(summary.renamed_files, 1); + assert_eq!(summary.copied_files, 1); + assert_eq!(summary.type_changed_files, 1); + assert_eq!(summary.unmerged_files, 1); + assert_eq!(summary.line_additions, 22); + assert_eq!(summary.line_deletions, 12); + assert!( + summary + .touched_symbols + .contains(&"fn symbol_09() {".to_string()) + ); + assert!( + summary + .touched_symbols + .contains(&"fn copied_fn() {".to_string()) + ); + + let renamed = summary + .files + .iter() + .find(|file| file.path == "new_name.rs") + .expect("renamed entry"); + assert_eq!(renamed.previous_path.as_deref(), Some("old_name.rs")); + assert_eq!(renamed.status, "renamed"); + assert_eq!(renamed.additions, Some(1)); + assert_eq!(renamed.deletions, Some(1)); + + let mystery = summary + .files + .iter() + .find(|file| file.path == "mystery.rs") + .expect("mystery entry"); + assert_eq!(mystery.status, "unknown"); + assert_eq!(mystery.symbols.len(), 8); + assert!(!mystery.symbols.contains(&"fn symbol_09() {".to_string())); + } + + #[test] + fn render_diff_summary_renders_missing_counts_and_symbol_lists() { + let summary = GitDiffSummary { + root: "C:/repo".to_string(), + mode: "revisions".to_string(), + revisions: vec!["HEAD~1".to_string(), "HEAD".to_string()], + files_changed: 2, + added_files: 0, + deleted_files: 0, + modified_files: 2, + renamed_files: 0, + copied_files: 0, + type_changed_files: 0, + unmerged_files: 0, + line_additions: 3, + line_deletions: 1, + touched_symbols: vec!["fn alpha() {".to_string(), "fn beta() {".to_string()], + files: vec![ + GitDiffFile { + path: "src/a.rs".to_string(), + previous_path: None, + status: "modified".to_string(), + additions: None, + deletions: None, + symbols: Vec::new(), + }, + GitDiffFile { + path: "src/b.rs".to_string(), + previous_path: Some("src/old_b.rs".to_string()), + status: "modified".to_string(), + additions: Some(3), + deletions: Some(1), + symbols: vec!["fn alpha() {".to_string(), "fn beta() {".to_string()], + }, + ], + }; + + let output = render_diff_summary(&summary); + assert!(output.contains("root=C:/repo mode=revisions files=2")); + assert!(output.contains("symbols=2")); + assert!(output.contains( + "status=modified path=src/a.rs previous=- additions=- deletions=- symbols=-" + )); + assert!(output.contains( + "status=modified path=src/b.rs previous=src/old_b.rs additions=3 deletions=1 symbols=fn alpha() {|fn beta() {" + )); + } + + #[test] + fn parse_cli_rejects_invalid_revision_and_repo_combinations() { + let ignored_with_diff = + parse_cli_from(["gitshape", "--ignored", "diff"]).expect_err("invalid diff option"); + assert!( + ignored_with_diff + .to_string() + .contains("--ignored is only valid with status") + ); + + let staged_with_revision = parse_cli_from(["gitshape", "diff", "--staged", "HEAD"]) + .expect_err("staged should not accept revisions"); + assert!( + staged_with_revision + .to_string() + .contains("diff --staged does not accept explicit revisions") + ); + + let too_many_revisions = + parse_cli_from(["gitshape", "diff", "A", "B", "C"]).expect_err("too many revisions"); + assert!( + too_many_revisions + .to_string() + .contains("diff accepts at most two revisions") + ); + + let duplicated_repo = parse_cli_from(["gitshape", "status", "--repo", "one", "two"]) + .expect_err("should reject both --repo and positional"); + assert!( + duplicated_repo + .to_string() + .contains("status accepts either --repo or positional [PATH], not both") + ); + + let too_many_status_paths = parse_cli_from(["gitshape", "status", "one", "two"]) + .expect_err("status should not accept two positional paths"); + assert!( + too_many_status_paths + .to_string() + .contains("status accepts at most one repository path") + ); + + let (_, implicit_status) = parse_cli_from(["gitshape"]).expect("implicit status"); + assert!(matches!( + implicit_status.command, + CommandMode::Status { + repo, + include_ignored: false, + } if repo == std::path::Path::new(".") + )); + } + + #[test] + fn parse_status_handles_unborn_head_without_runtime_failure() { + let summary = parse_status( + "C:\\repo".to_string(), + "# branch.oid (initial)\n# branch.head main\n? scratch.txt\n", + ) + .expect("status"); + assert!(summary.repository_present); + assert_eq!(summary.head, "-"); + assert_eq!(summary.branch.as_deref(), Some("main")); + assert_eq!(summary.untracked, 1); + assert!(!summary.clean); + } + + #[test] + fn inspect_repository_accepts_unborn_repositories() { + let temp = tempdir().expect("tempdir"); + let status = Command::new("git") + .arg("-C") + .arg(temp.path()) + .args(["-c", "init.defaultBranch=main", "init"]) + .status() + .expect("git init"); + assert!(status.success(), "git init failed"); + + let summary = inspect_repository(&temp.path().to_path_buf(), false).expect("summary"); + assert!(summary.repository_present); + assert_eq!(summary.branch.as_deref(), Some("main")); + assert_eq!(summary.head, "-"); + assert!(summary.clean); + } + + #[test] + fn inspect_repository_reports_missing_repository_as_summary() { + let temp = tempdir().expect("tempdir"); + let summary = inspect_repository(&temp.path().to_path_buf(), false).expect("summary"); + assert!(!summary.repository_present); + assert_eq!(summary.root, temp.path().display().to_string()); + assert_eq!(summary.head, "-"); + assert_eq!( + summary.error.as_deref(), + Some("fatal: not a git repository (or any of the parent directories): .git") + ); + } +} diff --git a/crates/gitshape/src/main.rs b/crates/gitshape/src/main.rs new file mode 100644 index 0000000..681b1ae --- /dev/null +++ b/crates/gitshape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `gitshape`. + +fn main() { + std::process::exit(gitshape::main_entry()); +} diff --git a/crates/gitshape/tests/gitshape_cli.rs b/crates/gitshape/tests/gitshape_cli.rs new file mode 100644 index 0000000..4db2f9b --- /dev/null +++ b/crates/gitshape/tests/gitshape_cli.rs @@ -0,0 +1,288 @@ +//! Integration tests for the `gitshape` command. + +use std::fs; +use std::process::Command as ProcessCommand; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("gitshape").expect("binary") +} + +#[test] +fn status_subcommand_summarizes_dirty_repository_as_json() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["-c", "init.defaultBranch=main", "init"]); + run_git(temp.path(), ["config", "user.name", "Codex"]); + run_git(temp.path(), ["config", "user.email", "codex@example.com"]); + fs::write(temp.path().join("tracked.txt"), "first\n").expect("tracked"); + run_git(temp.path(), ["add", "tracked.txt"]); + run_git(temp.path(), ["commit", "-m", "init"]); + + fs::write(temp.path().join("tracked.txt"), "second\n").expect("modify"); + fs::write(temp.path().join("new.txt"), "new\n").expect("untracked"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("status") + .arg("--repo") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"modified\":1")) + .stdout(predicate::str::contains("\"untracked\":1")) + .stdout(predicate::str::contains("\"clean\":false")); +} + +#[test] +fn diff_subcommand_reports_staged_change_summary() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["init"]); + run_git(temp.path(), ["config", "user.name", "Codex"]); + run_git(temp.path(), ["config", "user.email", "codex@example.com"]); + fs::write( + temp.path().join("src.rs"), + "fn build() {\n println!(\"old\");\n}\n", + ) + .expect("src"); + run_git(temp.path(), ["add", "src.rs"]); + run_git(temp.path(), ["commit", "-m", "init"]); + + fs::write( + temp.path().join("src.rs"), + "fn build() {\n println!(\"new\");\n}\n", + ) + .expect("modify"); + run_git(temp.path(), ["add", "src.rs"]); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("diff") + .arg("--repo") + .arg(temp.path()) + .arg("--staged") + .assert() + .success() + .stdout(predicate::str::contains("\"mode\":\"staged\"")) + .stdout(predicate::str::contains("\"files_changed\":1")) + .stdout(predicate::str::contains("\"status\":\"modified\"")); +} + +#[test] +fn status_subcommand_handles_unborn_repository() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["-c", "init.defaultBranch=main", "init"]); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("status") + .arg("--repo") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"branch\":\"main\"")) + .stdout(predicate::str::contains("\"head\":\"-\"")) + .stdout(predicate::str::contains("\"clean\":true")); +} + +#[test] +fn diff_subcommand_accepts_explicit_revisions() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["init"]); + run_git(temp.path(), ["config", "user.name", "Codex"]); + run_git(temp.path(), ["config", "user.email", "codex@example.com"]); + fs::write(temp.path().join("demo.txt"), "one\n").expect("demo"); + run_git(temp.path(), ["add", "demo.txt"]); + run_git(temp.path(), ["commit", "-m", "first"]); + let first = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]); + + fs::write(temp.path().join("demo.txt"), "one\ntwo\n").expect("update"); + run_git(temp.path(), ["add", "demo.txt"]); + run_git(temp.path(), ["commit", "-m", "second"]); + let second = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("diff") + .arg("--repo") + .arg(temp.path()) + .arg(first) + .arg(second) + .assert() + .success() + .stdout(predicate::str::contains("\"mode\":\"revisions\"")) + .stdout(predicate::str::contains("\"revisions\":[")) + .stdout(predicate::str::contains("\"files_changed\":1")); +} + +#[test] +fn diff_subcommand_returns_success_for_empty_revision_diff() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["init"]); + run_git(temp.path(), ["config", "user.name", "Codex"]); + run_git(temp.path(), ["config", "user.email", "codex@example.com"]); + fs::write(temp.path().join("demo.txt"), "one\n").expect("demo"); + run_git(temp.path(), ["add", "demo.txt"]); + run_git(temp.path(), ["commit", "-m", "first"]); + + let mut command = cargo_command(); + command + .arg("diff") + .arg("--repo") + .arg(temp.path()) + .arg("HEAD") + .arg("HEAD") + .assert() + .success() + .stdout(predicate::str::contains("mode=revisions")) + .stdout(predicate::str::contains("files=0")) + .stdout(predicate::str::contains("line_additions=0")) + .stdout(predicate::str::contains("line_deletions=0")); +} + +#[test] +fn help_includes_new_subcommands_and_flags() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("gitshape [OPTIONS] status")) + .stdout(predicate::str::contains("gitshape [OPTIONS] diff")) + .stdout(predicate::str::contains("--repo")) + .stdout(predicate::str::contains("--staged")) + .stdout(predicate::str::contains("--ignored")) + .stdout(predicate::str::contains("gitshape")); +} + +#[test] +fn bare_path_defaults_to_status_mode() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["-c", "init.defaultBranch=main", "init"]); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"repository_present\":true")) + .stdout(predicate::str::contains("\"branch\":\"main\"")); +} + +#[test] +fn status_reports_missing_repository_without_failing() { + let temp = tempdir().expect("tempdir"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("status") + .arg("--repo") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"repository_present\":false")) + .stdout(predicate::str::contains( + "\"error\":\"fatal: not a git repository", + )); +} + +#[test] +fn diff_subcommand_rejects_staged_with_explicit_revisions() { + let mut command = cargo_command(); + command + .arg("diff") + .arg("--staged") + .arg("HEAD") + .assert() + .failure() + .stderr(predicate::str::contains( + "diff --staged does not accept explicit revisions", + )); +} + +#[test] +fn status_subcommand_rejects_repo_and_positional_path_together() { + let temp = tempdir().expect("tempdir"); + + let mut command = cargo_command(); + command + .arg("status") + .arg("--repo") + .arg(temp.path()) + .arg(temp.path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "status accepts either --repo or positional [PATH], not both", + )); +} + +#[test] +fn diff_subcommand_text_output_renders_rename_details() { + let temp = tempdir().expect("tempdir"); + run_git(temp.path(), ["init"]); + run_git(temp.path(), ["config", "user.name", "Codex"]); + run_git(temp.path(), ["config", "user.email", "codex@example.com"]); + fs::write( + temp.path().join("old_name.rs"), + "fn rename_me() {\n println!(\"old\");\n}\n", + ) + .expect("old file"); + run_git(temp.path(), ["add", "old_name.rs"]); + run_git(temp.path(), ["commit", "-m", "before rename"]); + let first = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]); + + run_git(temp.path(), ["mv", "old_name.rs", "new_name.rs"]); + fs::write( + temp.path().join("new_name.rs"), + "fn rename_me() {\n println!(\"old\");\n}\nfn helper() {}\n", + ) + .expect("new file"); + run_git(temp.path(), ["add", "new_name.rs"]); + run_git(temp.path(), ["commit", "-m", "after rename"]); + let second = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]); + + let mut command = cargo_command(); + command + .arg("diff") + .arg("--repo") + .arg(temp.path()) + .arg(first) + .arg(second) + .assert() + .success() + .stdout(predicate::str::contains("mode=revisions")) + .stdout(predicate::str::contains("status=renamed")) + .stdout(predicate::str::contains("path=new_name.rs")) + .stdout(predicate::str::contains("previous=old_name.rs")); +} + +fn run_git(cwd: &std::path::Path, args: [&str; N]) { + let status = ProcessCommand::new("git") + .current_dir(cwd) + .args(args) + .status() + .expect("git command"); + assert!(status.success(), "git command failed"); +} + +fn run_git_capture(cwd: &std::path::Path, args: [&str; N]) -> String { + let output = ProcessCommand::new("git") + .current_dir(cwd) + .args(args) + .output() + .expect("git command output"); + assert!(output.status.success(), "git command failed"); + String::from_utf8(output.stdout) + .expect("utf8") + .trim() + .to_string() +} diff --git a/crates/hitsnip/Cargo.toml b/crates/hitsnip/Cargo.toml new file mode 100644 index 0000000..58129ab --- /dev/null +++ b/crates/hitsnip/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hitsnip" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Merge search hits into compact, AI-friendly snippets." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/hitsnip/src/lib.rs b/crates/hitsnip/src/lib.rs new file mode 100644 index 0000000..acddd5f --- /dev/null +++ b/crates/hitsnip/src/lib.rs @@ -0,0 +1,1272 @@ +//! The `hitsnip` command merges search hits into compact snippets. + +use codeindex::{CodeIndexer, IndexedSymbol, detect_language, enclosing_symbol}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, BufRead, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, is_probable_test_path, + map_result_count, parse_color_choice, parse_format_choice, parse_input_format, print_json, + print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const HELP: &str = "\ +Merge search hits into compact, AI-friendly snippets. + +Usage: + hitsnip [OPTIONS] [HIT...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --path Treat path-less hit input as belonging to one file + --context Context lines to add around each hit + --max-gap Maximum gap between merged snippet windows + --limit Optional maximum number of snippets to emit (text + --def defaults to 20) + --allow-empty Succeed quietly when the hit stream is empty + --def Promote hits to enclosing definitions when codeindex supports the file + -h, --help Show this help text + -V, --version Show the command version + +Examples: + hitsnip .\\fixtures\\reading\\sample.rs:18 .\\fixtures\\reading\\sample.rs:26:9 + rg -nH \"Mode::\" .\\fixtures\\reading\\sample.rs | hitsnip --context 1 + rg -nH \"helper\" .\\fixtures\\polyglot\\repo\\src\\lib.rs | hitsnip --def + rg -n \"Mode::\" .\\fixtures\\reading\\sample.rs | hitsnip .\\fixtures\\reading\\sample.rs + rg -n \"error|warning\" .\\fixtures\\diag\\rust-errors.txt | hitsnip --path .\\fixtures\\diag\\rust-errors.txt --context 1 + rg -n \"Mode::\" .\\fixtures\\reading\\sample.rs | hitsnip --path .\\fixtures\\reading\\sample.rs + hitsnip .\\fixtures\\reading\\sample.rs:18 .\\fixtures\\reading\\sample.rs:26:9 --json | ConvertFrom-Json + bat --style=plain --paging=never .\\fixtures\\hits\\rg-output.txt | hitsnip --max-gap 1 +"; + +const DEFAULT_TEXT_DEFINITION_LIMIT: usize = 20; + +/// CLI arguments for the `hitsnip` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Context lines to add around each hit. + context: usize, + /// Default file path for path-less hit streams such as `rg -n` on a single file. + default_path: Option, + /// Maximum allowed gap between snippet windows before they are merged. + max_gap: usize, + /// Optional maximum number of snippets to emit. + limit: Option, + /// Succeed quietly when stdin or explicit hits are empty. + allow_empty: bool, + /// Promote hit windows to enclosing definitions when supported. + definitions: bool, + /// Explicit hit arguments when stdin is empty. + hits: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LineHit { + path: String, + line: usize, + column: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SnippetWindow { + path: String, + start_line: usize, + end_line: usize, + hit_lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SnippetLine { + number: usize, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Snippet { + path: String, + start_line: usize, + end_line: usize, + hit_count: usize, + hit_lines: Vec, + lines: Vec, + definition: Option, + fallback_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoadedHits { + hits: Vec, + stdin_consumed: bool, +} + +struct LoadedSnippetFile { + lines: Vec, + definition_symbols: Option>, +} + +impl LoadedSnippetFile { + fn new(content: &str, definition_symbols: Option>) -> Self { + let lines = content + .trim_start_matches('\u{feff}') + .lines() + .map(ToString::to_string) + .collect(); + + Self { + lines, + definition_symbols, + } + } +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("hitsnip {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + context: 2, + default_path: None, + max_gap: 1, + limit: None, + allow_empty: false, + definitions: false, + hits: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("path") => { + cli.default_path = Some(parser_value_string(&mut parser, "--path")?); + } + Long("context") => { + cli.context = + parse_usize_flag("--context", &parser_value_string(&mut parser, "--context")?)?; + } + Long("max-gap") => { + cli.max_gap = + parse_usize_flag("--max-gap", &parser_value_string(&mut parser, "--max-gap")?)?; + } + Long("limit") => { + cli.limit = Some(parse_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + Long("allow-empty") => cli.allow_empty = true, + Long("def") => cli.definitions = true, + ArgValue(value) => cli.hits.push(os_string_to_string(value, "hit")?), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_string_to_string(value, flag) +} + +fn os_string_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + let loaded = load_hits(cli)?; + if loaded.hits.is_empty() { + if cli.allow_empty { + if cli.common.render_mode() == RenderMode::Json { + print_json(&Vec::::new())?; + } + return Ok(ExitCode::Success); + } + if loaded.stdin_consumed { + if cli.common.render_mode() == RenderMode::Json { + print_json(&Vec::::new())?; + } + if !cli.common.quiet { + eprintln!("no hits received on stdin; use --allow-empty to succeed quietly"); + } + return Ok(ExitCode::NoResults); + } + return Err(CliError::usage( + "provide at least one hit or pipe hit lines into stdin", + )); + } + + let mut snippets = materialize_snippets( + plan_snippets(&loaded.hits, cli.context, cli.max_gap), + cli.limit, + cli.definitions, + )?; + if cli.common.render_mode() == RenderMode::Text + && cli.definitions + && cli.limit.is_none() + && snippets.len() > DEFAULT_TEXT_DEFINITION_LIMIT + { + snippets.truncate(DEFAULT_TEXT_DEFINITION_LIMIT); + if !cli.common.quiet { + eprintln!( + "note: text mode capped to the first {DEFAULT_TEXT_DEFINITION_LIMIT} definition snippets; rerun with --limit or --json for full output" + ); + } + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&snippets)?, + RenderMode::Toon => print_structured(&snippets, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_text_snippets(&snippets)), + } + + Ok(map_result_count(snippets.len())) +} + +fn load_hits(cli: &Cli) -> Result { + let (default_path, explicit_hits) = infer_default_path(cli); + let stdin_consumed = + should_read_stdin(!explicit_hits.is_empty(), cli.common.stdin_is_terminal()); + + if stdin_consumed { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let hits = + parse_hits_from_string(&buffer, cli.common.input_format, default_path.as_deref())?; + if !hits.is_empty() { + return Ok(LoadedHits { + hits, + stdin_consumed, + }); + } + } + + let hits = explicit_hits + .iter() + .map(|raw| parse_text_hit_with_default_path(raw, default_path.as_deref())) + .collect::, _>>()?; + Ok(LoadedHits { + hits, + stdin_consumed, + }) +} + +fn infer_default_path(cli: &Cli) -> (Option, &[String]) { + if let Some(path) = &cli.default_path { + return (Some(path.clone()), &cli.hits); + } + + if cli.hits.len() != 1 { + return (None, &cli.hits); + } + + let candidate = &cli.hits[0]; + let candidate_path = PathBuf::from(candidate.as_str()); + if candidate_path.is_file() + || (parse_leftmost_text_hit(candidate).is_none() && looks_like_file_argument(candidate)) + { + return (Some(candidate.clone()), &[]); + } + + (None, &cli.hits) +} + +fn looks_like_file_argument(value: &str) -> bool { + value.contains('\\') + || value.contains('/') + || PathBuf::from(value).extension().is_some() + || value.starts_with('.') +} + +fn parse_hits_from_string( + content: &str, + input_format: InputFormat, + default_path: Option<&str>, +) -> Result, CliError> { + let mut hits = Vec::new(); + + for (index, line) in io::Cursor::new(content).lines().enumerate() { + let raw = + line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + let hit = match input_format { + InputFormat::Lines => parse_text_hit_with_default_path(trimmed, default_path)?, + InputFormat::Jsonl => parse_json_hit(trimmed, index + 1)?, + InputFormat::Auto => match serde_json::from_str::(trimmed) { + Ok(value) => parse_json_hit_value(&value, index + 1)?, + Err(_) => parse_text_hit_with_default_path(trimmed, default_path)?, + }, + }; + hits.push(hit); + } + + Ok(hits) +} + +fn parse_text_hit_with_default_path( + raw: &str, + default_path: Option<&str>, +) -> Result { + if let Ok(line) = raw.parse::() { + if let Some(path) = default_path { + return Ok(LineHit { + path: path.to_string(), + line, + column: None, + }); + } + return Err(invalid_text_hit_error(raw, default_path)); + } + + if let (Some(path), Some((line_text, _tail))) = (default_path, raw.split_once(':')) + && let Ok(line) = line_text.parse::() + && line > 0 + { + return Ok(LineHit { + path: path.to_string(), + line, + column: None, + }); + } + + let (path, line, column) = + parse_leftmost_text_hit(raw).ok_or_else(|| invalid_text_hit_error(raw, default_path))?; + if line == 0 || column == Some(0) { + return Err(invalid_text_hit_error(raw, default_path)); + } + Ok(LineHit { path, line, column }) +} + +fn parse_leftmost_text_hit(raw: &str) -> Option<(String, usize, Option)> { + for (index, _) in raw.match_indices(':') { + let path = &raw[..index]; + if path.is_empty() { + continue; + } + let rest = &raw[index + 1..]; + if let Some((line_text, tail)) = rest.split_once(':') { + let Ok(line) = line_text.parse::() else { + continue; + }; + if line == 0 { + continue; + } + let column = tail.split_once(':').map_or_else( + || tail.parse::().ok(), + |(column_text, _)| column_text.parse::().ok(), + ); + return Some((path.to_string(), line, column)); + } + if let Ok(line) = rest.parse::() + && line > 0 + { + return Some((path.to_string(), line, None)); + } + } + None +} + +fn invalid_text_hit_error(raw: &str, default_path: Option<&str>) -> CliError { + let looks_like_pathless_rg = raw + .split(':') + .next() + .is_some_and(|segment| segment.parse::().is_ok()); + if default_path.is_none() && looks_like_pathless_rg { + CliError::usage(format!( + "invalid hit format: {raw}; if this came from `rg -n` on a single file, rerun with `-H`, pass `--path `, or keep `rg -n` and pipe into `hitsnip `" + )) + } else { + CliError::usage(format!("invalid hit format: {raw}")) + } +} + +fn parse_json_hit(raw: &str, line_number: usize) -> Result { + let value = serde_json::from_str::(raw).map_err(|error| { + CliError::runtime(format!("invalid JSONL hit at line {line_number}: {error}")) + })?; + parse_json_hit_value(&value, line_number) +} + +fn parse_json_hit_value(value: &Value, line_number: usize) -> Result { + let Value::Object(object) = value else { + return Err(CliError::runtime(format!( + "JSON hit at line {line_number} must be an object with path and line" + ))); + }; + + let path = object + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON hit at line {line_number} is missing a string path" + )) + })? + .to_string(); + let line = object + .get("line") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON hit at line {line_number} is missing a positive integer line" + )) + })?; + let column = object + .get("column") + .map(|value| { + value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON hit at line {line_number} has an invalid column" + )) + }) + }) + .transpose()?; + + Ok(LineHit { path, line, column }) +} + +fn plan_snippets(hits: &[LineHit], context: usize, max_gap: usize) -> Vec { + let mut grouped = BTreeMap::>::new(); + for hit in hits { + grouped + .entry(hit.path.clone()) + .or_default() + .push(hit.clone()); + } + + let mut snippets = Vec::new(); + for (path, mut path_hits) in grouped { + path_hits.sort_by(|left, right| { + left.line + .cmp(&right.line) + .then(left.column.cmp(&right.column)) + }); + path_hits.dedup_by(|left, right| left.line == right.line); + snippets.extend(plan_file_snippets(&path, &path_hits, context, max_gap)); + } + + snippets +} + +fn plan_file_snippets( + path: &str, + hits: &[LineHit], + context: usize, + max_gap: usize, +) -> Vec { + let mut snippets = Vec::new(); + let mut current: Option = None; + + for hit in hits { + let start_line = hit.line.saturating_sub(context).max(1); + let end_line = hit.line.saturating_add(context); + + if let Some(window) = &mut current { + let merge_until = window.end_line.saturating_add(max_gap).saturating_add(1); + if start_line <= merge_until { + window.end_line = window.end_line.max(end_line); + window.hit_lines.push(hit.line); + continue; + } + snippets.push(window.clone()); + } + + current = Some(SnippetWindow { + path: path.to_string(), + start_line, + end_line, + hit_lines: vec![hit.line], + }); + } + + if let Some(window) = current { + snippets.push(window); + } + + snippets +} + +fn materialize_snippets( + windows: Vec, + limit: Option, + definitions: bool, +) -> Result, CliError> { + materialize_snippets_with_loader(windows, limit, definitions, |path| { + fs::read_to_string(PathBuf::from(path)) + .map_err(|error| CliError::runtime(format!("failed to read {path}: {error}"))) + }) +} + +fn materialize_snippets_with_loader( + windows: Vec, + limit: Option, + definitions: bool, + mut load_file: F, +) -> Result, CliError> +where + F: FnMut(&str) -> Result, +{ + let mut snippets = Vec::new(); + let mut indexer = CodeIndexer::new(); + let mut loaded_files = BTreeMap::::new(); + + for window in windows { + if !loaded_files.contains_key(&window.path) { + let content = load_file(&window.path)?; + let path = PathBuf::from(&window.path); + let definition_symbols = if definitions && detect_language(path.as_path()).is_some() { + indexer + .index_file(path.as_path(), &content) + .ok() + .map(|indexed| indexed.symbols().to_vec()) + } else { + None + }; + loaded_files.insert( + window.path.clone(), + LoadedSnippetFile::new(&content, definition_symbols), + ); + } + let loaded_file = loaded_files + .get(&window.path) + .expect("loaded file should be cached before materialization"); + let end_line = window.end_line.min(loaded_file.lines.len()); + let start_line = window.start_line.min(end_line.max(1)); + + let snippet_lines = loaded_file.lines[start_line - 1..end_line] + .iter() + .enumerate() + .map(|(offset, text)| SnippetLine { + number: start_line + offset, + text: text.clone(), + }) + .collect::>(); + + let (definition, fallback_reason) = if definitions { + let maybe_definition = loaded_file + .definition_symbols + .as_deref() + .and_then(|symbols| { + window + .hit_lines + .first() + .and_then(|line| enclosing_symbol(symbols, *line, None)) + }) + .cloned(); + if maybe_definition.is_some() { + (maybe_definition, None) + } else { + ( + None, + Some("definition lookup unavailable for this hit window".to_string()), + ) + } + } else { + (None, None) + }; + + let snippet = Snippet { + path: window.path, + start_line, + end_line, + hit_count: window.hit_lines.len(), + hit_lines: window.hit_lines, + lines: snippet_lines, + definition, + fallback_reason, + }; + + if let Some(existing) = snippets + .iter_mut() + .find(|candidate| same_definition(candidate, &snippet)) + { + merge_snippets(existing, snippet); + continue; + } + + snippets.push(snippet); + } + + sort_snippets(&mut snippets); + if let Some(limit) = limit { + snippets.truncate(limit); + } + + Ok(snippets) +} + +fn sort_snippets(snippets: &mut [Snippet]) { + snippets.sort_unstable_by(|left, right| { + ( + is_probable_test_path(&left.path), + left.path.as_str(), + left.definition + .as_ref() + .map_or(left.start_line, |definition| definition.start_line), + left.definition + .as_ref() + .map_or("", |definition| definition.qualified_name.as_str()), + left.start_line, + ) + .cmp(&( + is_probable_test_path(&right.path), + right.path.as_str(), + right + .definition + .as_ref() + .map_or(right.start_line, |definition| definition.start_line), + right + .definition + .as_ref() + .map_or("", |definition| definition.qualified_name.as_str()), + right.start_line, + )) + }); +} + +fn same_definition(left: &Snippet, right: &Snippet) -> bool { + match (&left.definition, &right.definition) { + (Some(left), Some(right)) => { + left.path == right.path + && left.qualified_name == right.qualified_name + && left.start_line == right.start_line + && left.end_line == right.end_line + } + _ => false, + } +} + +fn merge_snippets(existing: &mut Snippet, incoming: Snippet) { + existing.start_line = existing.start_line.min(incoming.start_line); + existing.end_line = existing.end_line.max(incoming.end_line); + + existing.hit_lines.extend(incoming.hit_lines); + existing.hit_lines.sort_unstable(); + existing.hit_lines.dedup(); + existing.hit_count = existing.hit_lines.len(); + + existing.lines.extend(incoming.lines); + existing.lines.sort_by_key(|line| line.number); + existing + .lines + .dedup_by(|left, right| left.number == right.number); +} + +fn render_text_snippets(snippets: &[Snippet]) -> String { + let mut rendered = String::new(); + + for (index, snippet) in snippets.iter().enumerate() { + if index > 0 { + rendered.push('\n'); + } + writeln!( + rendered, + "path={} lines={}:{} hits={} hit_lines={}", + snippet.path, + snippet.start_line, + snippet.end_line, + snippet.hit_count, + join_usize_csv(&snippet.hit_lines) + ) + .expect("writing to a String cannot fail"); + if let Some(definition) = &snippet.definition { + writeln!( + rendered, + "definition={} lines={}:{}", + definition.qualified_name, definition.start_line, definition.end_line + ) + .expect("writing to a String cannot fail"); + for (offset, line) in definition.text.lines().enumerate() { + writeln!(rendered, "{}: {}", definition.start_line + offset, line) + .expect("writing to a String cannot fail"); + } + } else { + for line in &snippet.lines { + writeln!(rendered, "{}: {}", line.number, line.text) + .expect("writing to a String cannot fail"); + } + if let Some(reason) = &snippet.fallback_reason { + writeln!(rendered, "fallback={reason}").expect("writing to a String cannot fail"); + } + } + } + + rendered +} + +fn join_usize_csv(values: &[usize]) -> String { + let mut rendered = String::new(); + for (index, value) in values.iter().enumerate() { + if index > 0 { + rendered.push(','); + } + write!(rendered, "{value}").expect("writing to a String cannot fail"); + } + rendered +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_text_hits_handle_windows_paths_and_rg_lines() { + assert_eq!( + parse_text_hit_with_default_path(r"C:\repo\demo.rs:18:9:pub fn run() {}", None) + .expect("rg hit"), + LineHit { + path: r"C:\repo\demo.rs".to_string(), + line: 18, + column: Some(9), + } + ); + assert_eq!( + parse_text_hit_with_default_path(r"fixtures\reading\sample.rs:22:Mode::Fast", None) + .expect("plain hit"), + LineHit { + path: r"fixtures\reading\sample.rs".to_string(), + line: 22, + column: None, + } + ); + assert_eq!( + parse_text_hit_with_default_path( + r#".\crates\hitsnip\src\lib.rs:644: let raw = r"C:\repo\demo.rs:18:9:pub fn run() {}";"#, + None, + ) + .expect("outer rg hit"), + LineHit { + path: r".\crates\hitsnip\src\lib.rs".to_string(), + line: 644, + column: None, + } + ); + } + + #[test] + fn parse_hits_from_lines_and_jsonl_report_errors() { + let hits = parse_hits_from_string( + "fixtures\\reading\\sample.rs:18\n{\"path\":\"demo.rs\",\"line\":4,\"column\":2}\n", + InputFormat::Auto, + None, + ) + .expect("auto hits"); + assert_eq!(hits.len(), 2); + assert_eq!(hits[1].column, Some(2)); + + let error = parse_hits_from_string("{\"path\":\"demo.rs\"}\n", InputFormat::Jsonl, None) + .expect_err("missing line should fail"); + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("missing a positive integer line") + )); + + let error = + parse_text_hit_with_default_path("demo.rs:not-a-line", None).expect_err("bad line"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("invalid hit format") + )); + } + + #[test] + fn snippet_planning_merges_nearby_hits_and_renders_text() { + let hits = vec![ + LineHit { + path: "demo.rs".to_string(), + line: 18, + column: None, + }, + LineHit { + path: "demo.rs".to_string(), + line: 22, + column: Some(4), + }, + LineHit { + path: "demo.rs".to_string(), + line: 26, + column: None, + }, + ]; + let windows = plan_snippets(&hits, 1, 1); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].start_line, 17); + assert_eq!(windows[0].end_line, 27); + + let rendered = render_text_snippets(&[Snippet { + path: "demo.rs".to_string(), + start_line: 17, + end_line: 27, + hit_count: 3, + hit_lines: vec![18, 22, 26], + lines: vec![SnippetLine { + number: 18, + text: "pub fn run() {}".to_string(), + }], + definition: None, + fallback_reason: None, + }]); + assert!(rendered.contains("hits=3")); + assert!(rendered.contains("hit_lines=18,22,26")); + } + + #[test] + fn snippet_planning_saturates_extreme_line_and_gap_values() { + let windows = plan_file_snippets( + "demo.rs", + &[ + LineHit { + path: "demo.rs".to_string(), + line: usize::MAX - 1, + column: None, + }, + LineHit { + path: "demo.rs".to_string(), + line: usize::MAX, + column: None, + }, + ], + usize::MAX, + usize::MAX, + ); + + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].start_line, 1); + assert_eq!(windows[0].end_line, usize::MAX); + assert_eq!(windows[0].hit_lines, vec![usize::MAX - 1, usize::MAX]); + } + + #[test] + fn run_treats_empty_piped_input_as_no_results() { + let exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + context: 1, + default_path: None, + max_gap: 1, + limit: None, + allow_empty: false, + definitions: false, + hits: Vec::new(), + }) + .expect("empty piped input should be tolerated"); + assert_eq!(exit, ExitCode::NoResults); + + assert!(parse_json_hit_value(&json!({"path":"demo.rs","line": 4}), 1).is_ok()); + } + + #[test] + fn parsing_helpers_cover_invalid_text_and_json_inputs() { + let invalid_text = parse_text_hit_with_default_path("sample.rs", None) + .expect_err("missing line should fail"); + assert!(matches!( + invalid_text, + CliError::Usage(message) if message.contains("invalid hit format") + )); + + let defaulted = + parse_text_hit_with_default_path("18:error here", Some("sample.rs")).expect("default"); + assert_eq!( + defaulted, + LineHit { + path: "sample.rs".to_string(), + line: 18, + column: None, + } + ); + let hinted = + parse_text_hit_with_default_path("18:error here", None).expect_err("hinted error"); + assert!(matches!( + hinted, + CliError::Usage(message) + if message.contains("rg -n") + && message.contains("--path ") + && message.contains("hitsnip ") + )); + + let zero_column = parse_text_hit_with_default_path("sample.rs:18:0", None) + .expect_err("zero column should fail"); + assert!(matches!( + zero_column, + CliError::Usage(message) if message.contains("invalid hit format") + )); + + let invalid_json = parse_json_hit("{\"path\":\"sample.rs\"}", 3).expect_err("missing line"); + assert!(matches!( + invalid_json, + CliError::Runtime(message) if message.contains("positive integer line") + )); + let zero_json_line = + parse_json_hit("{\"path\":\"sample.rs\",\"line\":0}", 3).expect_err("zero line"); + assert!(matches!( + zero_json_line, + CliError::Runtime(message) if message.contains("positive integer line") + )); + let zero_json_column = + parse_json_hit("{\"path\":\"sample.rs\",\"line\":1,\"column\":0}", 3) + .expect_err("zero column"); + assert!(matches!( + zero_json_column, + CliError::Runtime(message) if message.contains("invalid column") + )); + + let non_object = + parse_json_hit_value(&json!("sample.rs"), 4).expect_err("string should fail"); + assert!(matches!( + non_object, + CliError::Runtime(message) if message.contains("must be an object") + )); + } + + #[test] + fn infer_default_path_accepts_single_explicit_file_for_rg_n_input() { + let temp = tempdir().expect("tempdir"); + let source = temp.path().join("sample.rs"); + fs::write(&source, "fn main() {}\n").expect("source"); + + let cli = Cli { + common: common_args(false, InputFormat::Auto), + context: 1, + default_path: None, + max_gap: 1, + limit: None, + allow_empty: false, + definitions: false, + hits: vec![source.display().to_string()], + }; + let (default_path, explicit_hits) = infer_default_path(&cli); + + let expected = source.display().to_string(); + assert_eq!(default_path.as_deref(), Some(expected.as_str())); + assert!(explicit_hits.is_empty()); + } + + #[test] + fn planning_and_materialization_cover_separate_windows_and_limits() { + let windows = plan_file_snippets( + "demo.rs", + &[ + LineHit { + path: "demo.rs".to_string(), + line: 3, + column: Some(1), + }, + LineHit { + path: "demo.rs".to_string(), + line: 10, + column: Some(1), + }, + ], + 1, + 0, + ); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].start_line, 2); + assert_eq!(windows[1].start_line, 9); + + let temp = tempdir().expect("tempdir"); + let source = temp.path().join("demo.rs"); + fs::write(&source, "one\ntwo\nthree\n").expect("source"); + + let snippets = materialize_snippets( + vec![SnippetWindow { + path: source.display().to_string(), + start_line: 2, + end_line: 99, + hit_lines: vec![2], + }], + Some(1), + false, + ) + .expect("materialized snippets"); + assert_eq!(snippets.len(), 1); + assert_eq!(snippets[0].start_line, 2); + assert_eq!(snippets[0].end_line, 3); + assert_eq!(snippets[0].lines.len(), 2); + assert!(render_text_snippets(&snippets).contains("2: two")); + + let missing_error = materialize_snippets( + vec![SnippetWindow { + path: temp.path().join("missing.rs").display().to_string(), + start_line: 1, + end_line: 1, + hit_lines: vec![1], + }], + None, + false, + ) + .expect_err("missing file should fail"); + assert!(matches!( + missing_error, + CliError::Runtime(message) if message.contains("failed to read") + )); + } + + #[test] + fn materialize_snippets_dedupes_repeated_definition_windows() { + let temp = tempdir().expect("tempdir"); + let source = temp.path().join("demo.rs"); + fs::write( + &source, + "pub fn demo() {\n helper();\n let value = 1;\n let other = value + 1;\n helper();\n}\n\nfn helper() {}\n", + ) + .expect("source"); + + let snippets = materialize_snippets( + vec![ + SnippetWindow { + path: source.display().to_string(), + start_line: 2, + end_line: 2, + hit_lines: vec![2], + }, + SnippetWindow { + path: source.display().to_string(), + start_line: 5, + end_line: 5, + hit_lines: vec![5], + }, + ], + None, + true, + ) + .expect("materialized snippets"); + + assert_eq!(snippets.len(), 1); + assert_eq!(snippets[0].hit_count, 2); + assert_eq!(snippets[0].hit_lines, vec![2, 5]); + assert_eq!( + snippets[0] + .definition + .as_ref() + .map(|item| item.qualified_name.as_str()), + Some("demo") + ); + assert_eq!(snippets[0].fallback_reason, None); + } + + #[test] + fn materialize_snippets_reuses_loaded_file_for_repeated_paths() { + let temp = tempdir().expect("tempdir"); + let source = temp.path().join("demo.rs"); + fs::write(&source, "one\ntwo\nthree\nfour\n").expect("source"); + let source_path = source.display().to_string(); + let mut read_count = 0; + + let snippets = materialize_snippets_with_loader( + vec![ + SnippetWindow { + path: source_path.clone(), + start_line: 1, + end_line: 1, + hit_lines: vec![1], + }, + SnippetWindow { + path: source_path.clone(), + start_line: 4, + end_line: 4, + hit_lines: vec![4], + }, + ], + None, + false, + |path| { + read_count += 1; + assert_eq!(path, source_path); + fs::read_to_string(path) + .map_err(|error| CliError::runtime(format!("failed to read {path}: {error}"))) + }, + ) + .expect("materialized snippets"); + + assert_eq!(read_count, 1); + assert_eq!(snippets.len(), 2); + assert_eq!(snippets[0].lines[0].text, "one"); + assert_eq!(snippets[1].lines[0].text, "four"); + } + + #[test] + fn snippet_sorting_prefers_non_test_paths() { + let mut snippets = vec![ + Snippet { + path: "tests/helper_test.rs".to_string(), + start_line: 1, + end_line: 2, + hit_count: 1, + hit_lines: vec![1], + lines: vec![SnippetLine { + number: 1, + text: "helper();".to_string(), + }], + definition: None, + fallback_reason: None, + }, + Snippet { + path: "src/lib.rs".to_string(), + start_line: 4, + end_line: 6, + hit_count: 1, + hit_lines: vec![5], + lines: vec![SnippetLine { + number: 5, + text: "helper();".to_string(), + }], + definition: None, + fallback_reason: None, + }, + ]; + + sort_snippets(&mut snippets); + assert_eq!(snippets[0].path, "src/lib.rs"); + } + + #[test] + fn allow_empty_turns_missing_hits_into_success() { + let exit = run(&Cli { + common: common_args(true, InputFormat::Auto), + context: 1, + default_path: None, + max_gap: 1, + limit: None, + allow_empty: true, + definitions: false, + hits: Vec::new(), + }) + .expect("empty hits should succeed"); + assert_eq!(exit, ExitCode::Success); + } + + #[test] + fn inferred_default_path_supports_pathless_rg_streams() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.rs"); + fs::write(&path, "alpha\nbeta\ngamma\n").expect("fixture"); + + let cli = Cli { + common: common_args(false, InputFormat::Auto), + context: 1, + default_path: None, + max_gap: 1, + limit: None, + allow_empty: false, + definitions: false, + hits: vec![path.display().to_string()], + }; + let loaded = infer_default_path(&cli); + assert_eq!(loaded.0, Some(path.display().to_string())); + assert!(loaded.1.is_empty()); + } +} diff --git a/crates/hitsnip/src/main.rs b/crates/hitsnip/src/main.rs new file mode 100644 index 0000000..07342bb --- /dev/null +++ b/crates/hitsnip/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `hitsnip`. + +fn main() { + std::process::exit(hitsnip::main_entry()); +} diff --git a/crates/hitsnip/tests/hitsnip_cli.rs b/crates/hitsnip/tests/hitsnip_cli.rs new file mode 100644 index 0000000..083211b --- /dev/null +++ b/crates/hitsnip/tests/hitsnip_cli.rs @@ -0,0 +1,184 @@ +//! Integration tests for the `hitsnip` command. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::str; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::{TempDir, tempdir}; + +const SAMPLE_RS: &str = "reading/sample.rs"; + +fn cargo_command() -> Command { + Command::cargo_bin("hitsnip").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn fixture(path: &str) -> PathBuf { + workspace_root().join("fixtures").join(path) +} + +fn temp_file(name: &str, contents: impl AsRef<[u8]>) -> (TempDir, PathBuf) { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join(name); + fs::write(&path, contents).expect("fixture"); + (dir, path) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +fn relative_to_workspace(path: &Path) -> String { + path.strip_prefix(workspace_root()) + .expect("path within workspace") + .display() + .to_string() +} + +#[test] +fn merges_rg_hits_into_compact_text_snippets() { + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg("--context") + .arg("1") + .arg("--max-gap") + .arg("1") + .write_stdin(std::fs::read_to_string(fixture("hits/rg-output.txt")).expect("fixture")) + .assert() + .success() + .stdout(predicate::str::contains( + "path=fixtures\\reading\\sample.rs lines=17:27 hits=3", + )) + .stdout(predicate::str::contains("hit_lines=18,22,26")) + .stdout(predicate::str::contains("18: pub fn run")) + .stdout(predicate::str::contains( + "path=fixtures\\reading\\sample.cs lines=8:10 hits=1", + )); +} + +#[test] +fn accepts_explicit_hit_arguments_and_emits_json() { + let sample = fixture(SAMPLE_RS); + let hit_a = format!("{}:18", sample.display()); + let hit_b = format!("{}:26:9", sample.display()); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("--context") + .arg("0") + .arg("--max-gap") + .arg("10") + .arg(&hit_a) + .arg(&hit_b) + .assert() + .success() + .stdout(predicate::str::contains("\"start_line\":18")) + .stdout(predicate::str::contains("\"end_line\":26")) + .stdout(predicate::str::contains("\"hit_count\":2")) + .stdout(predicate::str::contains("\"hit_lines\":[18,26]")); +} + +#[test] +fn supports_powershell_pipeline_for_hit_lines() { + let binary = assert_cmd::cargo::cargo_bin("hitsnip"); + let sample = relative_to_workspace(&fixture(SAMPLE_RS)); + let script = format!( + "{},{} | & {} --context 0 --max-gap 0 --json", + ps_quote(format!("{sample}:18")), + ps_quote(format!("{sample}:26")), + ps_quote(binary.display()) + ); + + let mut command = pwsh_command(script); + command + .current_dir(workspace_root()) + .assert() + .success() + .stdout(predicate::str::contains( + "\"path\":\"fixtures\\\\reading\\\\sample.rs\"", + )) + .stdout(predicate::str::contains("\"selected\"").not()); +} + +#[test] +fn utf8_bom_source_file_does_not_pollute_first_snippet_line() { + let (_dir, source) = temp_file("bom.rs", "\u{feff}fn main() {}\nfn helper() {}\n"); + + let mut command = cargo_command(); + command + .arg("--context") + .arg("0") + .arg(format!("{}:1", source.display())) + .assert() + .success() + .stdout(predicate::str::contains("1: fn main() {}")) + .stdout(predicate::str::contains("\u{feff}fn main").not()); +} + +#[test] +fn invalid_utf8_source_file_reports_read_error() { + let (_dir, source) = temp_file("invalid-utf8.rs", [0x66, 0x80, 0x0A]); + + let mut command = cargo_command(); + command + .arg("--context") + .arg("0") + .arg(format!("{}:1", source.display())) + .assert() + .failure() + .stderr(predicate::str::contains("failed to read")) + .stderr(predicate::str::contains("invalid-utf8.rs")); +} + +#[test] +fn help_includes_rg_and_powershell_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("rg -nH")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("--max-gap")); +} + +#[test] +fn help_puts_rg_nh_before_pathless_rg_examples() { + let mut command = cargo_command(); + let output = command + .arg("--help") + .assert() + .success() + .get_output() + .stdout + .clone(); + let help = str::from_utf8(&output).expect("help is utf8"); + let rg_nh = help.find("rg -nH").expect("rg -nH example"); + let rg_n_pathless = help + .find("rg -n \"Mode::\"") + .expect("pathless rg -n example"); + assert!( + rg_nh < rg_n_pathless, + "the safest rg -nH example should appear before pathless rg -n" + ); +} diff --git a/crates/ison/Cargo.toml b/crates/ison/Cargo.toml new file mode 100644 index 0000000..5432d65 --- /dev/null +++ b/crates/ison/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ison" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Convert between JSON and ISON text with compact CLI output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/ison/src/lib.rs b/crates/ison/src/lib.rs new file mode 100644 index 0000000..3bd9970 --- /dev/null +++ b/crates/ison/src/lib.rs @@ -0,0 +1,920 @@ +//! The `ison` command converts between JSON and a compact ISON subset. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_quick_help_error, print_structured, + read_existing_stdin_paths, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde_json::{Map, Number, Value, json}; + +const HELP: &str = "\ +Convert between JSON and ISON. + +Usage: + ison [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --from Force source syntax: auto, json, ison + --to Force target syntax: auto, json, ison + --input-format Override stdin path mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +ISON block syntax: + table.users + id:int name:str active:bool + 1 Ada true + +Examples: + ison .\\fixtures\\json-family\\ison\\users.json + ison --from ison --to json .\\fixtures\\json-family\\ison\\users.ison +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Syntax { + Auto, + Json, + Ison, +} + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + from: Syntax, + to: Syntax, + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolvedSyntax { + Json, + Ison, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockKind { + Table, + Object, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldType { + Int, + Float, + Bool, + Str, + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Field { + name: String, + kind: FieldType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Token { + value: String, + quoted: bool, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("ison {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + from: Syntax::Auto, + to: Syntax::Auto, + path: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("from") => { + cli.from = parse_syntax("--from", &parser_value_string(&mut parser, "--from")?)?; + } + Long("to") => { + cli.to = parse_syntax("--to", &parser_value_string(&mut parser, "--to")?)?; + } + ArgValue(path) => { + if cli.path.replace(PathBuf::from(path)).is_some() { + return Err(CliError::usage("ison accepts at most one explicit path")); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_syntax(flag: &str, value: &str) -> Result { + match value { + "auto" => Ok(Syntax::Auto), + "json" => Ok(Syntax::Json), + "ison" => Ok(Syntax::Ison), + other => Err(CliError::usage(format!( + "invalid {flag} value '{other}'; expected auto, json, or ison" + ))), + } +} + +fn run(cli: &Cli) -> Result { + let input = load_input(cli)?; + let source = resolve_source(cli.from, &input); + let target = resolve_target(cli.to, source); + let value = match source { + ResolvedSyntax::Json => serde_json::from_str::(input.trim()) + .map_err(|error| CliError::usage(format!("invalid JSON input: {error}")))?, + ResolvedSyntax::Ison => decode_document(&input).map_err(CliError::usage)?, + }; + + let (format, text) = match target { + ResolvedSyntax::Json => { + let text = serde_json::to_string_pretty(&value) + .map_err(|error| CliError::runtime(format!("failed to render JSON: {error}")))?; + ("json", format!("{text}\n")) + } + ResolvedSyntax::Ison => ("ison", encode_document(&value).map_err(CliError::usage)?), + }; + + match cli.common.render_mode() { + RenderMode::Text => write_stdout(&text)?, + RenderMode::Json | RenderMode::Toon => { + print_structured( + &json!({ + "format": format, + "text": text, + }), + cli.common.render_mode(), + )?; + } + } + + Ok(ExitCode::Success) +} + +fn load_input(cli: &Cli) -> Result { + if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.is_empty() { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = + read_existing_stdin_paths(&buffer, cli.common.input_format, "ison")? + { + return read_single_stdin_path(&paths, "ison"); + } + return Ok(buffer); + } + } + + let Some(path) = &cli.path else { + return Err(CliError::usage( + "provide one JSON/ISON path or pipe input into stdin", + )); + }; + + let path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(path), "ison")?, + "ison", + )?; + fs::read_to_string(&path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn read_single_stdin_path(paths: &[PathBuf], command_name: &str) -> Result { + if paths.len() != 1 { + return Err(CliError::usage(format!( + "{command_name} accepts exactly one stdin path, got {}", + paths.len() + ))); + } + fs::read_to_string(&paths[0]).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", paths[0].display())) + }) +} + +fn resolve_source(source: Syntax, input: &str) -> ResolvedSyntax { + match source { + Syntax::Json => ResolvedSyntax::Json, + Syntax::Ison => ResolvedSyntax::Ison, + Syntax::Auto => { + if serde_json::from_str::(input.trim()).is_ok() { + ResolvedSyntax::Json + } else { + ResolvedSyntax::Ison + } + } + } +} + +const fn resolve_target(target: Syntax, source: ResolvedSyntax) -> ResolvedSyntax { + match target { + Syntax::Json => ResolvedSyntax::Json, + Syntax::Ison => ResolvedSyntax::Ison, + Syntax::Auto => match source { + ResolvedSyntax::Json => ResolvedSyntax::Ison, + ResolvedSyntax::Ison => ResolvedSyntax::Json, + }, + } +} + +fn write_stdout(text: &str) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + stdout + .write_all(text.as_bytes()) + .map_err(|error| CliError::runtime(format!("failed to write stdout: {error}"))) +} + +/// Encodes one JSON document into deterministic ISON block text. +/// +/// # Errors +/// +/// Returns an error when the document cannot be represented by the v1 compact +/// subset, for example a top-level non-object or an empty array whose fields +/// cannot be inferred. +pub fn encode_document(value: &Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| "ISON v1 expects a top-level JSON object".to_string())?; + let mut output = String::with_capacity(object.len().saturating_mul(128)); + + for (name, child) in object { + if !output.is_empty() { + output.push('\n'); + } + match child { + Value::Array(items) => encode_table_block(name, items, &mut output)?, + Value::Object(map) => encode_object_block(name, map, &mut output)?, + scalar => { + let mut map = Map::new(); + map.insert("value".to_string(), scalar.clone()); + encode_object_block(name, &map, &mut output)?; + } + } + } + + output.push('\n'); + Ok(output) +} + +fn encode_table_block(name: &str, items: &[Value], output: &mut String) -> Result<(), String> { + let Some(first) = items.first() else { + return Err(format!( + "table '{name}' is empty; fields cannot be inferred" + )); + }; + let first_object = first + .as_object() + .ok_or_else(|| format!("table '{name}' expects JSON object rows"))?; + let fields = infer_fields(first_object); + output.push_str("table."); + output.push_str(name); + output.push('\n'); + push_fields(output, &fields); + output.push('\n'); + + for (index, item) in items.iter().enumerate() { + let object = item + .as_object() + .ok_or_else(|| format!("table '{name}' row {} is not an object", index + 1))?; + if index > 0 { + output.push('\n'); + } + push_row(output, object, &fields); + } + + Ok(()) +} + +fn encode_object_block( + name: &str, + object: &Map, + output: &mut String, +) -> Result<(), String> { + if object.is_empty() { + return Err(format!( + "object '{name}' is empty; fields cannot be inferred" + )); + } + let fields = infer_fields(object); + output.push_str("object."); + output.push_str(name); + output.push('\n'); + push_fields(output, &fields); + output.push('\n'); + push_row(output, object, &fields); + Ok(()) +} + +/// Encodes one JSON object into an ISONL record line. +/// +/// # Errors +/// +/// Returns an error when `record` is not a JSON object or has no fields. +pub fn encode_record(record: &Value, name: &str) -> Result { + let object = record + .as_object() + .ok_or_else(|| "ISONL v1 expects each JSONL line to be an object".to_string())?; + if object.is_empty() { + return Err("ISONL v1 cannot infer fields from an empty object".to_string()); + } + let fields = infer_fields(object); + let mut output = String::with_capacity(name.len() + fields.len().saturating_mul(24) + 16); + output.push_str("object."); + output.push_str(name); + output.push('|'); + push_fields(&mut output, &fields); + output.push('|'); + push_row(&mut output, object, &fields); + Ok(output) +} + +fn infer_fields(object: &Map) -> Vec { + object + .iter() + .map(|(name, value)| Field { + name: name.clone(), + kind: infer_type(value), + }) + .collect() +} + +fn infer_type(value: &Value) -> FieldType { + match value { + Value::Bool(_) => FieldType::Bool, + Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int, + Value::Number(_) => FieldType::Float, + Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str, + Value::Null => FieldType::Null, + } +} + +fn push_fields(output: &mut String, fields: &[Field]) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(' '); + } + output.push_str(&field.name); + output.push(':'); + output.push_str(field.kind.as_str()); + } +} + +fn push_row(output: &mut String, object: &Map, fields: &[Field]) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(' '); + } + push_value(output, object.get(&field.name).unwrap_or(&Value::Null)); + } +} + +fn push_value(output: &mut String, value: &Value) { + match value { + Value::Null => output.push_str("null"), + Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => { + let _ = write!(output, "{value}"); + } + Value::String(value) => push_string(output, value), + Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()), + } +} + +fn push_string(output: &mut String, value: &str) { + if value.is_empty() + || value + .chars() + .any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|')) + { + output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())); + } else { + output.push_str(value); + } +} + +/// Decodes ISON block text into a JSON document. +/// +/// # Errors +/// +/// Returns an error with a line number when block headers, field definitions, +/// or row values are malformed. +pub fn decode_document(input: &str) -> Result { + let lines = input + .lines() + .enumerate() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty()).then_some((index + 1, trimmed)) + }) + .collect::>(); + let mut index = 0; + let mut root = Map::new(); + + while index < lines.len() { + let (header_line, header) = lines[index]; + let (kind, name) = parse_header(header, header_line)?; + index += 1; + let Some((fields_line, fields_text)) = lines.get(index).copied() else { + return Err(format!("line {header_line}: missing field definition")); + }; + let fields = parse_fields(fields_text, fields_line)?; + index += 1; + let mut rows = Vec::new(); + + while let Some((line_number, line)) = lines.get(index).copied() { + if looks_like_header(line) { + break; + } + rows.push(parse_row(line, line_number, &fields)?); + index += 1; + } + + if rows.is_empty() { + return Err(format!("line {header_line}: block '{name}' has no rows")); + } + let value = match kind { + BlockKind::Table => Value::Array(rows.into_iter().map(Value::Object).collect()), + BlockKind::Object => Value::Object(rows.remove(0)), + }; + root.insert(name, value); + } + + Ok(Value::Object(root)) +} + +/// Decodes one ISONL record line into a JSON object. +/// +/// # Errors +/// +/// Returns an error with `line_number` when the ISONL record is malformed. +pub fn decode_record_line(line: &str, line_number: usize) -> Result { + let mut parts = line.splitn(3, '|'); + let Some(header) = parts.next() else { + return Err(format!( + "line {line_number}: ISONL record expects 3 pipe-delimited segments" + )); + }; + let Some(fields_text) = parts.next() else { + return Err(format!( + "line {line_number}: ISONL record expects 3 pipe-delimited segments" + )); + }; + let Some(row_text) = parts.next() else { + return Err(format!( + "line {line_number}: ISONL record expects 3 pipe-delimited segments" + )); + }; + parse_header(header.trim(), line_number)?; + let fields = parse_fields(fields_text.trim(), line_number)?; + let row = parse_row(row_text.trim(), line_number, &fields)?; + Ok(Value::Object(row)) +} + +fn parse_header(line: &str, line_number: usize) -> Result<(BlockKind, String), String> { + let Some((kind, name)) = line.split_once('.') else { + return Err(format!( + "line {line_number}: expected block header kind.name" + )); + }; + if name.is_empty() { + return Err(format!("line {line_number}: block name must not be empty")); + } + let kind = match kind { + "table" => BlockKind::Table, + "object" => BlockKind::Object, + other => { + return Err(format!( + "line {line_number}: unsupported block kind '{other}'; expected table or object" + )); + } + }; + Ok((kind, name.to_string())) +} + +fn looks_like_header(line: &str) -> bool { + line.starts_with("table.") || line.starts_with("object.") +} + +fn parse_fields(line: &str, line_number: usize) -> Result, String> { + let fields = line + .split_whitespace() + .map(|part| parse_field(part, line_number)) + .collect::, _>>()?; + if fields.is_empty() { + Err(format!("line {line_number}: expected at least one field")) + } else { + Ok(fields) + } +} + +fn parse_field(part: &str, line_number: usize) -> Result { + let Some((name, kind)) = part.split_once(':') else { + return Err(format!( + "line {line_number}: field '{part}' must use name:type syntax" + )); + }; + if name.is_empty() { + return Err(format!("line {line_number}: field name must not be empty")); + } + Ok(Field { + name: name.to_string(), + kind: parse_field_type(kind, line_number)?, + }) +} + +fn parse_field_type(kind: &str, line_number: usize) -> Result { + match kind { + "int" => Ok(FieldType::Int), + "float" => Ok(FieldType::Float), + "bool" => Ok(FieldType::Bool), + "str" | "string" => Ok(FieldType::Str), + "null" => Ok(FieldType::Null), + other => Err(format!( + "line {line_number}: unsupported field type '{other}'" + )), + } +} + +fn parse_row( + line: &str, + line_number: usize, + fields: &[Field], +) -> Result, String> { + let tokens = tokenize_values(line, line_number)?; + if tokens.len() != fields.len() { + return Err(format!( + "line {line_number}: expected {} values, got {}", + fields.len(), + tokens.len() + )); + } + fields + .iter() + .zip(tokens) + .map(|(field, token)| { + parse_value(&token, field.kind, line_number).map(|value| (field.name.clone(), value)) + }) + .collect() +} + +fn tokenize_values(line: &str, line_number: usize) -> Result, String> { + let mut tokens = Vec::new(); + let mut chars = line.char_indices().peekable(); + + while let Some((_, character)) = chars.peek().copied() { + if character.is_whitespace() { + chars.next(); + continue; + } + if character == '"' { + let start = chars.next().map_or(0, |(index, _)| index); + let mut escaped = false; + let mut end = None; + for (index, current) in chars.by_ref() { + if escaped { + escaped = false; + continue; + } + if current == '\\' { + escaped = true; + continue; + } + if current == '"' { + end = Some(index + current.len_utf8()); + break; + } + } + let Some(end) = end else { + return Err(format!("line {line_number}: unterminated string value")); + }; + tokens.push(Token { + value: line[start..end].to_string(), + quoted: true, + }); + continue; + } + let start = chars.next().map_or(0, |(index, _)| index); + let mut end = line.len(); + while let Some((index, current)) = chars.peek().copied() { + if current.is_whitespace() { + end = index; + break; + } + chars.next(); + } + tokens.push(Token { + value: line[start..end].to_string(), + quoted: false, + }); + } + + Ok(tokens) +} + +fn parse_value(token: &Token, kind: FieldType, line_number: usize) -> Result { + match kind { + FieldType::Int => parse_number_value(token, line_number), + FieldType::Float => token + .value + .parse::() + .map_err(|error| { + format!( + "line {line_number}: invalid float '{}': {error}", + token.value + ) + }) + .and_then(|value| { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| format!("line {line_number}: float must be finite")) + }), + FieldType::Bool => token + .value + .parse::() + .map(Value::Bool) + .map_err(|error| { + format!( + "line {line_number}: invalid bool '{}': {error}", + token.value + ) + }), + FieldType::Str => { + if token.quoted { + serde_json::from_str::(&token.value) + .map(Value::String) + .map_err(|error| { + format!( + "line {line_number}: invalid quoted string '{}': {error}", + token.value + ) + }) + } else { + Ok(Value::String(token.value.clone())) + } + } + FieldType::Null => Ok(Value::Null), + } +} + +fn parse_number_value(token: &Token, line_number: usize) -> Result { + if let Ok(value) = token.value.parse::() { + return Ok(Value::Number(Number::from(value))); + } + token + .value + .parse::() + .map(Number::from) + .map(Value::Number) + .map_err(|error| format!("line {line_number}: invalid int '{}': {error}", token.value)) +} + +impl FieldType { + const fn as_str(self) -> &'static str { + match self { + Self::Int => "int", + Self::Float => "float", + Self::Bool => "bool", + Self::Str => "str", + Self::Null => "null", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parse_cli_covers_flags_and_rejects_duplicate_paths() { + let (outcome, cli) = parse_cli_from([ + "ison", + "--from", + "json", + "--to", + "ison", + "--format", + "json", + "--input-format", + "lines", + "input.json", + ]) + .expect("valid cli"); + + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.from, Syntax::Json); + assert_eq!(cli.to, Syntax::Ison); + assert_eq!(cli.common.render_mode(), RenderMode::Json); + assert_eq!(cli.common.input_format, InputFormat::Lines); + assert_eq!(cli.path, Some(PathBuf::from("input.json"))); + + assert!(parse_cli_from(["ison", "--from", "yaml"]).is_err()); + assert!(parse_cli_from(["ison", "a.json", "b.json"]).is_err()); + } + + #[test] + fn json_document_roundtrips_tables_objects_and_scalars() { + let value = json!({ + "users": [ + {"id": 1, "name": "Ada Lovelace", "active": true}, + {"id": 2, "name": "Grace", "active": false} + ], + "meta": {"score": 1.5, "note": "pipe|quote\"", "missing": null}, + "count": 2 + }); + + let encoded = encode_document(&value).expect("encode document"); + assert!(encoded.contains("table.users")); + assert!(encoded.contains("object.meta")); + assert!(encoded.contains("object.count")); + + let decoded = decode_document(&encoded).expect("decode document"); + assert_eq!( + decoded, + json!({ + "users": value["users"].clone(), + "meta": value["meta"].clone(), + "count": {"value": 2} + }) + ); + assert_eq!(resolve_source(Syntax::Auto, &encoded), ResolvedSyntax::Ison); + assert_eq!( + resolve_target(Syntax::Auto, ResolvedSyntax::Ison), + ResolvedSyntax::Json + ); + } + + #[test] + fn record_lines_handle_quoted_values_and_type_errors() { + let record = json!({ + "id": 7, + "name": "Ada Byron", + "active": true, + "payload": {"role": "math"} + }); + + let encoded = encode_record(&record, "user").expect("encode record"); + assert!(encoded.starts_with("object.user|")); + let decoded = decode_record_line(&encoded, 4).expect("decode record"); + assert_eq!( + decoded, + json!({ + "id": 7, + "name": "Ada Byron", + "active": true, + "payload": "{\"role\":\"math\"}" + }) + ); + + let bad = decode_record_line("object.user|id:int active:bool|oops maybe", 9) + .expect_err("invalid fields should fail"); + assert!(bad.contains("line 9")); + assert_eq!( + decode_record_line("object.counter|value:int|18446744073709551615", 10) + .expect("u64 int") + .get("value"), + Some(&Value::Number(Number::from(u64::MAX))) + ); + assert!(encode_record(&json!([]), "record").is_err()); + } + + #[test] + fn decoder_reports_malformed_headers_fields_and_rows() { + assert!(decode_document("plain\nid:int\n1\n").is_err()); + assert!(decode_document("object.\nid:int\n1\n").is_err()); + assert!(decode_document("object.user\nid:weird\n1\n").is_err()); + assert!(decode_document("object.user\nid:int name:str\n1\n").is_err()); + assert!(decode_document("object.user\nid:int\nnot-an-int\n").is_err()); + assert!(decode_document("object.user\nname:str\n\"unterminated\n").is_err()); + } + + #[test] + fn run_reads_path_inputs_and_supports_wrapper_modes() { + let directory = tempdir().expect("tempdir"); + let json_path = directory.path().join("users.json"); + fs::write( + &json_path, + r#"{"users":[{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]}"#, + ) + .expect("json fixture"); + + let (_, encode_cli) = parse_cli_from([ + "ison", + "--from", + "json", + "--to", + "ison", + "--json", + json_path.to_str().expect("utf8 path"), + ]) + .expect("encode cli"); + assert_eq!(run(&encode_cli).expect("encode run"), ExitCode::Success); + + let ison_path = directory.path().join("users.ison"); + fs::write(&ison_path, "table.users\nid:int name:str\n1 Ada\n2 Grace\n") + .expect("ison fixture"); + let (_, decode_cli) = parse_cli_from([ + "ison", + "--from", + "ison", + "--to", + "json", + "--toon", + ison_path.to_str().expect("utf8 path"), + ]) + .expect("decode cli"); + assert_eq!(run(&decode_cli).expect("decode run"), ExitCode::Success); + + let (_, missing_cli) = parse_cli_from(["ison"]).expect("missing input cli"); + assert!(run(&missing_cli).is_err()); + } +} diff --git a/crates/ison/src/main.rs b/crates/ison/src/main.rs new file mode 100644 index 0000000..eebc215 --- /dev/null +++ b/crates/ison/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `ison`. + +fn main() { + std::process::exit(ison::main_entry()); +} diff --git a/crates/ison/tests/ison_cli.rs b/crates/ison/tests/ison_cli.rs new file mode 100644 index 0000000..8ade881 --- /dev/null +++ b/crates/ison/tests/ison_cli.rs @@ -0,0 +1,120 @@ +//! Integration tests for the `ison` command. +//! +//! The table block expectations track public examples on and +//! . The v1 CLI tests a deterministic JSON-family +//! subset that is stable enough for local conversion workflows. + +use std::fs; +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("ison").expect("binary") +} + +fn fixture_path(relative: &str) -> PathBuf { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .join(relative); + assert!( + fixture.exists(), + "missing fixture `{relative}` at {}", + fixture.display() + ); + fixture +} + +#[test] +fn help_mentions_ison_examples_and_interop() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Convert between JSON and ISON")) + .stdout(predicate::str::contains("ISON block syntax")) + .stdout(predicate::str::contains("--from ")) + .stdout(predicate::str::contains("--to ")); +} + +#[test] +fn json_path_encodes_to_ison_table_block() { + cargo_command() + .args(["--from", "json", "--to", "ison"]) + .arg(fixture_path("fixtures/json-family/ison/users.json")) + .assert() + .success() + .stdout(predicate::str::contains( + "table.users\nid:int name:str active:bool\n1 Ada true\n2 Bob false\n", + )); +} + +#[test] +fn ison_path_decodes_to_json() { + cargo_command() + .args(["--from", "ison", "--to", "json"]) + .arg(fixture_path("fixtures/json-family/ison/users.ison")) + .assert() + .success() + .stdout(predicate::str::contains("\"users\": [")) + .stdout(predicate::str::contains("\"name\": \"Ada\"")) + .stdout(predicate::str::contains("\"active\": false")); +} + +#[test] +fn stdin_json_auto_encodes_to_ison() { + cargo_command() + .write_stdin("{\"users\":[{\"id\":1,\"name\":\"Ada\",\"active\":true}]}") + .assert() + .success() + .stdout(predicate::str::contains("table.users")) + .stdout(predicate::str::contains("1 Ada true")); +} + +#[test] +fn format_toon_wraps_conversion_for_ai_pipelines() { + cargo_command() + .args(["--format", "toon", "--from", "json", "--to", "ison"]) + .arg(fixture_path("fixtures/json-family/ison/users.json")) + .assert() + .success() + .stdout(predicate::str::contains("format: ison")) + .stdout(predicate::str::contains("text:")) + .stdout(predicate::str::contains("table.users")); +} + +#[test] +fn invalid_ison_reports_line_number() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("broken.ison"); + fs::write(&path, "table.users\nid:int name:str\n1\n").expect("fixture"); + + cargo_command() + .args(["--from", "ison", "--to", "json"]) + .arg(&path) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("line 3")) + .stderr(predicate::str::contains("expected 2 values")); +} + +#[test] +fn json_path_with_spaces_encodes_to_ison() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("users with spaces.json"); + fs::write(&path, r#"{"users":[{"id":1,"name":"Ada","active":true}]}"#) + .expect("spaced path fixture"); + + cargo_command() + .args(["--from", "json", "--to", "ison"]) + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("table.users")) + .stdout(predicate::str::contains("1 Ada true")); +} diff --git a/crates/isonl/Cargo.toml b/crates/isonl/Cargo.toml new file mode 100644 index 0000000..c381e46 --- /dev/null +++ b/crates/isonl/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "isonl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Convert between JSONL and ISONL line-oriented records." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +ison = { path = "../ison", default-features = false } +lexopt.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/isonl/src/lib.rs b/crates/isonl/src/lib.rs new file mode 100644 index 0000000..1968dc7 --- /dev/null +++ b/crates/isonl/src/lib.rs @@ -0,0 +1,452 @@ +//! The `isonl` command converts between JSONL and ISONL records. + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, formats::ison as common_ison, + parse_color_choice, parse_format_choice, parse_input_format, print_quick_help_error, + print_structured, read_existing_stdin_paths, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde_json::{Value, json}; + +const HELP: &str = "\ +Convert between JSONL and ISONL line-oriented records. + +Usage: + isonl [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --from Force source syntax: auto, jsonl, isonl + --to Force target syntax: auto, jsonl, isonl + --record-name ISONL record name when encoding JSONL + --input-format Override stdin path mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + isonl --from jsonl --to isonl .\\records.jsonl + isonl --from isonl --to jsonl .\\fixtures\\json-family\\ison\\users.isonl +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Syntax { + Auto, + Jsonl, + Isonl, +} + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + from: Syntax, + to: Syntax, + record_name: String, + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolvedSyntax { + Jsonl, + Isonl, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("isonl {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + from: Syntax::Auto, + to: Syntax::Auto, + record_name: "record".to_string(), + path: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("from") => { + cli.from = parse_syntax("--from", &parser_value_string(&mut parser, "--from")?)?; + } + Long("to") => { + cli.to = parse_syntax("--to", &parser_value_string(&mut parser, "--to")?)?; + } + Long("record-name") => { + cli.record_name = parser_value_string(&mut parser, "--record-name")?; + if cli.record_name.is_empty() { + return Err(CliError::usage("--record-name must not be empty")); + } + } + ArgValue(path) => { + if cli.path.replace(PathBuf::from(path)).is_some() { + return Err(CliError::usage("isonl accepts at most one explicit path")); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_syntax(flag: &str, value: &str) -> Result { + match value { + "auto" => Ok(Syntax::Auto), + "jsonl" => Ok(Syntax::Jsonl), + "isonl" => Ok(Syntax::Isonl), + other => Err(CliError::usage(format!( + "invalid {flag} value '{other}'; expected auto, jsonl, or isonl" + ))), + } +} + +fn run(cli: &Cli) -> Result { + let input = load_input(cli)?; + let source = resolve_source(cli.from, &input); + let target = resolve_target(cli.to, source); + let values = match source { + ResolvedSyntax::Jsonl => parse_jsonl(&input)?, + ResolvedSyntax::Isonl => parse_isonl(&input)?, + }; + + let (format, text) = match target { + ResolvedSyntax::Jsonl => ("jsonl", render_jsonl(&values)?), + ResolvedSyntax::Isonl => ("isonl", render_isonl(&values, &cli.record_name)?), + }; + + match cli.common.render_mode() { + RenderMode::Text => write_stdout(&text)?, + RenderMode::Json | RenderMode::Toon => { + print_structured( + &json!({ + "format": format, + "records": values.len(), + "text": text, + }), + cli.common.render_mode(), + )?; + } + } + + Ok(ExitCode::Success) +} + +fn load_input(cli: &Cli) -> Result { + if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.is_empty() { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = + read_existing_stdin_paths(&buffer, cli.common.input_format, "isonl")? + { + return read_single_stdin_path(&paths, "isonl"); + } + return Ok(buffer); + } + } + + let Some(path) = &cli.path else { + return Err(CliError::usage( + "provide one JSONL/ISONL path or pipe input into stdin", + )); + }; + + let path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(path), "isonl")?, + "isonl", + )?; + fs::read_to_string(&path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn read_single_stdin_path(paths: &[PathBuf], command_name: &str) -> Result { + if paths.len() != 1 { + return Err(CliError::usage(format!( + "{command_name} accepts exactly one stdin path, got {}", + paths.len() + ))); + } + fs::read_to_string(&paths[0]).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", paths[0].display())) + }) +} + +fn resolve_source(source: Syntax, input: &str) -> ResolvedSyntax { + match source { + Syntax::Jsonl => ResolvedSyntax::Jsonl, + Syntax::Isonl => ResolvedSyntax::Isonl, + Syntax::Auto => { + if input + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .is_some_and(|line| line.contains('|')) + { + ResolvedSyntax::Isonl + } else { + ResolvedSyntax::Jsonl + } + } + } +} + +const fn resolve_target(target: Syntax, source: ResolvedSyntax) -> ResolvedSyntax { + match target { + Syntax::Jsonl => ResolvedSyntax::Jsonl, + Syntax::Isonl => ResolvedSyntax::Isonl, + Syntax::Auto => match source { + ResolvedSyntax::Jsonl => ResolvedSyntax::Isonl, + ResolvedSyntax::Isonl => ResolvedSyntax::Jsonl, + }, + } +} + +fn parse_jsonl(input: &str) -> Result, CliError> { + let mut values = Vec::new(); + for (index, line) in input.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::usage(format!("invalid JSONL at line {}: {error}", index + 1)) + })?; + values.push(value); + } + if values.is_empty() { + Err(CliError::usage("JSONL input is empty")) + } else { + Ok(values) + } +} + +fn parse_isonl(input: &str) -> Result, CliError> { + let values = common_ison::decode_records(input)?; + if values.is_empty() { + Err(CliError::usage("ISONL input is empty")) + } else { + Ok(values) + } +} + +fn render_jsonl(values: &[Value]) -> Result { + let mut output = String::new(); + for value in values { + let line = serde_json::to_string(value) + .map_err(|error| CliError::runtime(format!("failed to render JSONL: {error}")))?; + output.push_str(&line); + output.push('\n'); + } + Ok(output) +} + +fn render_isonl(values: &[Value], record_name: &str) -> Result { + let mut output = String::new(); + for value in values { + let line = ison::encode_record(value, record_name).map_err(CliError::usage)?; + output.push_str(&line); + output.push('\n'); + } + Ok(output) +} + +fn write_stdout(text: &str) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + stdout + .write_all(text.as_bytes()) + .map_err(|error| CliError::runtime(format!("failed to write stdout: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parse_cli_covers_record_name_and_render_flags() { + let (outcome, cli) = parse_cli_from([ + "isonl", + "--from", + "jsonl", + "--to", + "isonl", + "--record-name", + "event", + "--toon", + "records.jsonl", + ]) + .expect("valid cli"); + + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.from, Syntax::Jsonl); + assert_eq!(cli.to, Syntax::Isonl); + assert_eq!(cli.record_name, "event"); + assert_eq!(cli.common.render_mode(), RenderMode::Toon); + assert_eq!(cli.path, Some(PathBuf::from("records.jsonl"))); + + assert!(parse_cli_from(["isonl", "--record-name", ""]).is_err()); + assert!(parse_cli_from(["isonl", "a", "b"]).is_err()); + } + + #[test] + fn jsonl_and_isonl_roundtrip_records() { + let input = "{\"id\":1,\"name\":\"Ada\"}\n\n{\"id\":2,\"name\":\"Grace\"}\n"; + let records = parse_jsonl(input).expect("jsonl records"); + assert_eq!(records.len(), 2); + + let isonl = render_isonl(&records, "user").expect("isonl render"); + assert!(isonl.contains("object.user|")); + assert_eq!(resolve_source(Syntax::Auto, &isonl), ResolvedSyntax::Isonl); + assert_eq!( + resolve_target(Syntax::Auto, ResolvedSyntax::Isonl), + ResolvedSyntax::Jsonl + ); + + let decoded = parse_isonl(&isonl).expect("isonl parse"); + assert_eq!(decoded, records); + let jsonl = render_jsonl(&decoded).expect("jsonl render"); + assert!(jsonl.contains("\"Ada\"")); + assert!(jsonl.ends_with('\n')); + assert_eq!( + parse_isonl("object.counter|value:int|18446744073709551615\n").expect("u64 isonl")[0] + .get("value"), + Some(&Value::Number(serde_json::Number::from(u64::MAX))) + ); + } + + #[test] + fn parsers_report_empty_and_invalid_inputs() { + assert!(parse_jsonl("\n \n").is_err()); + assert!(parse_jsonl("{\"ok\":true}\nnot-json\n").is_err()); + assert!(parse_isonl("\n").is_err()); + assert!(parse_syntax("--from", "yaml").is_err()); + } + + #[test] + fn run_reads_files_and_wraps_conversions() { + let directory = tempdir().expect("tempdir"); + let jsonl_path = directory.path().join("records.jsonl"); + fs::write( + &jsonl_path, + "{\"id\":1,\"name\":\"Ada\"}\n{\"id\":2,\"name\":\"Grace\"}\n", + ) + .expect("jsonl fixture"); + + let (_, encode_cli) = parse_cli_from([ + "isonl", + "--from", + "jsonl", + "--to", + "isonl", + "--json", + "--record-name", + "user", + jsonl_path.to_str().expect("utf8 path"), + ]) + .expect("encode cli"); + assert_eq!(run(&encode_cli).expect("encode run"), ExitCode::Success); + + let isonl_path = directory.path().join("records.isonl"); + fs::write( + &isonl_path, + "object.user|id:int name:str|1 Ada\nobject.user|id:int name:str|2 Grace\n", + ) + .expect("isonl fixture"); + let (_, decode_cli) = parse_cli_from([ + "isonl", + "--from", + "isonl", + "--to", + "jsonl", + "--toon", + isonl_path.to_str().expect("utf8 path"), + ]) + .expect("decode cli"); + assert_eq!(run(&decode_cli).expect("decode run"), ExitCode::Success); + + let (_, missing_cli) = parse_cli_from(["isonl"]).expect("missing input cli"); + assert!(run(&missing_cli).is_err()); + } +} diff --git a/crates/isonl/src/main.rs b/crates/isonl/src/main.rs new file mode 100644 index 0000000..208d3c2 --- /dev/null +++ b/crates/isonl/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `isonl`. + +fn main() { + std::process::exit(isonl::main_entry()); +} diff --git a/crates/isonl/tests/isonl_cli.rs b/crates/isonl/tests/isonl_cli.rs new file mode 100644 index 0000000..4d3f112 --- /dev/null +++ b/crates/isonl/tests/isonl_cli.rs @@ -0,0 +1,108 @@ +//! Integration tests for the `isonl` command. +//! +//! The pipe-delimited record expectations track public examples on +//! and . The v1 CLI tests +//! JSONL/ISONL interop for scalar object records first. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::{Path, PathBuf}; + +fn cargo_command() -> Command { + Command::cargo_bin("isonl").expect("binary") +} + +fn fixture_path(relative: &str) -> PathBuf { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .join(relative); + assert!( + fixture.exists(), + "missing fixture `{relative}` at {}", + fixture.display() + ); + fixture +} + +#[test] +fn help_mentions_line_oriented_json_interop() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Convert between JSONL and ISONL")) + .stdout(predicate::str::contains("--from ")) + .stdout(predicate::str::contains("--to ")); +} + +#[test] +fn jsonl_stdin_encodes_to_isonl_records() { + cargo_command() + .args(["--from", "jsonl", "--to", "isonl"]) + .write_stdin("{\"id\":1,\"name\":\"Ada\",\"active\":true}\n{\"id\":2,\"name\":\"Bob\",\"active\":false}\n") + .assert() + .success() + .stdout(predicate::str::contains( + "object.record|id:int name:str active:bool|1 Ada true\n", + )) + .stdout(predicate::str::contains( + "object.record|id:int name:str active:bool|2 Bob false\n", + )); +} + +#[test] +fn isonl_path_decodes_to_jsonl() { + cargo_command() + .args(["--from", "isonl", "--to", "jsonl"]) + .arg(fixture_path("fixtures/json-family/ison/users.isonl")) + .assert() + .success() + .stdout(predicate::str::contains( + "{\"id\":1,\"name\":\"Ada\",\"active\":true}\n", + )) + .stdout(predicate::str::contains( + "{\"id\":2,\"name\":\"Bob\",\"active\":false}\n", + )); +} + +#[test] +fn format_toon_wraps_jsonl_conversion_for_ai_pipelines() { + cargo_command() + .args(["--format", "toon", "--from", "isonl", "--to", "jsonl"]) + .arg(fixture_path("fixtures/json-family/ison/users.isonl")) + .assert() + .success() + .stdout(predicate::str::contains("format: jsonl")) + .stdout(predicate::str::contains("records: 2")) + .stdout(predicate::str::contains("text:")); +} + +#[test] +fn invalid_isonl_reports_line_number() { + cargo_command() + .args(["--from", "isonl", "--to", "jsonl"]) + .write_stdin("object.record|id:int name:str|1\n") + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("line 1")) + .stderr(predicate::str::contains("expected 2 values")); +} + +#[test] +fn empty_stdin_reports_usage_diagnostic() { + cargo_command() + .args(["--from", "jsonl", "--to", "isonl"]) + .write_stdin("") + .assert() + .failure() + .code(2) + .stderr( + predicate::str::contains("input is empty").or(predicate::str::contains( + "provide one JSONL/ISONL path or pipe input into stdin", + )), + ) + .stderr(predicate::str::contains("Usage:")); +} diff --git a/crates/jsonlgrep/Cargo.toml b/crates/jsonlgrep/Cargo.toml new file mode 100644 index 0000000..e76d824 --- /dev/null +++ b/crates/jsonlgrep/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "jsonlgrep" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Filter JSONL and line-oriented streams with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/jsonlgrep/src/lib.rs b/crates/jsonlgrep/src/lib.rs new file mode 100644 index 0000000..d9f26af --- /dev/null +++ b/crates/jsonlgrep/src/lib.rs @@ -0,0 +1,1144 @@ +//! The `jsonlgrep` command filters JSONL and line-oriented streams. + +use std::borrow::Cow; +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use regex_lite::Regex; +use serde::Serialize; +use serde_json::{Map, Value}; + +/// A parsed search query for `jsonlgrep`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Query { + /// Match a JSON field by exact string equality. + FieldEquals { + /// Field name to inspect. + field: String, + /// Expected scalar string value. + value: String, + }, + /// Match a JSON field by scalar string inequality. + FieldNotEquals { + /// Field name to inspect. + field: String, + /// Rejected scalar string value. + value: String, + }, + /// Match a JSON field with a regular expression. + FieldRegex { + /// Field name to inspect. + field: String, + /// Regular expression applied to the scalar string value. + pattern: String, + }, + /// Reject a JSON field when it matches a regular expression. + FieldNotRegex { + /// Field name to inspect. + field: String, + /// Regular expression applied to the scalar string value. + pattern: String, + }, + /// Match the raw line content with a substring search. + Contains(String), +} + +#[derive(Debug, Clone)] +enum CompiledQuery { + Contains(String), + FieldEquals { field: String, value: String }, + FieldNotEquals { field: String, value: String }, + FieldRegex { field: String, regex: Regex }, + FieldNotRegex { field: String, regex: Regex }, +} + +/// CLI arguments for the `jsonlgrep` binary. +#[derive(Debug, Clone)] +pub struct Cli { + /// Shared output and stdin policy flags. + pub common: CommonArgs, + /// Comma-delimited list of fields to project. + pub pick: Vec, + /// Return only the match count. + pub count: bool, + /// Stop after this many matches. + pub limit: Option, + /// Query expression to execute. + pub query: String, + /// Optional files to read when stdin is empty. + pub paths: Vec, +} + +#[derive(Debug, Clone)] +enum Record { + Text(String), + Json { raw: Option, value: Value }, +} + +#[derive(Debug, Serialize)] +struct CountOutput { + count: usize, +} + +const HELP: &str = "\ +Filter JSONL and line-oriented streams with compact AI-friendly output. + +Usage: + jsonlgrep [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --pick Comma-delimited list of fields to project + --count Return only the match count + --limit Stop after this many matches + -h, --help Show this help text + -V, --version Show the command version + +Query forms: + field=value exact scalar match + field!=value exact scalar mismatch + field~=regex scalar regex match + field!~=regex scalar regex mismatch + +Examples: + jsonlgrep level=error .\\fixtures\\jsonl\\events.jsonl --pick ts,msg + jsonlgrep 'level~=warn|error' .\\fixtures\\jsonl\\events.jsonl --pick ts,level,msg --toon + jsonlgrep event.user.name=alice .\\nested.jsonl --pick event.user.name,status + bat --style=plain --paging=never .\\fixtures\\jsonl\\events.jsonl | jsonlgrep 'msg~=login' --count --json | ConvertFrom-Json + +PowerShell tip: + Prefer single quotes around regex queries, for example 'path~=.exe$'. + If you type field~regex, jsonlgrep will suggest the intended field~=regex form. + --count emits a summary result and exits 0 even when the match count is 0. +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses a user-facing query expression into an executable form. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the query is empty or malformed. +pub fn parse_query(input: &str) -> Result { + if let Some((field, pattern)) = input.split_once("!~=") { + if field.is_empty() || pattern.is_empty() { + return Err(CliError::usage("query must be in the form field!~=regex")); + } + return Ok(Query::FieldNotRegex { + field: field.to_string(), + pattern: pattern.to_string(), + }); + } + + if let Some((field, pattern)) = input.split_once("~=") { + if field.is_empty() || pattern.is_empty() { + return Err(CliError::usage("query must be in the form field~=regex")); + } + return Ok(Query::FieldRegex { + field: field.to_string(), + pattern: pattern.to_string(), + }); + } + + if let Some((field, value)) = input.split_once("!=") { + if field.is_empty() || value.is_empty() { + return Err(CliError::usage("query must be in the form field!=value")); + } + return Ok(Query::FieldNotEquals { + field: field.to_string(), + value: value.to_string(), + }); + } + + if let Some((field, value)) = input.split_once('=') { + if field.is_empty() || value.is_empty() { + return Err(CliError::usage("query must be in the form field=value")); + } + if value.starts_with('=') + || field.ends_with('!') + || field.ends_with('~') + || value.starts_with('~') + { + return Err(CliError::usage(format!( + "invalid query syntax '{input}'; expected field=value, field!=value, field~=regex, or field!~=regex" + ))); + } + return Ok(Query::FieldEquals { + field: field.to_string(), + value: value.to_string(), + }); + } + + if input.trim().is_empty() { + return Err(CliError::usage("query must not be empty")); + } + + if let Some(suggestion) = missing_regex_operator_suggestion(input) { + return Err(CliError::usage(format!( + "invalid query syntax '{input}'; did you mean '{suggestion}'?" + ))); + } + + Ok(Query::Contains(input.to_string())) +} + +fn missing_regex_operator_suggestion(input: &str) -> Option { + if let Some((field, pattern)) = input.split_once("!~") { + if is_field_like(field) && !pattern.is_empty() && !pattern.starts_with('=') { + return Some(format!("{field}!~={pattern}")); + } + } + + if let Some((field, pattern)) = input.split_once('~') { + if is_field_like(field) && !pattern.is_empty() && !pattern.starts_with('=') { + return Some(format!("{field}~={pattern}")); + } + } + + None +} + +fn is_field_like(value: &str) -> bool { + !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-') + }) +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("jsonlgrep {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + pick: Vec::new(), + count: false, + limit: None, + query: String::new(), + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("pick") => { + cli.pick.extend(split_csv_values(&parser_value_string( + &mut parser, + "--pick", + )?)); + } + Long("count") => cli.count = true, + Long("limit") => { + cli.limit = Some(parse_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + ArgValue(value) => { + if cli.query.is_empty() { + cli.query = os_string_to_string(value, "query")?; + } else { + cli.paths.push(PathBuf::from(value)); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if cli.query.trim().is_empty() { + return Err(CliError::usage( + "provide a query expression before any optional paths", + )); + } + if cli.limit == Some(0) { + return Err(CliError::usage("--limit must be greater than 0")); + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_string_to_string(value, flag) +} + +fn os_string_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn split_csv_values(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +/// Executes the `jsonlgrep` command with the provided arguments. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] for invalid queries and [`CliError::Runtime`] for stream or file failures. +pub fn run(cli: &Cli) -> Result { + if cli.limit == Some(0) { + return Err(CliError::usage("--limit must be greater than 0")); + } + + let query = parse_query(&cli.query)?; + let compiled_query = compile_query(&query)?; + let limit = cli.limit.unwrap_or(usize::MAX); + let mut count = 0_usize; + + if cli.count { + process_records(cli, raw_line_needed(&compiled_query, cli), |record| { + if query_matches(&compiled_query, &record) { + count += 1; + } + Ok(count < limit) + })?; + + let payload = CountOutput { count }; + match cli.common.render_mode() { + RenderMode::Json => print_json(&payload)?, + RenderMode::Toon => print_structured(&payload, RenderMode::Toon)?, + RenderMode::Text => println!("{count}"), + } + return Ok(ExitCode::Success); + } + + match cli.common.render_mode() { + RenderMode::Json => { + let mut payload = Vec::new(); + process_records(cli, raw_line_needed(&compiled_query, cli), |record| { + if query_matches(&compiled_query, &record) { + payload.push(project_json_record(&record, &cli.pick)); + count += 1; + } + Ok(count < limit) + })?; + print_json(&payload)?; + } + RenderMode::Toon => { + let mut payload = Vec::new(); + process_records(cli, raw_line_needed(&compiled_query, cli), |record| { + if query_matches(&compiled_query, &record) { + payload.push(project_json_record(&record, &cli.pick)); + count += 1; + } + Ok(count < limit) + })?; + print_structured(&payload, RenderMode::Toon)?; + } + RenderMode::Text => { + process_records(cli, raw_line_needed(&compiled_query, cli), |record| { + if query_matches(&compiled_query, &record) { + println!("{}", render_text_record(&record, &cli.pick)); + count += 1; + } + Ok(count < limit) + })?; + } + } + + Ok(map_result_count(count)) +} + +fn raw_line_needed(query: &CompiledQuery, cli: &Cli) -> bool { + matches!(query, CompiledQuery::Contains(_)) + || (cli.common.render_mode() == RenderMode::Text && cli.pick.is_empty()) +} + +fn process_records(cli: &Cli, keep_raw_json: bool, mut visitor: F) -> Result<(), CliError> +where + F: FnMut(Record) -> Result, +{ + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let stdin = io::stdin(); + let mut locked = stdin.lock(); + if process_reader( + &mut locked, + cli.common.input_format, + keep_raw_json, + &mut visitor, + )? { + return Ok(()); + } + } + + if cli.paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe input into stdin", + )); + } + + let paths = common::expand_input_patterns_preserve_order(&cli.paths, "jsonlgrep")?; + for path in &paths { + let file = File::open(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let mut reader = BufReader::new(file); + if process_reader( + &mut reader, + resolve_path_input_format(path, cli.common.input_format), + keep_raw_json, + &mut visitor, + )? { + break; + } + } + + Ok(()) +} + +fn resolve_path_input_format(path: &Path, input_format: InputFormat) -> InputFormat { + if input_format != InputFormat::Auto { + return input_format; + } + if has_jsonl_extension(path) { + InputFormat::Jsonl + } else { + InputFormat::Auto + } +} + +fn has_jsonl_extension(path: &Path) -> bool { + path.extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("jsonl") || extension.eq_ignore_ascii_case("ndjson") + }) +} + +fn process_reader( + reader: &mut R, + input_format: InputFormat, + keep_raw_json: bool, + visitor: &mut F, +) -> Result +where + R: BufRead, + F: FnMut(Record) -> Result, +{ + let mut line = String::new(); + let mut line_number = 0_usize; + let mut processed_any = false; + + loop { + line.clear(); + let read = reader + .read_line(&mut line) + .map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?; + if read == 0 { + return Ok(processed_any); + } + + line_number += 1; + let raw = line.trim_end_matches(['\r', '\n']).to_string(); + if raw.trim().is_empty() { + continue; + } + + if line_number == 1 + && input_format != InputFormat::Lines + && raw.trim_start().starts_with('[') + { + let mut remaining = String::new(); + reader + .read_to_string(&mut remaining) + .map_err(|error| CliError::runtime(format!("failed to read input: {error}")))?; + let mut payload = raw; + payload.push_str(&remaining); + return process_json_array_payload(&payload, keep_raw_json, visitor); + } + + processed_any = true; + let keep_going = visitor(parse_record(raw, input_format, line_number, keep_raw_json)?)?; + if !keep_going { + return Ok(true); + } + } +} + +fn process_json_array_payload( + payload: &str, + keep_raw_json: bool, + visitor: &mut F, +) -> Result +where + F: FnMut(Record) -> Result, +{ + let value = serde_json::from_str::(payload) + .map_err(|error| CliError::runtime(format!("invalid JSON array input: {error}")))?; + let Value::Array(items) = value else { + return Err(CliError::runtime( + "JSON array input must decode into a top-level array", + )); + }; + + let mut processed_any = false; + for item in items { + processed_any = true; + let raw = keep_raw_json + .then(|| serde_json::to_string(&item)) + .transpose() + .map_err(|error| CliError::runtime(format!("failed to render JSON item: {error}")))?; + if !visitor(Record::Json { raw, value: item })? { + return Ok(true); + } + } + + Ok(processed_any) +} + +fn parse_record( + raw: String, + input_format: InputFormat, + line_number: usize, + keep_raw_json: bool, +) -> Result { + match input_format { + InputFormat::Lines => Ok(Record::Text(raw)), + InputFormat::Jsonl => { + let value = serde_json::from_str::(&raw).map_err(|error| { + CliError::runtime(format!("invalid JSONL at line {line_number}: {error}")) + })?; + Ok(Record::Json { + raw: keep_raw_json.then_some(raw), + value, + }) + } + InputFormat::Auto => match serde_json::from_str::(&raw) { + Ok(value) => Ok(Record::Json { + raw: keep_raw_json.then_some(raw), + value, + }), + Err(_) => Ok(Record::Text(raw)), + }, + } +} + +#[cfg(test)] +fn parse_records_from_string( + content: &str, + input_format: InputFormat, +) -> Result, CliError> { + let mut records = Vec::new(); + let mut cursor = io::Cursor::new(content); + let _ = process_reader(&mut cursor, input_format, false, &mut |record| { + records.push(record); + Ok(true) + })?; + Ok(records) +} + +#[cfg(test)] +fn load_records(cli: &Cli) -> Result, CliError> { + let mut records = Vec::new(); + process_records(cli, false, |record| { + records.push(record); + Ok(true) + })?; + Ok(records) +} + +fn compile_query(query: &Query) -> Result { + match query { + Query::Contains(needle) => Ok(CompiledQuery::Contains(needle.clone())), + Query::FieldEquals { field, value } => Ok(CompiledQuery::FieldEquals { + field: field.clone(), + value: value.clone(), + }), + Query::FieldNotEquals { field, value } => Ok(CompiledQuery::FieldNotEquals { + field: field.clone(), + value: value.clone(), + }), + Query::FieldRegex { field, pattern } => Ok(CompiledQuery::FieldRegex { + field: field.clone(), + regex: Regex::new(pattern) + .map_err(|error| CliError::usage(format!("invalid regex '{pattern}': {error}")))?, + }), + Query::FieldNotRegex { field, pattern } => Ok(CompiledQuery::FieldNotRegex { + field: field.clone(), + regex: Regex::new(pattern) + .map_err(|error| CliError::usage(format!("invalid regex '{pattern}': {error}")))?, + }), + } +} + +fn query_matches(query: &CompiledQuery, record: &Record) -> bool { + match query { + CompiledQuery::Contains(needle) => record.raw_line().contains(needle), + CompiledQuery::FieldEquals { field, value } => record + .field_text(field) + .is_some_and(|candidate| candidate.as_ref() == value.as_str()), + CompiledQuery::FieldNotEquals { field, value } => record + .field_text(field) + .is_none_or(|candidate| candidate.as_ref() != value.as_str()), + CompiledQuery::FieldRegex { field, regex } => record + .field_text(field) + .is_some_and(|candidate| regex.is_match(candidate.as_ref())), + CompiledQuery::FieldNotRegex { field, regex } => record + .field_text(field) + .is_none_or(|candidate| !regex.is_match(candidate.as_ref())), + } +} + +fn render_text_record(record: &Record, pick: &[String]) -> String { + if pick.is_empty() { + return record.raw_line().to_string(); + } + + let mut rendered = String::new(); + for (index, field) in pick.iter().enumerate() { + if index > 0 { + rendered.push(' '); + } + let value = record.field_text(field).unwrap_or(Cow::Borrowed("null")); + rendered.push_str(field); + rendered.push('='); + rendered.push_str(value.as_ref()); + } + rendered +} + +fn project_json_record(record: &Record, pick: &[String]) -> Value { + if pick.is_empty() { + return match record { + Record::Text(line) => Value::Object(Map::from_iter([( + "line".to_string(), + Value::String(line.clone()), + )])), + Record::Json { value, .. } => value.clone(), + }; + } + + let mut object = Map::new(); + for field in pick { + object.insert( + field.clone(), + record.json_field(field).cloned().unwrap_or(Value::Null), + ); + } + Value::Object(object) +} + +impl Record { + fn raw_line(&self) -> Cow<'_, str> { + match self { + Self::Text(line) => Cow::Borrowed(line), + Self::Json { raw: Some(raw), .. } => Cow::Borrowed(raw), + Self::Json { raw: None, value } => { + serde_json::to_string(value).map_or_else(|_| Cow::Borrowed("null"), Cow::Owned) + } + } + } + + fn field_text(&self, field: &str) -> Option> { + match self { + Self::Text(_) => None, + Self::Json { value, .. } => json_path(value, field).and_then(json_scalar_as_text), + } + } + + fn json_field(&self, field: &str) -> Option<&Value> { + match self { + Self::Text(_) => None, + Self::Json { value, .. } => json_path(value, field), + } + } +} + +fn json_path<'value>(value: &'value Value, field: &str) -> Option<&'value Value> { + let mut current = value; + for segment in field.split('.') { + if segment.is_empty() { + return None; + } + current = current.get(segment)?; + } + Some(current) +} + +fn json_scalar_as_text(value: &Value) -> Option> { + match value { + Value::String(text) => Some(Cow::Borrowed(text.as_str())), + Value::Number(number) => Some(Cow::Owned(number.to_string())), + Value::Bool(boolean) => Some(Cow::Owned(boolean.to_string())), + Value::Null => Some(Cow::Borrowed("null")), + Value::Array(_) | Value::Object(_) => None, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + fn common_args(input_format: InputFormat, json: bool) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + fn json_record() -> Record { + Record::Json { + raw: r#"{"level":"error","msg":"failed login","ts":"2026-04-20T12:01:00Z"}"# + .to_string() + .into(), + value: json!({ + "level": "error", + "msg": "failed login", + "ts": "2026-04-20T12:01:00Z", + "attempts": 3, + "ok": false, + "note": null, + "event": { + "user": { + "name": "alice", + }, + }, + }), + } + } + + #[test] + fn parse_records_supports_auto_lines_and_jsonl_modes() { + let auto_records = + parse_records_from_string("\nplain line\n{\"level\":\"warn\"}\n", InputFormat::Auto) + .expect("auto records"); + assert!(matches!( + auto_records.as_slice(), + [Record::Text(line), Record::Json { value, .. }] + if line == "plain line" && value["level"] == "warn" + )); + + let line_records = parse_records_from_string("{\"level\":\"warn\"}\n", InputFormat::Lines) + .expect("line records"); + assert!(matches!( + line_records.as_slice(), + [Record::Text(line)] if line == "{\"level\":\"warn\"}" + )); + + let jsonl_records = parse_records_from_string( + "{\"level\":\"info\"}\n{\"level\":\"error\"}\n", + InputFormat::Jsonl, + ) + .expect("jsonl records"); + assert!(matches!( + jsonl_records.as_slice(), + [Record::Json { value: first, .. }, Record::Json { value: second, .. }] + if first["level"] == "info" && second["level"] == "error" + )); + + let json_array_records = parse_records_from_string( + "[{\"level\":\"info\"},{\"level\":\"error\"}]\n", + InputFormat::Auto, + ) + .expect("json array records"); + assert!(matches!( + json_array_records.as_slice(), + [Record::Json { value: first, .. }, Record::Json { value: second, .. }] + if first["level"] == "info" && second["level"] == "error" + )); + } + + #[test] + fn parse_records_reports_invalid_jsonl_line_numbers() { + let error = parse_records_from_string("{\"ok\":true}\nnot-json\n", InputFormat::Jsonl) + .expect_err("invalid jsonl should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("invalid JSONL at line 2") + )); + } + + #[test] + fn auto_input_uses_jsonl_rules_for_jsonl_paths() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("events.jsonl"); + fs::write(&path, "{\"ok\":true}\nnot-json\n").expect("fixture"); + let cli = Cli { + common: common_args(InputFormat::Auto, false), + pick: Vec::new(), + count: false, + limit: None, + query: "ok".into(), + paths: vec![path], + }; + assert!(matches!( + run(&cli), + Err(CliError::Runtime(message)) if message.contains("invalid JSONL at line 2") + )); + } + + #[test] + fn jsonl_field_projection_records_do_not_keep_raw_lines() { + let records = parse_records_from_string( + "{\"level\":\"warn\",\"msg\":\"cache warmup\"}\n", + InputFormat::Jsonl, + ) + .expect("jsonl records"); + + assert!(matches!( + records.as_slice(), + [Record::Json { raw: None, value }] if value["level"] == "warn" + )); + } + + #[test] + fn parse_query_rejects_malformed_operator_sequences() { + let error = parse_query("level==error").expect_err("double equals should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("invalid query syntax") + && message.contains("level==error") + && message.contains("field=value") + )); + } + + #[test] + fn query_matching_handles_all_query_types() { + let record = json_record(); + + assert!(query_matches( + &compile_query(&Query::Contains("failed".into())).expect("contains query"), + &record + )); + assert!(query_matches( + &compile_query(&Query::FieldEquals { + field: "level".into(), + value: "error".into(), + }) + .expect("field equals query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldEquals { + field: "attempts".into(), + value: "3".into(), + }) + .expect("numeric query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldRegex { + field: "msg".into(), + pattern: "failed\\s+login".into(), + }) + .expect("field regex query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldEquals { + field: "event.user.name".into(), + value: "alice".into(), + }) + .expect("nested query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldEquals { + field: "ok".into(), + value: "false".into(), + }) + .expect("boolean query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldEquals { + field: "note".into(), + value: "null".into(), + }) + .expect("null query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldNotEquals { + field: "level".into(), + value: "warn".into(), + }) + .expect("field not equals query"), + &record, + )); + assert!(query_matches( + &compile_query(&Query::FieldNotRegex { + field: "msg".into(), + pattern: "^cache".into(), + }) + .expect("field not regex query"), + &record, + )); + } + + #[test] + fn query_matching_reports_invalid_regex_patterns() { + let error = compile_query(&Query::FieldRegex { + field: "msg".into(), + pattern: "[".into(), + }) + .expect_err("invalid regex should fail"); + + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("invalid regex '['") + )); + } + + #[test] + fn render_text_record_uses_requested_fields_or_raw_line() { + let record = json_record(); + assert_eq!( + render_text_record(&record, &["ts".into(), "msg".into()]), + "ts=2026-04-20T12:01:00Z msg=failed login" + ); + assert_eq!( + render_text_record(&record, &["event.user.name".into(), "attempts".into()]), + "event.user.name=alice attempts=3" + ); + assert_eq!( + render_text_record(&record, &["missing".into()]), + "missing=null" + ); + + let text_record = Record::Text("plain line".to_string()); + assert_eq!(render_text_record(&text_record, &[]), "plain line"); + assert_eq!( + render_text_record(&text_record, &["missing".into()]), + "missing=null" + ); + } + + #[test] + fn project_json_record_projects_requested_fields_or_falls_back() { + let record = json_record(); + assert_eq!( + project_json_record( + &record, + &["level".into(), "msg".into(), "event.user.name".into()] + ), + json!({ + "level": "error", + "msg": "failed login", + "event.user.name": "alice", + }) + ); + assert_eq!( + project_json_record(&record, &["missing".into()]), + json!({ "missing": null }) + ); + + let text_record = Record::Text("plain line".to_string()); + assert_eq!( + project_json_record(&text_record, &[]), + json!({ "line": "plain line" }) + ); + assert_eq!( + project_json_record(&text_record, &["missing".into()]), + json!({ "missing": null }) + ); + } + + #[test] + fn load_records_reads_files_and_reports_input_errors() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("events.txt"); + fs::write(&path, "{\"level\":\"warn\"}\nplain line\n").expect("fixture"); + + let cli = Cli { + common: common_args(InputFormat::Auto, false), + pick: Vec::new(), + count: false, + limit: None, + query: "warn".into(), + paths: vec![path], + }; + let records = load_records(&cli).expect("records from file"); + assert!(matches!( + records.as_slice(), + [Record::Json { value, .. }, Record::Text(line)] + if value["level"] == "warn" && line == "plain line" + )); + + let jsonl_path = temp.path().join("broken.jsonl"); + fs::write(&jsonl_path, "{\"level\":\"warn\"}\nplain line\n").expect("jsonl fixture"); + let jsonl_error = load_records(&Cli { + common: common_args(InputFormat::Auto, false), + pick: Vec::new(), + count: false, + limit: None, + query: "warn".into(), + paths: vec![jsonl_path], + }) + .expect_err("broken jsonl should fail"); + assert!(matches!( + jsonl_error, + CliError::Runtime(message) if message.contains("invalid JSONL at line 2") + )); + + let missing_error = load_records(&Cli { + common: common_args(InputFormat::Auto, false), + pick: Vec::new(), + count: false, + limit: None, + query: "warn".into(), + paths: Vec::new(), + }) + .expect_err("missing input should fail"); + assert!(matches!( + missing_error, + CliError::Usage(message) + if message.contains("provide at least one path") + )); + + let file_error = load_records(&Cli { + common: common_args(InputFormat::Auto, false), + pick: Vec::new(), + count: false, + limit: None, + query: "warn".into(), + paths: vec![temp.path().join("missing.jsonl")], + }) + .expect_err("missing file should fail"); + assert!(matches!( + file_error, + CliError::Runtime(message) + if message.contains("failed to read") + )); + } + + #[test] + fn run_maps_match_counts_and_respects_limits() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("events.jsonl"); + fs::write( + &path, + "{\"level\":\"error\",\"msg\":\"first\"}\n{\"level\":\"error\",\"msg\":\"second\"}\n", + ) + .expect("fixture"); + + let success = run(&Cli { + common: common_args(InputFormat::Jsonl, false), + pick: vec!["msg".into()], + count: false, + limit: Some(1), + query: "level=error".into(), + paths: vec![path.clone()], + }) + .expect("text run"); + assert_eq!(success, ExitCode::Success); + + let no_results = run(&Cli { + common: common_args(InputFormat::Jsonl, true), + pick: Vec::new(), + count: true, + limit: None, + query: "level=warn".into(), + paths: vec![path], + }) + .expect("json count run"); + assert_eq!(no_results, ExitCode::Success); + } + + #[test] + fn parse_cli_rejects_zero_limit() { + assert!(matches!( + parse_cli_from(["jsonlgrep", "--limit", "0", "level=error"]), + Err(CliError::Usage(message)) if message.contains("--limit must be greater than 0") + )); + } +} diff --git a/crates/jsonlgrep/src/main.rs b/crates/jsonlgrep/src/main.rs new file mode 100644 index 0000000..01d65f7 --- /dev/null +++ b/crates/jsonlgrep/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `jsonlgrep`. + +fn main() { + std::process::exit(jsonlgrep::main_entry()); +} diff --git a/crates/jsonlgrep/tests/query_and_cli.rs b/crates/jsonlgrep/tests/query_and_cli.rs new file mode 100644 index 0000000..3c6fcc6 --- /dev/null +++ b/crates/jsonlgrep/tests/query_and_cli.rs @@ -0,0 +1,256 @@ +//! Integration tests for the `jsonlgrep` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use jsonlgrep::{Query, parse_query}; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("jsonlgrep").expect("binary") +} + +fn cargo_binary() -> PathBuf { + assert_cmd::cargo::cargo_bin("jsonlgrep") +} + +fn powershell_command(script: String) -> Command { + let mut command = Command::new("pwsh"); + command.args(["-NoProfile", "-Command"]).arg(script); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +fn fixture(path: &str) -> PathBuf { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path); + assert!( + fixture.exists(), + "missing fixture `{path}` at {}", + fixture.display() + ); + fixture +} + +#[test] +fn parses_field_queries() { + assert_eq!( + parse_query("level=error").expect("exact query"), + Query::FieldEquals { + field: "level".into(), + value: "error".into(), + } + ); + assert!(matches!( + parse_query("msg~=fail").expect("regex query"), + Query::FieldRegex { field, .. } if field == "msg" + )); + assert_eq!( + parse_query("panic").expect("text query"), + Query::Contains("panic".into()) + ); + assert!(matches!( + parse_query("level!=info").expect("negative exact query"), + Query::FieldNotEquals { field, value } if field == "level" && value == "info" + )); + assert!(matches!( + parse_query("msg!~=login").expect("negative regex query"), + Query::FieldNotRegex { field, .. } if field == "msg" + )); +} + +#[test] +fn filters_and_projects_jsonl_records() { + cargo_command() + .arg("level=error") + .arg(fixture("jsonl/events.jsonl")) + .arg("--pick") + .arg("ts,msg") + .assert() + .success() + .stdout(predicate::str::contains( + "ts=2026-04-20T12:01:00Z msg=failed login", + )); +} + +#[test] +fn text_and_json_projections_survive_conditional_raw_retention() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("spaced.jsonl"); + std::fs::write( + &path, + "{ \"level\" : \"error\" , \"msg\" : \"failed login\" , \"ts\" : \"2026-04-20T12:01:00Z\" }\n", + ) + .expect("fixture"); + + let mut text = cargo_command(); + text.arg("level=error") + .arg(&path) + .arg("--pick") + .arg("ts,msg") + .assert() + .success() + .stdout("ts=2026-04-20T12:01:00Z msg=failed login\n"); + + let mut json = cargo_command(); + json.arg("level=error") + .arg(&path) + .arg("--pick") + .arg("ts,msg") + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"ts\":\"2026-04-20T12:01:00Z\"")) + .stdout(predicate::str::contains("\"msg\":\"failed login\"")); +} + +#[test] +fn counts_matches_as_json() { + cargo_command() + .arg("msg~=login") + .arg(fixture("jsonl/events.jsonl")) + .arg("--count") + .arg("--json") + .assert() + .success() + .stdout("{\"count\":2}\n"); +} + +#[test] +fn supports_powershell_pipeline() { + let binary = cargo_binary(); + let input = fixture("jsonl/events.jsonl"); + let script = format!( + "[System.IO.File]::ReadLines({}) | & {} 'level=warn'", + ps_quote(input.display()), + ps_quote(binary.display()) + ); + + powershell_command(script) + .assert() + .success() + .stdout(predicate::str::contains("cache warmup")); +} + +#[test] +fn filters_nested_fields_and_negative_predicates() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("nested.jsonl"); + std::fs::write( + &path, + concat!( + "{\"event\":{\"user\":{\"name\":\"alice\"}},\"status\":500,\"ok\":false}\n", + "{\"event\":{\"user\":{\"name\":\"bob\"}},\"status\":200,\"ok\":true}\n", + "{\"event\":{\"user\":{\"name\":\"carol\"}},\"status\":404,\"ok\":false}\n" + ), + ) + .expect("fixture"); + + let mut nested = cargo_command(); + nested + .arg("event.user.name=alice") + .arg(&path) + .arg("--pick") + .arg("event.user.name,status") + .assert() + .success() + .stdout(predicate::str::contains("event.user.name=alice status=500")); + + let mut negative_exact = cargo_command(); + negative_exact + .arg("status!=200") + .arg(&path) + .arg("--pick") + .arg("event.user.name,status") + .assert() + .success() + .stdout(predicate::str::contains("alice")) + .stdout(predicate::str::contains("carol")) + .stdout(predicate::str::contains("bob").not()); + + let mut negative_regex = cargo_command(); + negative_regex + .arg("event.user.name!~=^(alice|bob)$") + .arg(&path) + .arg("--pick") + .arg("event.user.name") + .assert() + .success() + .stdout("event.user.name=carol\n"); +} + +#[test] +fn help_includes_examples_for_nested_and_powershell_usage() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("event.user.name=alice")) + .stdout(predicate::str::contains("field!=value")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("--toon")); +} + +#[test] +fn mistyped_regex_operator_reports_the_fix() { + let error = parse_query("level~warn|error").expect_err("missing equals should fail"); + assert!( + error + .to_string() + .contains("did you mean 'level~=warn|error'"), + "unexpected query error: {error}" + ); + + cargo_command() + .arg("level~warn|error") + .arg(fixture("jsonl/events.jsonl")) + .assert() + .failure() + .stderr(predicate::str::contains("did you mean 'level~=warn|error'")); +} + +#[test] +fn stops_after_limit_before_parsing_invalid_trailing_jsonl() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("limited.jsonl"); + std::fs::write( + &path, + concat!( + "{\"level\":\"error\",\"msg\":\"first\"}\n", + "{not valid json}\n", + "{\"level\":\"error\",\"msg\":\"third\"}\n" + ), + ) + .expect("fixture"); + + cargo_command() + .arg("level=error") + .arg(&path) + .arg("--input-format") + .arg("jsonl") + .arg("--count") + .arg("--limit") + .arg("1") + .assert() + .success() + .stdout("1\n"); +} + +#[test] +fn rejects_zero_limit() { + cargo_command() + .arg("level=error") + .arg(fixture("jsonl/events.jsonl")) + .arg("--limit") + .arg("0") + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("--limit must be greater than 0")); +} diff --git a/crates/jsonshape/Cargo.toml b/crates/jsonshape/Cargo.toml new file mode 100644 index 0000000..e390b02 --- /dev/null +++ b/crates/jsonshape/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "jsonshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Summarize JSON and JSONL structure into compact AI-friendly path stats." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/jsonshape/src/lib.rs b/crates/jsonshape/src/lib.rs new file mode 100644 index 0000000..876ace8 --- /dev/null +++ b/crates/jsonshape/src/lib.rs @@ -0,0 +1,1068 @@ +//! The `jsonshape` command summarizes JSON structure. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + read_existing_stdin_paths, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const MAX_SHAPE_DEPTH: usize = 512; + +const HELP: &str = "\ +Summarize JSON and JSONL structure into compact AI-friendly path stats. + +Usage: + jsonshape [OPTIONS] [PATH...] + jsonshape [OPTIONS] diff + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --examples Maximum number of examples to retain per path + --limit Optional maximum number of paths to emit + -h, --help Show this help text + -V, --version Show the command version + +Examples: + jsonshape .\\fixtures\\reading\\config.json + bat --style=plain --paging=never .\\fixtures\\jsonshape\\events.jsonl | jsonshape --input-format jsonl + fd -g config.json . .\\fixtures | jsonshape --json + jsonshape .\\fixtures\\reading\\config.json --json | ConvertFrom-Json + jsonshape diff before.json after.json --json | ConvertFrom-Json +"; + +/// CLI arguments for the `jsonshape` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Selected command mode. + command: CommandMode, + /// Maximum number of examples to retain for each path. + examples: usize, + /// Optional maximum number of paths to emit. + limit: Option, + /// Optional JSON files to inspect when stdin is empty. + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandMode { + Summary, + Diff { before: PathBuf, after: PathBuf }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +enum ValueKind { + Object, + Array, + String, + Number, + Boolean, + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ShapeSummary { + documents: usize, + paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PathSummary { + path: String, + kinds: Vec, + count: usize, + null_count: usize, + examples: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct PathAccumulator { + kinds: BTreeSet, + count: usize, + null_count: usize, + examples: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ShapeDiff { + before_documents: usize, + after_documents: usize, + added_paths: Vec, + removed_paths: Vec, + changed_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PathChange { + path: String, + before_kinds: Vec, + after_kinds: Vec, + before_count: usize, + after_count: usize, + before_null_count: usize, + after_null_count: usize, + added_examples: Vec, + removed_examples: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("jsonshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + command: CommandMode::Summary, + examples: 3, + limit: None, + paths: Vec::new(), + }; + let mut diff_paths = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("examples") => { + cli.examples = parse_usize_flag( + "--examples", + &parser_value_string(&mut parser, "--examples")?, + )?; + } + Long("limit") => { + cli.limit = Some(parse_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + ArgValue(path) => { + if matches!(cli.command, CommandMode::Summary) + && cli.paths.is_empty() + && diff_paths.is_empty() + && path == "diff" + { + cli.command = CommandMode::Diff { + before: PathBuf::new(), + after: PathBuf::new(), + }; + } else if matches!(cli.command, CommandMode::Diff { .. }) { + diff_paths.push(PathBuf::from(path)); + } else { + cli.paths.push(PathBuf::from(path)); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if matches!(cli.command, CommandMode::Diff { .. }) { + if diff_paths.len() != 2 { + return Err(CliError::usage("diff expects exactly two JSON inputs")); + } + cli.command = CommandMode::Diff { + before: diff_paths[0].clone(), + after: diff_paths[1].clone(), + }; + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + if cli.limit == Some(0) { + return Err(CliError::usage("--limit must be greater than 0")); + } + + match &cli.command { + CommandMode::Summary => { + let documents = load_documents(cli)?; + let summary = summarize_documents(&documents, cli.examples, cli.limit); + let output_summary = limit_shape_summary(summary, cli.limit); + + match cli.common.render_mode() { + RenderMode::Json => print_json(&output_summary)?, + RenderMode::Toon => print_structured(&output_summary, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_summary(&output_summary)), + } + + Ok(map_result_count(output_summary.paths.len())) + } + CommandMode::Diff { before, after } => { + let before_path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(before), "jsonshape")?, + "jsonshape diff before", + )?; + let after_path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(after), "jsonshape")?, + "jsonshape diff after", + )?; + let before_documents = load_documents_from_paths( + std::slice::from_ref(&before_path), + cli.common.input_format, + )?; + let after_documents = load_documents_from_paths( + std::slice::from_ref(&after_path), + cli.common.input_format, + )?; + let before_summary = summarize_documents(&before_documents, cli.examples, None); + let after_summary = summarize_documents(&after_documents, cli.examples, None); + let diff = diff_summaries(&before_summary, &after_summary); + + match cli.common.render_mode() { + RenderMode::Json => print_json(&diff)?, + RenderMode::Toon => print_structured(&diff, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_diff(&diff)), + } + + Ok(map_result_count( + diff.added_paths.len() + diff.removed_paths.len() + diff.changed_paths.len(), + )) + } + } +} + +fn load_documents(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.trim().is_empty() { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = + read_existing_stdin_paths(&buffer, cli.common.input_format, "jsonshape")? + { + return load_documents_from_paths(&paths, InputFormat::Auto); + } + return parse_documents_from_string(&buffer, cli.common.input_format); + } + } + + if cli.paths.is_empty() { + return Err(CliError::usage( + "provide at least one JSON path or pipe JSON into stdin", + )); + } + + load_documents_from_paths( + &common::expand_input_patterns(&cli.paths, "jsonshape")?, + cli.common.input_format, + ) +} + +fn load_documents_from_paths( + paths: &[PathBuf], + input_format: InputFormat, +) -> Result, CliError> { + let mut documents = Vec::new(); + for path in paths { + let content = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + documents.extend(parse_documents_from_string( + &content, + resolve_path_input_format(path, input_format), + )?); + } + + Ok(documents) +} + +fn resolve_path_input_format(path: &Path, input_format: InputFormat) -> InputFormat { + if input_format != InputFormat::Auto { + return input_format; + } + + if has_jsonl_extension(path) { + InputFormat::Jsonl + } else { + InputFormat::Auto + } +} + +fn has_jsonl_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("jsonl") || extension.eq_ignore_ascii_case("ndjson") + }) +} + +fn parse_documents_from_string( + content: &str, + input_format: InputFormat, +) -> Result, CliError> { + match input_format { + InputFormat::Auto | InputFormat::Lines => parse_auto_documents(content), + InputFormat::Jsonl => parse_jsonl_documents(content), + } +} + +fn parse_auto_documents(content: &str) -> Result, CliError> { + let trimmed = content.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + serde_json::from_str::(trimmed).map_or_else( + |_| parse_jsonl_documents(content), + |document| Ok(vec![document]), + ) +} + +fn parse_jsonl_documents(content: &str) -> Result, CliError> { + let mut documents = Vec::new(); + + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let document = serde_json::from_str::(trimmed).map_err(|error| { + CliError::runtime(format!("invalid JSONL at line {}: {error}", index + 1)) + })?; + documents.push(document); + } + + Ok(documents) +} + +fn summarize_documents( + documents: &[Value], + example_limit: usize, + _limit: Option, +) -> ShapeSummary { + let mut paths = BTreeMap::::new(); + + for document in documents { + visit_value(document, "$", &mut paths, example_limit); + } + + let summaries = paths + .into_iter() + .map(|(path, accumulator)| PathSummary { + path, + kinds: accumulator.kinds.into_iter().collect(), + count: accumulator.count, + null_count: accumulator.null_count, + examples: accumulator.examples, + }) + .collect::>(); + + ShapeSummary { + documents: documents.len(), + paths: summaries, + } +} + +fn limit_shape_summary(mut summary: ShapeSummary, limit: Option) -> ShapeSummary { + if let Some(max_paths) = limit { + summary.paths.truncate(max_paths); + } + summary +} + +fn visit_value( + value: &Value, + path: &str, + paths: &mut BTreeMap, + example_limit: usize, +) { + let mut stack = vec![(value, path.to_string(), 0_usize)]; + while let Some((value, path, depth)) = stack.pop() { + record_path(paths, &path, value, example_limit); + if depth >= MAX_SHAPE_DEPTH { + continue; + } + + match value { + Value::Object(object) => { + for (key, child) in object.iter().rev() { + stack.push((child, push_object_path(&path, key), depth + 1)); + } + } + Value::Array(items) => { + let item_path = format!("{path}[]"); + for item in items.iter().rev() { + stack.push((item, item_path.clone(), depth + 1)); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + } +} + +fn record_path( + paths: &mut BTreeMap, + path: &str, + value: &Value, + example_limit: usize, +) { + let accumulator = paths.entry(path.to_string()).or_default(); + accumulator.kinds.insert(kind_for_value(value)); + accumulator.count += 1; + if matches!(value, Value::Null) { + accumulator.null_count += 1; + } + + if example_limit == 0 { + return; + } + + let example = render_example(value); + if accumulator.examples.len() < example_limit && !accumulator.examples.contains(&example) { + accumulator.examples.push(example); + } +} + +const fn kind_for_value(value: &Value) -> ValueKind { + match value { + Value::Object(_) => ValueKind::Object, + Value::Array(_) => ValueKind::Array, + Value::String(_) => ValueKind::String, + Value::Number(_) => ValueKind::Number, + Value::Bool(_) => ValueKind::Boolean, + Value::Null => ValueKind::Null, + } +} + +fn push_object_path(base: &str, key: &str) -> String { + if is_identifier_like(key) { + format!("{base}.{key}") + } else { + let encoded_key = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into()); + format!("{base}[{encoded_key}]") + } +} + +fn is_identifier_like(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + + chars.all(|value| value == '_' || value.is_ascii_alphanumeric()) +} + +fn render_example(value: &Value) -> String { + match value { + Value::Object(object) => { + if object.is_empty() { + "{}".to_string() + } else { + let mut rendered = String::from("keys="); + for (index, key) in object.keys().take(4).enumerate() { + if index > 0 { + rendered.push(','); + } + rendered.push_str(key); + } + if object.len() > 4 { + rendered.push_str(",..."); + } + rendered + } + } + Value::Array(items) => format!("len={}", items.len()), + Value::String(text) => truncate_example(&json_string_literal(text)), + Value::Number(number) => number.to_string(), + Value::Bool(boolean) => boolean.to_string(), + Value::Null => "null".to_string(), + } +} + +fn json_string_literal(text: &str) -> String { + serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string()) +} + +fn truncate_example(value: &str) -> String { + const MAX_CHARS: usize = 40; + + let char_count = value.chars().count(); + if char_count <= MAX_CHARS { + return value.to_string(); + } + + let end_index = value + .char_indices() + .nth(MAX_CHARS) + .map_or(value.len(), |(index, _)| index); + format!("{}...", &value[..end_index]) +} + +fn render_summary(summary: &ShapeSummary) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "documents={} paths={}", + summary.documents, + summary.paths.len() + ) + .expect("writing to a String cannot fail"); + + for path in &summary.paths { + writeln!( + rendered, + "path={} kinds={} count={} nulls={} examples={}", + path.path, + render_kinds(&path.kinds), + path.count, + path.null_count, + render_examples(&path.examples) + ) + .expect("writing to a String cannot fail"); + } + + rendered +} + +fn render_kinds(kinds: &[ValueKind]) -> String { + let mut rendered = String::new(); + + for (index, kind) in kinds.iter().enumerate() { + if index > 0 { + rendered.push('|'); + } + rendered.push_str(kind.as_str()); + } + + rendered +} + +fn render_examples(examples: &[String]) -> String { + if examples.is_empty() { + "-".to_string() + } else { + examples.join("|") + } +} + +fn diff_summaries(before: &ShapeSummary, after: &ShapeSummary) -> ShapeDiff { + let before_paths = before + .paths + .iter() + .map(|path| (path.path.clone(), path)) + .collect::>(); + let after_paths = after + .paths + .iter() + .map(|path| (path.path.clone(), path)) + .collect::>(); + + let mut added_paths = Vec::new(); + let mut removed_paths = Vec::new(); + let mut changed_paths = Vec::new(); + + let path_names = before_paths + .keys() + .chain(after_paths.keys()) + .collect::>(); + + for path in path_names { + match (before_paths.get(path), after_paths.get(path)) { + (None, Some(after_path)) => added_paths.push((*after_path).clone()), + (Some(before_path), None) => removed_paths.push((*before_path).clone()), + (Some(before_path), Some(after_path)) + if path_summary_changed(before_path, after_path) => + { + changed_paths.push(PathChange { + path: path.clone(), + before_kinds: before_path.kinds.clone(), + after_kinds: after_path.kinds.clone(), + before_count: before_path.count, + after_count: after_path.count, + before_null_count: before_path.null_count, + after_null_count: after_path.null_count, + added_examples: difference(&after_path.examples, &before_path.examples), + removed_examples: difference(&before_path.examples, &after_path.examples), + }); + } + _ => {} + } + } + + ShapeDiff { + before_documents: before.documents, + after_documents: after.documents, + added_paths, + removed_paths, + changed_paths, + } +} + +fn difference(left: &[String], right: &[String]) -> Vec { + let right_set = right.iter().collect::>(); + left.iter() + .filter(|item| !right_set.contains(item)) + .cloned() + .collect() +} + +fn path_summary_changed(before: &PathSummary, after: &PathSummary) -> bool { + before.kinds != after.kinds + || before.count != after.count + || before.null_count != after.null_count + || (examples_should_affect_diff(before, after) && before.examples != after.examples) +} + +fn examples_should_affect_diff(before: &PathSummary, after: &PathSummary) -> bool { + before + .kinds + .iter() + .chain(after.kinds.iter()) + .all(|kind| !matches!(kind, ValueKind::Object | ValueKind::Array)) +} + +impl ValueKind { + const fn as_str(self) -> &'static str { + match self { + Self::Object => "object", + Self::Array => "array", + Self::String => "string", + Self::Number => "number", + Self::Boolean => "boolean", + Self::Null => "null", + } + } +} + +fn render_diff(diff: &ShapeDiff) -> String { + let mut rendered = String::new(); + + for path in &diff.added_paths { + writeln!( + rendered, + "path={} change=added kinds={} count={} nulls={} examples={}", + path.path, + render_kinds(&path.kinds), + path.count, + path.null_count, + render_examples(&path.examples) + ) + .expect("writing to a String cannot fail"); + } + for path in &diff.removed_paths { + writeln!( + rendered, + "path={} change=removed kinds={} count={} nulls={} examples={}", + path.path, + render_kinds(&path.kinds), + path.count, + path.null_count, + render_examples(&path.examples) + ) + .expect("writing to a String cannot fail"); + } + for path in &diff.changed_paths { + writeln!( + rendered, + "path={} change=changed before_kinds={} after_kinds={} before_count={} after_count={} before_nulls={} after_nulls={} added_examples={} removed_examples={}", + path.path, + render_kinds(&path.before_kinds), + render_kinds(&path.after_kinds), + path.before_count, + path.after_count, + path.before_null_count, + path.after_null_count, + render_examples(&path.added_examples), + render_examples(&path.removed_examples) + ) + .expect("writing to a String cannot fail"); + } + + rendered +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::{ColorChoice, InputFormat}; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_documents_supports_auto_json_and_jsonl() { + let auto = parse_documents_from_string( + "{\n \"user\": {\"id\": 7},\n \"tags\": [\"alpha\", \"beta\"]\n}\n", + InputFormat::Auto, + ) + .expect("single JSON document"); + assert_eq!( + auto, + vec![json!({"user": {"id": 7}, "tags": ["alpha", "beta"]})] + ); + + let jsonl = parse_documents_from_string( + "{\"event\":\"login\",\"ok\":true}\n{\"event\":\"logout\",\"ok\":false}\n", + InputFormat::Jsonl, + ) + .expect("jsonl documents"); + assert_eq!( + jsonl, + vec![ + json!({"event": "login", "ok": true}), + json!({"event": "logout", "ok": false}), + ] + ); + + let error = parse_documents_from_string("alpha\nbeta\n", InputFormat::Lines) + .expect_err("invalid line mode content should fail as JSON"); + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("invalid JSONL at line 1") + )); + } + + #[test] + fn summarize_documents_tracks_paths_kinds_and_examples() { + let summary = summarize_documents( + &[json!({ + "user": { + "id": 7, + "name": "Ada", + "note": null, + }, + "tags": ["alpha", "beta"], + "items": [ + {"sku": "A1", "qty": 2}, + {"sku": "B2", "qty": 1}, + ], + })], + 2, + None, + ); + + assert_eq!(summary.documents, 1); + assert_eq!( + summary.paths.iter().find(|path| path.path == "$.user.id"), + Some(&PathSummary { + path: "$.user.id".to_string(), + kinds: vec![ValueKind::Number], + count: 1, + null_count: 0, + examples: vec!["7".to_string()], + }) + ); + assert_eq!( + summary.paths.iter().find(|path| path.path == "$.tags[]"), + Some(&PathSummary { + path: "$.tags[]".to_string(), + kinds: vec![ValueKind::String], + count: 2, + null_count: 0, + examples: vec!["\"alpha\"".to_string(), "\"beta\"".to_string()], + }) + ); + assert_eq!( + summary.paths.iter().find(|path| path.path == "$.items"), + Some(&PathSummary { + path: "$.items".to_string(), + kinds: vec![ValueKind::Array], + count: 1, + null_count: 0, + examples: vec!["len=2".to_string()], + }) + ); + assert_eq!( + summary.paths.iter().find(|path| path.path == "$.user.note"), + Some(&PathSummary { + path: "$.user.note".to_string(), + kinds: vec![ValueKind::Null], + count: 1, + null_count: 1, + examples: vec!["null".to_string()], + }) + ); + } + + #[test] + fn summarize_documents_bounds_deep_shape_traversal() { + let mut value = json!(true); + for _ in 0..(MAX_SHAPE_DEPTH + 16) { + value = json!({ "child": value }); + } + + let summary = summarize_documents(&[value], 0, None); + + assert_eq!(summary.documents, 1); + assert_eq!(summary.paths.len(), MAX_SHAPE_DEPTH + 1); + } + + #[test] + fn render_summary_is_compact_and_stable() { + let rendered = render_summary(&ShapeSummary { + documents: 1, + paths: vec![PathSummary { + path: "$.user.name".to_string(), + kinds: vec![ValueKind::String], + count: 2, + null_count: 0, + examples: vec!["\"Ada\"".to_string(), "\"Bob\"".to_string()], + }], + }); + + assert_eq!( + rendered, + "documents=1 paths=1\npath=$.user.name kinds=string count=2 nulls=0 examples=\"Ada\"|\"Bob\"\n" + ); + } + + #[test] + fn run_reads_files_and_reports_json_output_success() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("events.json"); + fs::write( + &path, + "{\n \"event\": \"login\",\n \"ok\": true,\n \"count\": 2\n}\n", + ) + .expect("fixture"); + + let text_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + command: CommandMode::Summary, + examples: 2, + limit: None, + paths: vec![path.clone()], + }) + .expect("text run"); + assert_eq!(text_exit, ExitCode::Success); + + let json_exit = run(&Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + examples: 2, + limit: Some(4), + paths: vec![path], + }) + .expect("json run"); + assert_eq!(json_exit, ExitCode::Success); + } + + #[test] + fn stdin_path_streams_are_loaded_as_files() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("first.json"); + let second = temp.path().join("second.json"); + fs::write(&first, "{\"user\":{\"id\":1}}\n").expect("first"); + fs::write(&second, "{\"user\":{\"id\":2}}\n").expect("second"); + + let paths = read_existing_stdin_paths( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Auto, + "jsonshape", + ) + .expect("paths") + .expect("detected"); + let documents = load_documents_from_paths(&paths, InputFormat::Auto).expect("documents"); + let summary = summarize_documents(&documents, 2, None); + + assert_eq!(summary.documents, 2); + assert!(summary.paths.iter().any(|path| path.path == "$.user.id")); + } + + #[test] + fn helper_rendering_covers_special_keys_and_truncation() { + assert_eq!(push_object_path("$", "user_name"), "$.user_name"); + assert_eq!(push_object_path("$", "full name"), "$[\"full name\"]"); + assert!(truncate_example(&"x".repeat(50)).ends_with("...")); + assert_eq!(render_examples(&[]), "-"); + } + + #[test] + fn parse_cli_supports_diff_subcommand() { + let (_, cli) = parse_cli_from([ + "jsonshape", + "--json", + "diff", + "--examples", + "1", + "before.json", + "after.json", + ]) + .expect("cli"); + + assert!(cli.common.json); + assert_eq!(cli.examples, 1); + assert_eq!( + cli.command, + CommandMode::Diff { + before: PathBuf::from("before.json"), + after: PathBuf::from("after.json"), + } + ); + } + + #[test] + fn diff_summary_reports_added_removed_and_changed_paths() { + let before = summarize_documents( + &[json!({ + "user": { + "name": "Ada", + "active": true + } + })], + 2, + None, + ); + let after = summarize_documents( + &[json!({ + "user": { + "name": null, + "city": "Paris" + } + })], + 2, + None, + ); + + let diff = diff_summaries(&before, &after); + + assert_eq!(diff.added_paths.len(), 1); + assert_eq!(diff.added_paths[0].path, "$.user.city"); + assert_eq!(diff.removed_paths.len(), 1); + assert_eq!(diff.removed_paths[0].path, "$.user.active"); + assert_eq!(diff.changed_paths.len(), 1); + assert_eq!(diff.changed_paths[0].path, "$.user.name"); + assert_eq!(diff.changed_paths[0].before_kinds, vec![ValueKind::String]); + assert_eq!(diff.changed_paths[0].after_kinds, vec![ValueKind::Null]); + } + + #[test] + fn diff_uses_full_shape_when_render_limit_is_set() { + let before = summarize_documents( + &[json!({ + "visible": 1, + "hidden": "stable" + })], + 3, + Some(1), + ); + let after = summarize_documents( + &[json!({ + "visible": 1, + "hidden": "changed" + })], + 3, + Some(1), + ); + + let diff = diff_summaries(&before, &after); + + assert_eq!( + diff.changed_paths + .iter() + .map(|change| change.path.as_str()) + .collect::>(), + vec!["$.hidden"] + ); + } +} diff --git a/crates/jsonshape/src/main.rs b/crates/jsonshape/src/main.rs new file mode 100644 index 0000000..cbbc77f --- /dev/null +++ b/crates/jsonshape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `jsonshape`. + +fn main() { + std::process::exit(jsonshape::main_entry()); +} diff --git a/crates/jsonshape/tests/jsonshape_cli.rs b/crates/jsonshape/tests/jsonshape_cli.rs new file mode 100644 index 0000000..a49c637 --- /dev/null +++ b/crates/jsonshape/tests/jsonshape_cli.rs @@ -0,0 +1,126 @@ +//! Integration tests for the `jsonshape` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("jsonshape").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path); + assert!( + fixture.exists(), + "missing fixture `{path}` at {}", + fixture.display() + ); + fixture +} + +fn json_stdout(output: &[u8]) -> Value { + serde_json::from_slice(output).unwrap_or_else(|error| { + panic!( + "stdout should be valid JSON: {error}\n{}", + String::from_utf8_lossy(output) + ) + }) +} + +#[test] +fn accepts_stdin_path_streams_in_auto_mode() { + let output = cargo_command() + .arg("--json") + .write_stdin(format!( + "{}\n{}\n", + fixture("reading/config.json").display(), + fixture("toon/config.json").display() + )) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let payload = json_stdout(&output); + assert_eq!( + payload["documents"], + 2, + "stdout={}", + String::from_utf8_lossy(&output) + ); + let paths = payload["paths"].as_array().unwrap_or_else(|| { + panic!( + "paths should be an array: {}", + String::from_utf8_lossy(&output) + ) + }); + assert!( + paths.iter().any(|entry| entry["path"] == "$.app.name"), + "stdout={}", + String::from_utf8_lossy(&output) + ); + assert!( + paths.iter().any(|entry| entry["path"] == "$.context.task"), + "stdout={}", + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn help_mentions_repo_safe_examples() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "bat --style=plain --paging=never .\\fixtures\\jsonshape\\events.jsonl", + )) + .stdout(predicate::str::contains( + "fd -g config.json . .\\fixtures | jsonshape --json", + )); +} + +#[test] +fn diff_compares_paths_hidden_by_display_limit() { + let temp = tempdir().expect("tempdir"); + let before = temp.path().join("before.json"); + let after = temp.path().join("after.json"); + std::fs::write(&before, r#"{"a":1,"z":1}"#).expect("before fixture"); + std::fs::write(&after, r#"{"a":1,"z":"changed"}"#).expect("after fixture"); + + cargo_command() + .arg("diff") + .arg(&before) + .arg(&after) + .arg("--limit") + .arg("1") + .assert() + .success() + .stdout(predicate::str::contains("change=changed")) + .stdout(predicate::str::contains("$.z")); +} + +#[test] +fn auto_mode_rejects_pretty_json_in_explicit_jsonl_and_ndjson_paths() { + let temp = tempdir().expect("tempdir"); + let jsonl = temp.path().join("pretty.jsonl"); + let ndjson = temp.path().join("pretty.ndjson"); + std::fs::write(&jsonl, "{\n \"ok\": true\n}\n").expect("jsonl fixture"); + std::fs::write(&ndjson, "{\n \"ok\": true\n}\n").expect("ndjson fixture"); + + for path in [&jsonl, &ndjson] { + cargo_command() + .arg(path) + .assert() + .failure() + .stderr(predicate::str::contains("invalid JSONL at line 1")); + } +} diff --git a/crates/llvmtools/Cargo.toml b/crates/llvmtools/Cargo.toml new file mode 100644 index 0000000..a9e5db6 --- /dev/null +++ b/crates/llvmtools/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "llvmtools" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Wrap LLVM object tools with Mercury-friendly PE/COFF diagnostics." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "llvmobjdump" +path = "src/main_objdump.rs" + +[[bin]] +name = "llvmreadobj" +path = "src/main_readobj.rs" + +[[bin]] +name = "llvmnm" +path = "src/main_nm.rs" + +[dependencies] +common = { path = "../common", default-features = false } +goblin.workspace = true +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/llvmtools/src/backend.rs b/crates/llvmtools/src/backend.rs new file mode 100644 index 0000000..2fc06df --- /dev/null +++ b/crates/llvmtools/src/backend.rs @@ -0,0 +1,316 @@ +//! LLVM backend discovery and process execution. + +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use common::CliError; +use serde::Serialize; + +use crate::cli::ToolKind; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +/// Describes the LLVM backend executable selected for a run. +pub struct BackendInfo { + /// Fully resolved backend executable path. + pub tool_path: PathBuf, + /// First line of `--version`, when the backend reports one. + pub version: Option, + /// Arguments passed to the backend, excluding the executable path. + pub argv: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +/// Raw LLVM text preserved when a parser does not map a block. +pub struct RawBlock { + /// Input path associated with this block, when known. + pub path: Option, + /// Short parser label for the block. + pub label: String, + /// Raw text content. + pub text: String, + /// Whether the raw text was truncated before being exposed. + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Captured output from an LLVM backend invocation. +pub struct BackendOutput { + /// Selected backend metadata. + pub info: BackendInfo, + /// Bounded standard output. + pub stdout: String, + /// Bounded standard error. + pub stderr: String, + /// Backend process exit code. + pub exit_code: i32, + /// Whether standard output was truncated. + pub stdout_truncated: bool, + /// Whether standard error was truncated. + pub stderr_truncated: bool, +} + +pub(crate) fn run_backend( + tool: ToolKind, + explicit_bin_dir: Option<&Path>, + args: &[String], + raw_output_limit: usize, +) -> Result { + run_llvm_binary( + tool.llvm_binary_name(), + explicit_bin_dir, + args, + raw_output_limit, + ) +} + +/// Finds and runs an LLVM backend binary using Mercury's shared discovery order. +/// +/// Discovery checks `--llvm-bin-dir`, `MERCURY_LLVM_BIN`, `PATH`, then the +/// current Rust sysroot's `lib\rustlib\\bin` directory. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when the backend cannot be found or executed. +pub fn run_llvm_binary( + binary_name: &str, + explicit_bin_dir: Option<&Path>, + args: &[String], + raw_output_limit: usize, +) -> Result { + let tool_path = find_tool_by_name(binary_name, explicit_bin_dir)?; + let version = read_version(&tool_path); + let output = Command::new(&tool_path) + .args(args) + .output() + .map_err(|error| { + CliError::runtime(format!( + "failed to run {} at {}: {error}", + binary_name, + tool_path.display() + )) + })?; + let (stdout, stdout_truncated) = + bounded_string(&String::from_utf8_lossy(&output.stdout), raw_output_limit); + let (stderr, stderr_truncated) = bounded_string( + &String::from_utf8_lossy(&output.stderr), + raw_output_limit.min(16_384), + ); + Ok(BackendOutput { + info: BackendInfo { + tool_path, + version, + argv: args.to_vec(), + }, + stdout, + stderr, + exit_code: output.status.code().unwrap_or(3), + stdout_truncated, + stderr_truncated, + }) +} + +fn find_tool_by_name(name: &str, explicit_bin_dir: Option<&Path>) -> Result { + let mut searched = Vec::new(); + if let Some(dir) = explicit_bin_dir { + return candidate_in_dir(dir, name, &mut searched) + .ok_or_else(|| missing_backend_error(name, &searched)); + } + if let Some(dir) = env::var_os("MERCURY_LLVM_BIN").map(PathBuf::from) { + if let Some(path) = candidate_in_dir(&dir, name, &mut searched) { + return Ok(path); + } + } + if let Some(paths) = env::var_os("PATH") { + for dir in env::split_paths(&paths) { + if let Some(path) = candidate_in_dir(&dir, name, &mut searched) { + return Ok(path); + } + } + } + if let Some(dir) = rustup_llvm_bin_dir() { + if let Some(path) = candidate_in_dir(&dir, name, &mut searched) { + return Ok(path); + } + } + Err(missing_backend_error(name, &searched)) +} + +fn candidate_in_dir(dir: &Path, name: &str, searched: &mut Vec) -> Option { + let candidate = dir.join(executable_name(name)); + searched.push(candidate.clone()); + candidate.is_file().then_some(candidate) +} + +fn executable_name(name: &str) -> OsString { + if cfg!(windows) { + OsString::from(format!("{name}.exe")) + } else { + OsString::from(name) + } +} + +fn rustup_llvm_bin_dir() -> Option { + let output = Command::new("rustc") + .args(["--print", "sysroot"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let sysroot = String::from_utf8(output.stdout).ok()?; + Some( + Path::new(sysroot.trim()) + .join("lib") + .join("rustlib") + .join(rustc_host_triple()?) + .join("bin"), + ) +} + +fn rustc_host_triple() -> Option { + let output = Command::new("rustc").arg("-vV").output().ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8(output.stdout).ok()?; + parse_rustc_host(&version).map(ToOwned::to_owned) +} + +fn parse_rustc_host(version: &str) -> Option<&str> { + version + .lines() + .find_map(|line| line.strip_prefix("host:").map(str::trim)) + .filter(|host| !host.is_empty()) +} + +fn read_version(tool_path: &Path) -> Option { + let output = Command::new(tool_path).arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .and_then(|text| text.lines().next().map(str::trim).map(ToOwned::to_owned)) + .filter(|line| !line.is_empty()) +} + +fn bounded_string(text: &str, limit: usize) -> (String, bool) { + if text.len() <= limit { + return (text.to_owned(), false); + } + let mut end = limit; + while !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_owned(), true) +} + +fn missing_backend_error(binary_name: &str, searched: &[PathBuf]) -> CliError { + let searched_text = searched + .iter() + .take(12) + .map(|path| path.display().to_string()) + .collect::>() + .join("; "); + CliError::runtime(format!( + "could not find {binary_name}. Discovery order: --llvm-bin-dir, MERCURY_LLVM_BIN, PATH, then rustc --print sysroot lib\\rustlib\\\\bin. Searched: {searched_text}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_string_truncates_on_char_boundary() { + let (text, truncated) = bounded_string("abcédef", 5); + assert_eq!(text, "abcé"); + assert!(truncated); + let (text, truncated) = bounded_string("short", 99); + assert_eq!(text, "short"); + assert!(!truncated); + } + + #[test] + fn explicit_missing_dir_reports_discovery_hints() { + let dir = Path::new("Z:/definitely/missing"); + let error = find_tool_by_name(ToolKind::Nm.llvm_binary_name(), Some(dir)) + .expect_err("missing backend"); + let message = error.to_string(); + assert!(message.contains("MERCURY_LLVM_BIN")); + assert!(message.contains("rustc --print sysroot")); + } + + #[test] + fn candidate_lookup_and_executable_names_are_platform_aware() { + let temp = tempfile::tempdir().expect("tempdir"); + let name = if cfg!(windows) { + "fake-llvm.exe" + } else { + "fake-llvm" + }; + let tool = temp.path().join(name); + std::fs::write(&tool, b"").expect("tool placeholder"); + + let mut searched = Vec::new(); + assert_eq!( + candidate_in_dir(temp.path(), "fake-llvm", &mut searched), + Some(tool) + ); + assert_eq!(searched.len(), 1); + assert!( + executable_name("fake-llvm") + .to_string_lossy() + .contains("fake-llvm") + ); + + let mut searched = Vec::new(); + assert_eq!( + candidate_in_dir(temp.path(), "missing", &mut searched), + None + ); + let message = missing_backend_error("missing", &searched).to_string(); + assert!(message.contains("could not find missing")); + assert!(message.contains("Searched:")); + } + + #[test] + fn llvm_backend_runs_version_command_when_backend_is_available() { + let output = run_llvm_binary("llvm-readobj", None, &["--version".to_string()], 256) + .expect("llvm-readobj should be available through repo toolchain"); + + assert_eq!(output.exit_code, 0); + assert!(output.info.tool_path.is_file()); + assert_eq!(output.info.argv, vec!["--version"]); + assert!(!output.stdout.is_empty()); + assert!(!output.stdout_truncated); + assert!(!output.stderr_truncated); + + let via_tool = run_backend(ToolKind::Readobj, None, &["--version".to_string()], 256) + .expect("run_backend delegates to llvm-readobj"); + assert_eq!(via_tool.exit_code, 0); + } + + #[test] + fn parses_rustc_host_from_verbose_version_output() { + let version = "rustc 1.86.0\nbinary: rustc\nhost: x86_64-pc-windows-msvc\n"; + assert_eq!(parse_rustc_host(version), Some("x86_64-pc-windows-msvc")); + assert_eq!(parse_rustc_host("rustc 1.86.0\nhost:\n"), None); + assert_eq!( + rustc_host_triple().as_deref(), + parse_rustc_host( + &String::from_utf8( + Command::new("rustc") + .arg("-vV") + .output() + .expect("rustc") + .stdout + ) + .expect("utf8") + ) + ); + assert!(rustup_llvm_bin_dir().is_some()); + } +} diff --git a/crates/llvmtools/src/cli.rs b/crates/llvmtools/src/cli.rs new file mode 100644 index 0000000..7077e51 --- /dev/null +++ b/crates/llvmtools/src/cli.rs @@ -0,0 +1,908 @@ +//! CLI parsing, orchestration, and rendering for the LLVM wrappers. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + parse_input_format, print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +use crate::backend::{BackendInfo, RawBlock, run_backend}; +use crate::parse_nm::{NmReport, parse_nm_output}; +use crate::parse_objdump::{ObjdumpReport, parse_objdump_output}; +use crate::parse_readobj::{ReadobjReport, parse_readobj_output}; +use crate::pe_summary::{PeSummary, summarize_pe}; + +const DEFAULT_RAW_OUTPUT_LIMIT: usize = 262_144; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Selects which LLVM object tool wrapper is being executed. +pub enum ToolKind { + /// `llvm-objdump` wrapper. + Objdump, + /// `llvm-readobj` wrapper. + Readobj, + /// `llvm-nm` wrapper. + Nm, +} + +impl ToolKind { + /// Returns the Mercury command name. + #[must_use] + pub const fn command_name(self) -> &'static str { + match self { + Self::Objdump => "llvmobjdump", + Self::Readobj => "llvmreadobj", + Self::Nm => "llvmnm", + } + } + + /// Returns the upstream LLVM executable stem. + #[must_use] + pub const fn llvm_binary_name(self) -> &'static str { + match self { + Self::Objdump => "llvm-objdump", + Self::Readobj => "llvm-readobj", + Self::Nm => "llvm-nm", + } + } + + const fn help(self) -> &'static str { + match self { + Self::Objdump => OBJDUMP_HELP, + Self::Readobj => READOBJ_HELP, + Self::Nm => NM_HELP, + } + } +} + +const OBJDUMP_HELP: &str = "\ +Inspect object files through LLVM objdump with Mercury-friendly output. + +Usage: + llvmobjdump [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --llvm-bin-dir Directory containing llvm-objdump + --llvm-arg Append a raw LLVM argument + --raw-output-limit Maximum captured stdout bytes (default: 262144) + --section Restrict LLVM output to a section + --symbol Disassemble one symbol + --start-address Start address for disassembly + --stop-address Stop address for disassembly + --relocs Include relocations + --headers, --section-headers Include section headers + --all Include all available headers + -h, --help Show this help text + -V, --version Show the command version + +Examples: + llvmobjdump .\\target\\release-fast\\binmeta.exe --json | ConvertFrom-Json + @('.\\target\\release-fast\\binmeta.exe') | llvmobjdump --input-format lines --toon + fd -e exe .\\target\\release-fast | llvmobjdump --input-format lines --toon + llvmobjdump .\\target\\release-fast\\binmeta.exe --section .text --toon +"; + +const READOBJ_HELP: &str = "\ +Inspect COFF/PE object metadata through LLVM readobj with Mercury-friendly output. + +Usage: + llvmreadobj [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --llvm-bin-dir Directory containing llvm-readobj + --llvm-arg Append a raw LLVM argument + --raw-output-limit Maximum captured stdout bytes (default: 262144) + --symbols Include symbols + --relocations Include relocations + --exports Include COFF exports + --debug-directory Include COFF debug directory + --codeview Include CodeView debug information + --all Include all available readobj sections + -h, --help Show this help text + -V, --version Show the command version + +Examples: + llvmreadobj .\\target\\release-fast\\binmeta.exe --json | ConvertFrom-Json + @('.\\target\\release-fast\\binmeta.exe') | llvmreadobj --input-format lines --toon + fd -e exe .\\target\\release-fast | llvmreadobj --input-format lines --toon + llvmreadobj .\\target\\release-fast\\binmeta.exe --debug-directory --toon +"; + +const NM_HELP: &str = "\ +Inspect object symbols through LLVM nm with Mercury-friendly output. + +Usage: + llvmnm [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --llvm-bin-dir Directory containing llvm-nm + --llvm-arg Append a raw LLVM argument + --raw-output-limit Maximum captured stdout bytes (default: 262144) + --defined-only Show only defined symbols (default) + --undefined-only Show only undefined symbols + --extern-only Show only external symbols + --demangle Demangle names (default) + --no-demangle Do not demangle names + --sort Sort symbols: address, name, size, none + --print-file-name Prefix symbol rows with file names + -h, --help Show this help text + -V, --version Show the command version + +Examples: + llvmnm .\\target\\release-fast\\binmeta.exe --defined-only --json | ConvertFrom-Json + @('.\\target\\release-fast\\binmeta.exe') | llvmnm --input-format lines --toon + fd -e exe .\\target\\release-fast | llvmnm --input-format lines --toon + llvmnm .\\target\\release-fast\\binmeta.exe --undefined-only --toon +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + paths: Vec, + llvm_bin_dir: Option, + llvm_args: Vec, + raw_output_limit: usize, + flags: ToolFlags, +} + +#[derive(Debug, Clone, Default)] +struct ToolFlags { + objdump: ObjdumpFlags, + readobj: ReadobjFlags, + nm: NmFlags, +} + +#[derive(Debug, Clone, Default)] +struct ObjdumpFlags { + sections: Vec, + symbols: Vec, + start_address: Option, + stop_address: Option, + relocs: bool, + headers: bool, + all: bool, +} + +#[derive(Debug, Clone, Default)] +#[allow( + clippy::struct_excessive_bools, + reason = "these booleans map directly to independent llvm-readobj flags" +)] +struct ReadobjFlags { + symbols: bool, + relocations: bool, + exports: bool, + debug_directory: bool, + codeview: bool, + all: bool, +} + +#[derive(Debug, Clone)] +#[allow( + clippy::struct_excessive_bools, + reason = "these booleans map directly to independent llvm-nm flags" +)] +struct NmFlags { + defined_only: bool, + undefined_only: bool, + extern_only: bool, + demangle: bool, + sort: NmSort, + print_file_name: bool, +} + +impl Default for NmFlags { + fn default() -> Self { + Self { + defined_only: true, + undefined_only: false, + extern_only: false, + demangle: true, + sort: NmSort::Address, + print_file_name: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NmSort { + Address, + Name, + Size, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Serialize)] +struct ToolReport { + tool: &'static str, + backend: BackendInfo, + inputs: Vec, + summary: Summary, + files: Vec, + parse_warnings: Vec, + raw_blocks: Vec, +} + +#[derive(Debug, Default, Serialize)] +#[allow( + clippy::struct_field_names, + reason = "the JSON contract intentionally names summary counters with a count suffix" +)] +struct Summary { + file_count: usize, + success_count: usize, + failed_count: usize, + section_count: usize, + symbol_count: usize, + instruction_count: usize, + import_library_count: usize, + export_count: usize, +} + +#[derive(Debug, Serialize)] +struct FileReport { + path: PathBuf, + format: Option, + arch: Option, + address_size: Option, + exit_code: i32, + stderr_tail: Option, + pe: Option, + objdump: Option, + readobj: Option, + nm: Option, +} + +/// Parses CLI arguments, runs the selected LLVM wrapper, and returns a process exit code. +#[must_use] +pub fn main_entry(tool: ToolKind) -> i32 { + match parse_cli_from(std::env::args_os(), tool) { + Ok((ParseOutcome::Help, _)) => { + print!("{}", tool.help()); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("{} {}", tool.command_name(), env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(tool, &cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, tool.help()); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, tool.help()); + error.exit_code().as_i32() + } + } +} + +#[allow( + clippy::too_many_lines, + reason = "the parser keeps each supported CLI flag visible beside its validation" +)] +fn parse_cli_from(args: I, tool: ToolKind) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + paths: Vec::new(), + llvm_bin_dir: None, + llvm_args: Vec::new(), + raw_output_limit: DEFAULT_RAW_OUTPUT_LIMIT, + flags: ToolFlags::default(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("llvm-bin-dir") => { + cli.llvm_bin_dir = Some(PathBuf::from(parser_value_string( + &mut parser, + "--llvm-bin-dir", + )?)); + } + Long("llvm-arg") => cli + .llvm_args + .push(parser_value_string(&mut parser, "--llvm-arg")?), + Long("raw-output-limit") => { + let value = parser_value_string(&mut parser, "--raw-output-limit")?; + cli.raw_output_limit = value.parse().map_err(|error| { + CliError::usage(format!("--raw-output-limit expects a byte count: {error}")) + })?; + } + Long("section") => { + require_tool(tool, ToolKind::Objdump, "--section")?; + cli.flags + .objdump + .sections + .push(parser_value_string(&mut parser, "--section")?); + } + Long("symbol") => { + require_tool(tool, ToolKind::Objdump, "--symbol")?; + cli.flags + .objdump + .symbols + .push(parser_value_string(&mut parser, "--symbol")?); + } + Long("start-address") => { + require_tool(tool, ToolKind::Objdump, "--start-address")?; + cli.flags.objdump.start_address = + Some(parser_value_string(&mut parser, "--start-address")?); + } + Long("stop-address") => { + require_tool(tool, ToolKind::Objdump, "--stop-address")?; + cli.flags.objdump.stop_address = + Some(parser_value_string(&mut parser, "--stop-address")?); + } + Long("relocs") => { + require_tool(tool, ToolKind::Objdump, "--relocs")?; + cli.flags.objdump.relocs = true; + } + Long("headers" | "section-headers") => { + require_tool(tool, ToolKind::Objdump, "--headers")?; + cli.flags.objdump.headers = true; + } + Long("all") if tool == ToolKind::Objdump => cli.flags.objdump.all = true, + Long("symbols") => { + require_tool(tool, ToolKind::Readobj, "--symbols")?; + cli.flags.readobj.symbols = true; + } + Long("relocations") => { + require_tool(tool, ToolKind::Readobj, "--relocations")?; + cli.flags.readobj.relocations = true; + } + Long("exports") => { + require_tool(tool, ToolKind::Readobj, "--exports")?; + cli.flags.readobj.exports = true; + } + Long("debug-directory") => { + require_tool(tool, ToolKind::Readobj, "--debug-directory")?; + cli.flags.readobj.debug_directory = true; + } + Long("codeview") => { + require_tool(tool, ToolKind::Readobj, "--codeview")?; + cli.flags.readobj.codeview = true; + } + Long("all") if tool == ToolKind::Readobj => cli.flags.readobj.all = true, + Long("defined-only") => { + require_tool(tool, ToolKind::Nm, "--defined-only")?; + cli.flags.nm.defined_only = true; + cli.flags.nm.undefined_only = false; + } + Long("undefined-only") => { + require_tool(tool, ToolKind::Nm, "--undefined-only")?; + cli.flags.nm.undefined_only = true; + cli.flags.nm.defined_only = false; + } + Long("extern-only") => { + require_tool(tool, ToolKind::Nm, "--extern-only")?; + cli.flags.nm.extern_only = true; + } + Long("demangle") => { + require_tool(tool, ToolKind::Nm, "--demangle")?; + cli.flags.nm.demangle = true; + } + Long("no-demangle") => { + require_tool(tool, ToolKind::Nm, "--no-demangle")?; + cli.flags.nm.demangle = false; + } + Long("sort") => { + require_tool(tool, ToolKind::Nm, "--sort")?; + cli.flags.nm.sort = parse_nm_sort(&parser_value_string(&mut parser, "--sort")?)?; + } + Long("print-file-name") => { + require_tool(tool, ToolKind::Nm, "--print-file-name")?; + cli.flags.nm.print_file_name = true; + } + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + Ok((ParseOutcome::Run, cli)) +} + +fn require_tool(actual: ToolKind, expected: ToolKind, flag: &str) -> Result<(), CliError> { + if actual == expected { + Ok(()) + } else { + Err(CliError::usage(format!( + "{flag} is not supported by {}", + actual.command_name() + ))) + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_nm_sort(value: &str) -> Result { + match value { + "address" => Ok(NmSort::Address), + "name" => Ok(NmSort::Name), + "size" => Ok(NmSort::Size), + "none" => Ok(NmSort::None), + other => Err(CliError::usage(format!( + "invalid --sort value '{other}'; expected address, name, size, or none" + ))), + } +} + +fn run(tool: ToolKind, cli: &Cli) -> Result { + let paths = collect_paths(cli, tool.command_name())?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let mut files = Vec::new(); + let mut parse_warnings = Vec::new(); + let mut raw_blocks = Vec::new(); + let mut backend_info = None; + for path in &paths { + if !path.exists() { + return Err(CliError::runtime(format!( + "input path does not exist: {}", + path.display() + ))); + } + let args = build_llvm_args(tool, cli, path); + let backend = run_backend( + tool, + cli.llvm_bin_dir.as_deref(), + &args, + cli.raw_output_limit, + )?; + backend_info = Some(backend.info.clone()); + if backend.exit_code != 0 { + return Err(CliError::runtime(format!( + "{} failed for {} with exit code {}: {}", + tool.llvm_binary_name(), + path.display(), + backend.exit_code, + tail(&backend.stderr, 2000).unwrap_or_default() + ))); + } + let (file, warnings, blocks) = parse_file_report( + tool, + path, + &backend.stdout, + &backend.stderr, + backend.exit_code, + backend.stdout_truncated, + ); + parse_warnings.extend(warnings); + raw_blocks.extend(blocks); + files.push(file); + } + + let report = ToolReport { + tool: tool.command_name(), + backend: backend_info.expect("backend ran for at least one file"), + inputs: paths, + summary: summarize_files(&files), + files, + parse_warnings, + raw_blocks, + }; + + match cli.common.render_mode() { + RenderMode::Json | RenderMode::Toon => print_structured(&report, cli.common.render_mode())?, + RenderMode::Text => print!("{}", render_text_report(&report)), + } + Ok(ExitCode::Success) +} + +fn collect_paths(cli: &Cli, command_name: &str) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if let Some(paths) = common::read_existing_stdin_path_records( + &buffer, + cli.common.input_format, + command_name, + )? { + return Ok(paths); + } + } + common::expand_input_patterns(&cli.paths, command_name) +} + +fn build_llvm_args(tool: ToolKind, cli: &Cli, path: &std::path::Path) -> Vec { + let mut args = match tool { + ToolKind::Objdump => objdump_args(&cli.flags.objdump), + ToolKind::Readobj => readobj_args(&cli.flags.readobj), + ToolKind::Nm => nm_args(&cli.flags.nm), + }; + args.extend(cli.llvm_args.iter().cloned()); + args.push(path.display().to_string()); + args +} + +fn objdump_args(flags: &ObjdumpFlags) -> Vec { + let mut args = Vec::new(); + if flags.all { + args.push("--all-headers".to_owned()); + } else { + args.extend([ + "--file-headers".to_owned(), + "--section-headers".to_owned(), + "--disassemble".to_owned(), + "--demangle".to_owned(), + ]); + } + if flags.headers && !args.iter().any(|arg| arg == "--section-headers") { + args.push("--section-headers".to_owned()); + } + if flags.relocs { + args.push("--reloc".to_owned()); + } + for section in &flags.sections { + args.push(format!("--section={section}")); + } + for symbol in &flags.symbols { + args.push(format!("--disassemble-symbols={symbol}")); + } + if let Some(start) = &flags.start_address { + args.push(format!("--start-address={start}")); + } + if let Some(stop) = &flags.stop_address { + args.push(format!("--stop-address={stop}")); + } + args +} + +fn readobj_args(flags: &ReadobjFlags) -> Vec { + let mut args = if flags.all { + vec!["--all".to_owned()] + } else { + vec![ + "--file-headers".to_owned(), + "--sections".to_owned(), + "--coff-imports".to_owned(), + ] + }; + if flags.symbols { + args.push("--symbols".to_owned()); + } + if flags.relocations { + args.push("--relocations".to_owned()); + } + if flags.exports { + args.push("--coff-exports".to_owned()); + } + if flags.debug_directory { + args.push("--coff-debug-directory".to_owned()); + } + if flags.codeview { + args.push("--codeview".to_owned()); + } + args +} + +fn nm_args(flags: &NmFlags) -> Vec { + let mut args = vec!["--format=posix".to_owned(), "--print-size".to_owned()]; + if flags.defined_only { + args.push("--defined-only".to_owned()); + } + if flags.undefined_only { + args.push("--undefined-only".to_owned()); + } + if flags.extern_only { + args.push("--extern-only".to_owned()); + } + if flags.demangle { + args.push("--demangle".to_owned()); + } else { + args.push("--no-demangle".to_owned()); + } + if flags.print_file_name { + args.push("--print-file-name".to_owned()); + } + match flags.sort { + NmSort::Address => args.push("--numeric-sort".to_owned()), + NmSort::Name => {} + NmSort::Size => args.push("--size-sort".to_owned()), + NmSort::None => args.push("--no-sort".to_owned()), + } + args +} + +fn parse_file_report( + tool: ToolKind, + path: &std::path::Path, + stdout: &str, + stderr: &str, + exit_code: i32, + stdout_truncated: bool, +) -> (FileReport, Vec, Vec) { + let mut warnings = Vec::new(); + let mut raw_blocks = Vec::new(); + let pe = summarize_pe(path); + let mut report = FileReport { + path: path.to_path_buf(), + format: None, + arch: pe.as_ref().map(|summary| summary.architecture.clone()), + address_size: pe.as_ref().map(|summary| format!("{}bit", summary.bitness)), + exit_code, + stderr_tail: if tool == ToolKind::Nm && stderr.trim().eq_ignore_ascii_case("no symbols") { + None + } else { + tail(stderr, 4000) + }, + pe, + objdump: None, + readobj: None, + nm: None, + }; + if stdout_truncated { + warnings.push(format!( + "{} stdout for {} was truncated", + tool.llvm_binary_name(), + path.display() + )); + } + match tool { + ToolKind::Objdump => { + let parsed = parse_objdump_output(stdout); + report.format.clone_from(&parsed.report.format); + report.arch.clone_from(&parsed.report.architecture); + warnings.extend(parsed.warnings); + if !parsed.raw_lines.is_empty() { + raw_blocks.push(RawBlock { + path: Some(path.to_path_buf()), + label: "llvm-objdump-unparsed".to_owned(), + text: parsed.raw_lines.join("\n"), + truncated: stdout_truncated, + }); + } + report.objdump = Some(parsed.report); + } + ToolKind::Readobj => { + let parsed = parse_readobj_output(stdout); + report.format.clone_from(&parsed.report.format); + report.arch.clone_from(&parsed.report.arch); + report.address_size.clone_from(&parsed.report.address_size); + warnings.extend(parsed.warnings); + if !parsed.raw_lines.is_empty() { + raw_blocks.push(RawBlock { + path: Some(path.to_path_buf()), + label: "llvm-readobj-unparsed".to_owned(), + text: parsed.raw_lines.join("\n"), + truncated: stdout_truncated, + }); + } + report.readobj = Some(parsed.report); + } + ToolKind::Nm => { + let parsed = parse_nm_output(stdout); + warnings.extend(parsed.warnings); + if !parsed.raw_lines.is_empty() { + raw_blocks.push(RawBlock { + path: Some(path.to_path_buf()), + label: "llvm-nm-raw".to_owned(), + text: parsed.raw_lines.join("\n"), + truncated: stdout_truncated, + }); + } + report.nm = Some(parsed.report); + } + } + (report, warnings, raw_blocks) +} + +fn summarize_files(files: &[FileReport]) -> Summary { + let mut summary = Summary { + file_count: files.len(), + success_count: files.iter().filter(|file| file.exit_code == 0).count(), + failed_count: files.iter().filter(|file| file.exit_code != 0).count(), + ..Summary::default() + }; + for file in files { + if let Some(objdump) = &file.objdump { + summary.section_count += objdump.sections.len(); + summary.symbol_count += objdump.symbols.len(); + summary.instruction_count += objdump.instruction_count; + } + if let Some(readobj) = &file.readobj { + summary.section_count += readobj.sections.len(); + summary.import_library_count += readobj.imports.len(); + summary.export_count += readobj.export_count; + } + if let Some(nm) = &file.nm { + summary.symbol_count += nm.symbol_count; + } + } + summary +} + +fn render_text_report(report: &ToolReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "{} files={} sections={} symbols={} instructions={} imports={} exports={}", + report.tool, + report.summary.file_count, + report.summary.section_count, + report.summary.symbol_count, + report.summary.instruction_count, + report.summary.import_library_count, + report.summary.export_count + ); + let _ = writeln!( + output, + "backend={} version={}", + report.backend.tool_path.display(), + report.backend.version.as_deref().unwrap_or("unknown") + ); + for file in &report.files { + let _ = writeln!( + output, + "file={} format={} arch={} exit_code={}", + file.path.display(), + file.format.as_deref().unwrap_or("unknown"), + file.arch.as_deref().unwrap_or("unknown"), + file.exit_code + ); + } + for warning in &report.parse_warnings { + let _ = writeln!(output, "warning={warning}"); + } + output +} + +fn tail(text: &str, max_bytes: usize) -> Option { + if text.trim().is_empty() { + return None; + } + if text.len() <= max_bytes { + return Some(text.trim().to_owned()); + } + let mut start = text.len() - max_bytes; + while !text.is_char_boundary(start) { + start += 1; + } + Some(text[start..].trim().to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn objdump_symbol_flags_are_translated_to_llvm_args() { + let flags = ObjdumpFlags { + symbols: vec!["main".to_owned()], + start_address: Some("0x10".to_owned()), + stop_address: Some("0x20".to_owned()), + ..ObjdumpFlags::default() + }; + let args = objdump_args(&flags); + assert!(args.contains(&"--disassemble-symbols=main".to_owned())); + assert!(args.contains(&"--start-address=0x10".to_owned())); + assert!(args.contains(&"--stop-address=0x20".to_owned())); + } + + #[test] + fn nm_sort_values_are_strict() { + assert_eq!(parse_nm_sort("address").expect("sort"), NmSort::Address); + assert!(parse_nm_sort("mtime").is_err()); + } + + #[test] + fn readobj_defaults_include_coff_imports() { + let args = readobj_args(&ReadobjFlags::default()); + assert!(args.contains(&"--file-headers".to_owned())); + assert!(args.contains(&"--coff-imports".to_owned())); + } + + #[test] + fn tail_preserves_short_text_and_trims_long_text() { + assert_eq!(tail(" hello ", 100).as_deref(), Some("hello")); + assert_eq!(tail("abcdef", 3).as_deref(), Some("def")); + } + + #[test] + fn objdump_truncated_stdout_marks_unparsed_raw_block_as_truncated() { + let stdout = "\ +sample.obj:\tfile format coff-x86-64 +architecture: x86_64 +unparsed disassembly row preserved from truncated output +"; + let path = std::path::PathBuf::from("sample.obj"); + + let (file, warnings, raw_blocks) = + parse_file_report(ToolKind::Objdump, &path, stdout, "", 0, true); + + assert_eq!(file.format.as_deref(), Some("coff-x86-64")); + assert!( + warnings + .iter() + .any(|warning| warning.contains("llvm-objdump stdout") + && warning.contains("truncated")), + "expected truncation warning, got {warnings:?}" + ); + + let raw = raw_blocks + .iter() + .find(|block| block.label == "llvm-objdump-unparsed") + .expect("unparsed raw block"); + assert!( + raw.truncated, + "raw objdump block derived from truncated stdout must be marked truncated" + ); + assert!(raw.text.contains("unparsed disassembly row")); + } +} diff --git a/crates/llvmtools/src/lib.rs b/crates/llvmtools/src/lib.rs new file mode 100644 index 0000000..963784d --- /dev/null +++ b/crates/llvmtools/src/lib.rs @@ -0,0 +1,28 @@ +//! Mercury wrappers for LLVM object-file tools. +#![allow( + clippy::redundant_pub_crate, + reason = "internal sibling modules share parser and backend contracts without exporting them" +)] + +pub mod backend; +mod cli; +pub(crate) mod parse_nm; +pub(crate) mod parse_objdump; +pub(crate) mod parse_readobj; +pub(crate) mod pe_summary; + +pub use cli::{ToolKind, main_entry}; + +/// Parses `llvm-objdump` text into a structured JSON value. +#[must_use] +pub fn parse_objdump_json_value(text: &str) -> serde_json::Value { + serde_json::to_value(parse_objdump::parse_objdump_output(text).report) + .unwrap_or(serde_json::Value::Null) +} + +/// Parses `llvm-readobj` text into a structured JSON value. +#[must_use] +pub fn parse_readobj_json_value(text: &str) -> serde_json::Value { + serde_json::to_value(parse_readobj::parse_readobj_output(text).report) + .unwrap_or(serde_json::Value::Null) +} diff --git a/crates/llvmtools/src/main_nm.rs b/crates/llvmtools/src/main_nm.rs new file mode 100644 index 0000000..d9bc8ea --- /dev/null +++ b/crates/llvmtools/src/main_nm.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `llvmnm`. + +fn main() { + std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Nm)); +} diff --git a/crates/llvmtools/src/main_objdump.rs b/crates/llvmtools/src/main_objdump.rs new file mode 100644 index 0000000..f70a7e8 --- /dev/null +++ b/crates/llvmtools/src/main_objdump.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `llvmobjdump`. + +fn main() { + std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Objdump)); +} diff --git a/crates/llvmtools/src/main_readobj.rs b/crates/llvmtools/src/main_readobj.rs new file mode 100644 index 0000000..bc3fe6b --- /dev/null +++ b/crates/llvmtools/src/main_readobj.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `llvmreadobj`. + +fn main() { + std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Readobj)); +} diff --git a/crates/llvmtools/src/parse_nm.rs b/crates/llvmtools/src/parse_nm.rs new file mode 100644 index 0000000..8a60219 --- /dev/null +++ b/crates/llvmtools/src/parse_nm.rs @@ -0,0 +1,398 @@ +//! Parser for `llvm-nm --format=posix` output. + +use std::path::PathBuf; + +use serde::Serialize; + +/// Summary parsed from POSIX `llvm-nm` output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct NmReport { + /// Structured symbol rows. + pub symbols: Vec, + /// Number of structured symbol rows. + pub symbol_count: usize, +} + +/// Symbol row parsed from POSIX `llvm-nm` output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct NmSymbol { + /// Optional file prefix emitted by `--print-file-name`. + pub file: Option, + /// Symbol name as printed by `llvm-nm`. + pub name: String, + /// One-character symbol type. + pub symbol_type: String, + /// Symbol address, when the symbol is defined. + pub address: Option, + /// Symbol size, when the symbol is defined. + pub size: Option, + /// Optional source file from line-number output. + pub source: Option, +} + +/// Full parser result, including raw fallback lines and warnings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NmParseResult { + /// Structured report. + pub report: NmReport, + /// Non-fatal parser warnings. + pub warnings: Vec, + /// Raw lines that could not be parsed as symbol rows. + pub raw_lines: Vec, +} + +/// Parses POSIX `llvm-nm` text. +#[must_use] +pub(crate) fn parse_nm_output(text: &str) -> NmParseResult { + let mut symbols = Vec::new(); + let mut warnings = Vec::new(); + let mut raw_lines = Vec::new(); + + for (index, line) in text.lines().enumerate() { + match parse_nm_line(line) { + Ok(symbol) => symbols.push(symbol), + Err(warning) => { + warnings.push(format!("line {}: {warning}", index + 1)); + raw_lines.push(line.to_owned()); + } + } + } + + NmParseResult { + report: NmReport { + symbol_count: symbols.len(), + symbols, + }, + warnings, + raw_lines, + } +} + +fn parse_nm_line(line: &str) -> Result { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Err("blank llvm-nm POSIX line".to_string()); + } + if trimmed.contains("no symbols") { + return Err("raw llvm-nm informational line".to_string()); + } + + let (body, source) = split_source_suffix(trimmed); + let (file, symbol_text) = split_file_name_prefix(body); + let fields = parse_symbol_fields(symbol_text)?; + + Ok(NmSymbol { + file, + name: fields.name, + symbol_type: fields.kind.to_string(), + address: fields.address, + size: fields.size, + source, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SymbolFields { + name: String, + kind: char, + address: Option, + size: Option, +} + +fn parse_symbol_fields(text: &str) -> Result { + let tokens = text.split_whitespace().collect::>(); + if tokens.len() < 2 { + return Err("malformed llvm-nm POSIX line: expected symbol name and type".to_string()); + } + + if let Some(fields) = parse_undefined_symbol_without_numbers(&tokens) { + return fields; + } + + if tokens.len() < 4 { + return Err( + "malformed llvm-nm POSIX line: expected name, type, address, and size".to_string(), + ); + } + + let size_text = tokens[tokens.len() - 1]; + let address_text = tokens[tokens.len() - 2]; + let kind_text = tokens[tokens.len() - 3]; + let name_text = tokens[..tokens.len() - 3].join(" "); + + let Some(kind) = parse_kind(kind_text) else { + return Err("malformed llvm-nm POSIX line: invalid symbol type".to_string()); + }; + if name_text.is_empty() { + return Err("malformed llvm-nm POSIX line: missing symbol name".to_string()); + } + + let address = parse_number(address_text) + .ok_or_else(|| format!("invalid address '{address_text}' in llvm-nm POSIX line"))?; + let size = parse_number(size_text) + .ok_or_else(|| format!("invalid size '{size_text}' in llvm-nm POSIX line"))?; + + Ok(SymbolFields { + name: name_text, + kind, + address: symbol_address(kind, address), + size: symbol_size(kind, size), + }) +} + +fn parse_undefined_symbol_without_numbers(tokens: &[&str]) -> Option> { + let kind_text = tokens.last()?; + let kind = parse_kind(kind_text)?; + if !is_undefined_kind(kind) { + return None; + } + + let name_text = tokens[..tokens.len() - 1].join(" "); + if name_text.is_empty() { + return Some(Err( + "malformed llvm-nm POSIX line: missing symbol name".to_string() + )); + } + + Some(Ok(SymbolFields { + name: name_text, + kind, + address: None, + size: None, + })) +} + +const fn symbol_address(kind: char, address: u64) -> Option { + if is_undefined_kind(kind) { + None + } else { + Some(address) + } +} + +const fn symbol_size(kind: char, size: u64) -> Option { + if is_undefined_kind(kind) { + None + } else { + Some(size) + } +} + +const fn is_undefined_kind(kind: char) -> bool { + matches!(kind, 'U' | 'u') +} + +fn parse_kind(text: &str) -> Option { + let mut chars = text.chars(); + let kind = chars.next()?; + if chars.next().is_some() || !is_nm_kind(kind) { + return None; + } + Some(kind) +} + +const fn is_nm_kind(kind: char) -> bool { + matches!( + kind, + 'A' | 'a' + | 'B' + | 'b' + | 'C' + | 'c' + | 'D' + | 'd' + | 'G' + | 'g' + | 'I' + | 'i' + | 'N' + | 'n' + | 'P' + | 'p' + | 'R' + | 'r' + | 'S' + | 's' + | 'T' + | 't' + | 'U' + | 'u' + | 'V' + | 'v' + | 'W' + | 'w' + | '-' + | '?' + ) +} + +fn parse_number(value: &str) -> Option { + let trimmed = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value); + u64::from_str_radix(trimmed, 16).ok() +} + +fn split_file_name_prefix(text: &str) -> (Option, &str) { + let mut search_end = text.len(); + while let Some(colon_index) = text[..search_end].rfind(':') { + let Some(after_separator) = text[colon_index + 1..].chars().next() else { + search_end = colon_index; + continue; + }; + if !after_separator.is_whitespace() { + search_end = colon_index; + continue; + } + + let after_colon = text[colon_index + 1..].trim_start(); + if parse_symbol_fields(after_colon).is_ok() { + let file_name = text[..colon_index].trim(); + if !file_name.is_empty() { + return (Some(PathBuf::from(file_name)), after_colon); + } + } + search_end = colon_index; + } + + (None, text) +} + +fn split_source_suffix(text: &str) -> (&str, Option) { + let Some((body, source)) = text.split_once('\t') else { + return (text, None); + }; + let source = source.trim(); + if source.is_empty() { + return (body.trim_end(), None); + } + + (body.trim_end(), Some(source_file_without_line(source))) +} + +fn source_file_without_line(source: &str) -> String { + let Some((file, line)) = source.rsplit_once(':') else { + return source.to_string(); + }; + if file.is_empty() + || line.is_empty() + || !line.chars().all(|character| character.is_ascii_digit()) + { + return source.to_string(); + } + file.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn symbol_at(result: &NmParseResult, index: usize) -> &NmSymbol { + &result.report.symbols[index] + } + + #[test] + fn parses_posix_defined_symbols_with_size() { + let result = parse_nm_output("main T 140001000 20\n"); + + assert_eq!(result.report.symbol_count, 1); + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.name, "main"); + assert_eq!(symbol.symbol_type, "T"); + assert_eq!(symbol.address, Some(0x0001_4000_1000)); + assert_eq!(symbol.size, Some(0x20)); + assert_eq!(symbol.file, None); + assert_eq!(symbol.source, None); + } + + #[test] + fn parses_undefined_symbols_with_zero_address_and_size_as_none() { + let result = parse_nm_output("__imp_CreateFileW U 0 0\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.name, "__imp_CreateFileW"); + assert_eq!(symbol.symbol_type, "U"); + assert_eq!(symbol.address, None); + assert_eq!(symbol.size, None); + } + + #[test] + fn parses_undefined_symbols_without_address_columns() { + let result = parse_nm_output("demo.obj: __imp_CloseHandle U\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.file, Some(PathBuf::from("demo.obj"))); + assert_eq!(symbol.name, "__imp_CloseHandle"); + assert_eq!(symbol.symbol_type, "U"); + assert_eq!(symbol.address, None); + assert_eq!(symbol.size, None); + } + + #[test] + fn parses_print_file_name_prefix_before_symbol_fields() { + let result = parse_nm_output("obj/foo.obj: ?helper@@YAXXZ T 140001000 30\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.file, Some(PathBuf::from("obj/foo.obj"))); + assert_eq!(symbol.name, "?helper@@YAXXZ"); + assert_eq!(symbol.symbol_type, "T"); + assert_eq!(symbol.address, Some(0x0001_4000_1000)); + assert_eq!(symbol.size, Some(0x30)); + } + + #[test] + fn parses_archive_print_file_name_prefix() { + let result = parse_nm_output("libstuff.a[file.o]: exported D 2000 8\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.file, Some(PathBuf::from("libstuff.a[file.o]"))); + assert_eq!(symbol.name, "exported"); + assert_eq!(symbol.address, Some(0x2000)); + assert_eq!(symbol.size, Some(8)); + } + + #[test] + fn parses_source_file_suffix_from_line_numbers_output() { + let result = parse_nm_output("global_var D 4040 4\tC:/src/main.c:27\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.name, "global_var"); + assert_eq!(symbol.source.as_deref(), Some("C:/src/main.c")); + } + + #[test] + fn keeps_source_file_without_numeric_line_suffix() { + let result = parse_nm_output("global_var D 4040 4\tC:/src/generated file.c\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.source.as_deref(), Some("C:/src/generated file.c")); + } + + #[test] + fn parses_demangled_names_that_contain_spaces() { + let result = parse_nm_output("void ns::demo(int, char const*) T 10 20\n"); + + let symbol = symbol_at(&result, 0); + assert_eq!(symbol.name, "void ns::demo(int, char const*)"); + assert_eq!(symbol.symbol_type, "T"); + assert_eq!(symbol.address, Some(0x10)); + assert_eq!(symbol.size, Some(0x20)); + assert_eq!(symbol.file, None); + } + + #[test] + fn malformed_lines_become_warnings_and_raw_lines() { + let result = parse_nm_output("this is not nm output\n\nname T xyz 4\n"); + + assert!(result.report.symbols.is_empty()); + assert_eq!( + result.raw_lines, + vec!["this is not nm output", "", "name T xyz 4"] + ); + assert_eq!(result.warnings.len(), 3); + assert!(result.warnings[0].contains("malformed llvm-nm POSIX line")); + assert!(result.warnings[1].contains("blank llvm-nm POSIX line")); + assert!(result.warnings[2].contains("invalid address")); + } +} diff --git a/crates/llvmtools/src/parse_objdump.rs b/crates/llvmtools/src/parse_objdump.rs new file mode 100644 index 0000000..ba167a6 --- /dev/null +++ b/crates/llvmtools/src/parse_objdump.rs @@ -0,0 +1,631 @@ +//! Parser for high-value `llvm-objdump` text blocks. + +use serde::Serialize; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ObjdumpReport { + pub file: Option, + pub format: Option, + pub architecture: Option, + pub start_address: Option, + pub start_address_value: Option, + pub sections: Vec, + pub symbols: Vec, + pub instructions: Vec, + pub address_range: Option, + pub raw_blocks: Vec, + pub instruction_count: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ObjdumpSection { + pub index: usize, + pub name: String, + pub size: String, + pub size_value: Option, + pub vma: String, + pub vma_value: Option, + pub section_type: Option, + pub raw: String, + pub line_number: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct DisassemblySymbol { + pub address: String, + pub address_value: Option, + pub name: String, + pub section: Option, + pub instructions: Vec, + pub raw: String, + pub line_number: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct Instruction { + pub address: String, + pub address_value: Option, + pub bytes: Vec, + pub byte_values: Vec, + pub text: String, + pub section: Option, + pub symbol: Option, + pub raw: String, + pub line_number: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct AddressRange { + pub start: u64, + pub end: u64, + pub instruction_count: usize, + pub byte_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ObjdumpRawBlock { + pub label: String, + pub text: String, + pub line_number: usize, + pub warning: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct ObjdumpParseResult { + pub report: ObjdumpReport, + pub warnings: Vec, + pub raw_lines: Vec, +} + +#[must_use] +#[cfg(test)] +pub(crate) fn parse_objdump(text: &str) -> ObjdumpParseResult { + parse_objdump_output(text) +} + +#[allow( + clippy::too_many_lines, + reason = "the parser keeps the single-pass state machine readable for each objdump block" +)] +#[must_use] +pub(crate) fn parse_objdump_output(text: &str) -> ObjdumpParseResult { + let mut result = ObjdumpParseResult::default(); + let mut in_sections = false; + let mut current_section: Option = None; + let mut current_symbol: Option = None; + + for (index, line) in text.lines().enumerate() { + let line_number = index + 1; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some((file, format)) = parse_file_format_line(trimmed) { + flush_symbol(&mut result.report, &mut current_symbol); + result.report.file = Some(file); + result.report.format = Some(format); + in_sections = false; + continue; + } + + if let Some(value) = parse_prefixed_value(trimmed, "architecture:") { + result.report.architecture = Some(value.trim_end_matches(',').to_owned()); + in_sections = false; + continue; + } + + if let Some(value) = parse_prefixed_value(trimmed, "start address:") { + result.report.start_address = Some(value.to_owned()); + match parse_hex_u64(value) { + Ok(address) => result.report.start_address_value = Some(address), + Err(()) => warn( + &mut result, + "prelude", + line, + line_number, + format!("line {line_number}: invalid start address '{value}'"), + ), + } + in_sections = false; + continue; + } + + if trimmed == "Sections:" { + flush_symbol(&mut result.report, &mut current_symbol); + in_sections = true; + continue; + } + + if is_section_table_header(trimmed) { + flush_symbol(&mut result.report, &mut current_symbol); + in_sections = true; + continue; + } + + if let Some(section) = parse_disassembly_section(trimmed) { + flush_symbol(&mut result.report, &mut current_symbol); + current_section = Some(section); + in_sections = false; + continue; + } + + if in_sections { + if let Some(section) = parse_section_row(line, line_number, &mut result.warnings) { + result.report.sections.push(section); + continue; + } + in_sections = false; + } + + if let Some(symbol) = + parse_symbol_label(trimmed, line, line_number, current_section.clone()) + { + flush_symbol(&mut result.report, &mut current_symbol); + current_symbol = Some(symbol); + continue; + } + + if looks_like_address_row(trimmed) { + match parse_instruction( + trimmed, + line, + line_number, + current_section.clone(), + current_symbol.as_ref().map(|symbol| symbol.name.clone()), + ) { + Some(instruction) if !instruction.bytes.is_empty() => { + result.report.instruction_count += 1; + update_address_range(&mut result.report, &instruction); + if let Some(symbol) = &mut current_symbol { + symbol.instructions.push(instruction.clone()); + } + result.report.instructions.push(instruction); + } + _ => warn( + &mut result, + "instruction", + line, + line_number, + format!("line {line_number}: invalid instruction bytes in llvm-objdump row"), + ), + } + continue; + } + + if is_source_or_line_hint(trimmed) { + warn( + &mut result, + "source", + line, + line_number, + format!("line {line_number}: preserved source/line block"), + ); + continue; + } + + warn( + &mut result, + "unparsed", + line, + line_number, + format!("line {line_number}: unparsed llvm-objdump line"), + ); + } + + flush_symbol(&mut result.report, &mut current_symbol); + result +} + +fn flush_symbol(report: &mut ObjdumpReport, current_symbol: &mut Option) { + if let Some(symbol) = current_symbol.take() { + report.symbols.push(symbol); + } +} + +fn parse_file_format_line(trimmed: &str) -> Option<(String, String)> { + let (file, rest) = trimmed.split_once(':')?; + let format = rest.trim().strip_prefix("file format")?.trim(); + if file.trim().is_empty() || format.is_empty() { + return None; + } + Some((file.trim().to_owned(), format.to_owned())) +} + +fn parse_prefixed_value<'a>(trimmed: &'a str, prefix: &str) -> Option<&'a str> { + trimmed + .strip_prefix(prefix) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn is_section_table_header(trimmed: &str) -> bool { + trimmed.starts_with("Idx ") && trimmed.contains("Name") && trimmed.contains("Size") +} + +fn parse_disassembly_section(trimmed: &str) -> Option { + trimmed + .strip_prefix("Disassembly of section ")? + .strip_suffix(':') + .map(str::trim) + .filter(|section| !section.is_empty()) + .map(ToOwned::to_owned) +} + +fn parse_section_row( + line: &str, + line_number: usize, + warnings: &mut Vec, +) -> Option { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 4 || !parts[0].chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + + let index = match parts[0].parse() { + Ok(index) => index, + Err(error) => { + warnings.push(format!( + "line {line_number}: invalid section index '{}': {error}", + parts[0] + )); + 0 + } + }; + let size_value = parse_hex_u64(parts[2]).map_or_else( + |()| { + warnings.push(format!( + "line {line_number}: invalid section size '{}'", + parts[2] + )); + None + }, + Some, + ); + let vma_value = parse_hex_u64(parts[3]).map_or_else( + |()| { + warnings.push(format!( + "line {line_number}: invalid section VMA '{}'", + parts[3] + )); + None + }, + Some, + ); + + Some(ObjdumpSection { + index, + name: parts[1].to_owned(), + size: parts[2].to_owned(), + size_value, + vma: parts[3].to_owned(), + vma_value, + section_type: (parts.len() > 4).then(|| parts[4..].join(" ")), + raw: line.to_owned(), + line_number, + }) +} + +fn parse_symbol_label( + trimmed: &str, + raw: &str, + line_number: usize, + section: Option, +) -> Option { + if !(trimmed.ends_with(">:") && trimmed.contains('<')) { + return None; + } + let (address, rest) = trimmed.split_once('<')?; + let address = address.trim(); + let name = rest.trim_end_matches(">:").trim(); + if name.is_empty() { + return None; + } + + Some(DisassemblySymbol { + address: address.to_owned(), + address_value: parse_hex_u64(address).ok(), + name: name.to_owned(), + section, + instructions: Vec::new(), + raw: raw.to_owned(), + line_number, + }) +} + +fn looks_like_address_row(trimmed: &str) -> bool { + let Some((address, _)) = trimmed.split_once(':') else { + return false; + }; + !address.is_empty() && address.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn parse_instruction( + trimmed: &str, + raw: &str, + line_number: usize, + section: Option, + symbol: Option, +) -> Option { + let (address, rest) = trimmed.split_once(':')?; + if !address.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + let (bytes, byte_values, text) = parse_instruction_body(rest); + Some(Instruction { + address: address.to_owned(), + address_value: parse_hex_u64(address).ok(), + bytes, + byte_values, + text, + section, + symbol, + raw: raw.to_owned(), + line_number, + }) +} + +fn parse_instruction_body(rest: &str) -> (Vec, Vec, String) { + let mut bytes = Vec::new(); + let mut byte_values = Vec::new(); + let mut cursor = 0; + + while cursor < rest.len() { + cursor += rest[cursor..] + .bytes() + .take_while(u8::is_ascii_whitespace) + .count(); + if cursor >= rest.len() { + break; + } + + let token_start = cursor; + cursor += rest[cursor..] + .bytes() + .take_while(|byte| !byte.is_ascii_whitespace()) + .count(); + let token = &rest[token_start..cursor]; + if token.len() != 2 || !token.chars().all(|ch| ch.is_ascii_hexdigit()) { + cursor = token_start; + break; + } + + bytes.push(token.to_owned()); + if let Ok(value) = u8::from_str_radix(token, 16) { + byte_values.push(value); + } + } + + (bytes, byte_values, rest[cursor..].trim().to_owned()) +} + +fn update_address_range(report: &mut ObjdumpReport, instruction: &Instruction) { + let Some(address) = instruction.address_value else { + return; + }; + let row_end = address.saturating_add(instruction.byte_values.len() as u64); + match report.address_range.as_mut() { + Some(range) => { + range.start = range.start.min(address); + range.end = range.end.max(row_end); + range.instruction_count += 1; + range.byte_count += instruction.byte_values.len(); + } + None => { + report.address_range = Some(AddressRange { + start: address, + end: row_end, + instruction_count: 1, + byte_count: instruction.byte_values.len(), + }); + } + } +} + +fn is_source_or_line_hint(trimmed: &str) -> bool { + trimmed.starts_with(';') + || trimmed.starts_with("//") + || trimmed.starts_with('#') + || (looks_like_source_path(trimmed) + && trimmed + .rsplit_once(':') + .is_some_and(|(_, line)| line.chars().all(|ch| ch.is_ascii_digit()))) +} + +fn looks_like_source_path(trimmed: &str) -> bool { + trimmed.contains('/') + || trimmed.contains('\\') + || std::path::Path::new(trimmed) + .extension() + .is_some_and(|ext| { + ["c", "cc", "cpp", "h", "hpp", "rs"] + .iter() + .any(|expected| ext.eq_ignore_ascii_case(expected)) + }) +} + +fn warn( + result: &mut ObjdumpParseResult, + label: &str, + line: &str, + line_number: usize, + warning: String, +) { + result.warnings.push(warning.clone()); + result.raw_lines.push(line.to_owned()); + result.report.raw_blocks.push(ObjdumpRawBlock { + label: label.to_owned(), + text: line.to_owned(), + line_number, + warning, + }); +} + +fn parse_hex_u64(text: &str) -> Result { + let digits = text + .strip_prefix("0x") + .or_else(|| text.strip_prefix("0X")) + .unwrap_or(text); + if digits.is_empty() { + return Err(()); + } + u64::from_str_radix(digits, 16).map_err(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_objdump() -> &'static str { + r" +sample.obj: file format coff-x86-64 +architecture: x86_64 +start address: 0x0000000140001000 + +Sections: +Idx Name Size VMA Type + 0 .text 00000007 0000000140001000 TEXT + 1 .rdata 00000010 0000000140002000 DATA + +Disassembly of section .text: + +0000000140001000
: +140001000: 55 pushq %rbp +140001001: 48 89 e5 movq %rsp, %rbp +; C:\src\main.cpp:42 +140001004: e8 00 00 00 00 callq 0x140001009 + +0000000140001009 : +140001009: c3 retq +" + } + + #[test] + fn parses_headers_sections_symbols_and_instructions() { + let result = parse_objdump_output(sample_objdump()); + + assert_eq!(result.report.file.as_deref(), Some("sample.obj")); + assert_eq!(result.report.format.as_deref(), Some("coff-x86-64")); + assert_eq!(result.report.architecture.as_deref(), Some("x86_64")); + assert_eq!( + result.report.start_address.as_deref(), + Some("0x0000000140001000") + ); + assert_eq!(result.report.start_address_value, Some(0x0001_4000_1000)); + + assert_eq!(result.report.sections.len(), 2); + assert_eq!(result.report.sections[0].index, 0); + assert_eq!(result.report.sections[0].name, ".text"); + assert_eq!(result.report.sections[0].size, "00000007"); + assert_eq!(result.report.sections[0].size_value, Some(7)); + assert_eq!(result.report.sections[0].vma_value, Some(0x0001_4000_1000)); + assert_eq!( + result.report.sections[0].section_type.as_deref(), + Some("TEXT") + ); + + assert_eq!(result.report.symbols.len(), 2); + assert_eq!(result.report.symbols[0].name, "main"); + assert_eq!( + result.report.symbols[0].address_value, + Some(0x0001_4000_1000) + ); + assert_eq!(result.report.symbols[0].section.as_deref(), Some(".text")); + assert_eq!(result.report.symbols[0].instructions.len(), 3); + assert_eq!(result.report.symbols[1].name, "helper"); + assert_eq!(result.report.symbols[1].instructions.len(), 1); + + assert_eq!(result.report.instruction_count, 4); + assert_eq!(result.report.instructions[0].address, "140001000"); + assert_eq!(result.report.instructions[0].byte_values, vec![0x55]); + assert_eq!(result.report.instructions[0].text, "pushq %rbp"); + assert_eq!( + result.report.instructions[0].symbol.as_deref(), + Some("main") + ); + assert_eq!( + result.report.instructions[2].byte_values, + vec![0xe8, 0x00, 0x00, 0x00, 0x00] + ); + assert_eq!( + result.report.instructions[2].text, + "callq 0x140001009 " + ); + } + + #[test] + fn summarizes_instruction_address_range_and_preserves_source_lines() { + let result = parse_objdump(sample_objdump()); + + assert_eq!( + result.report.address_range, + Some(AddressRange { + start: 0x0001_4000_1000, + end: 0x0001_4000_100a, + instruction_count: 4, + byte_count: 10, + }) + ); + assert_eq!(result.report.raw_blocks.len(), 1); + assert_eq!(result.report.raw_blocks[0].label, "source"); + assert_eq!(result.report.raw_blocks[0].text, r"; C:\src\main.cpp:42"); + assert_eq!(result.report.raw_blocks[0].line_number, 16); + assert!( + result + .warnings + .iter() + .any(|warning| warning.contains("preserved source/line block")) + ); + } + + #[test] + fn preserves_malformed_rows_with_line_warnings() { + let input = r" +bad.exe: file format coff-i386 +architecture: i386 +start address: not_hex +Sections: +Idx Name Size VMA Type + 0 .text badsize 00401000 TEXT +random trailer that is not recognized +00401000: zz db 0 +"; + + let result = parse_objdump_output(input); + + assert_eq!(result.report.file.as_deref(), Some("bad.exe")); + assert_eq!(result.report.format.as_deref(), Some("coff-i386")); + assert_eq!(result.report.architecture.as_deref(), Some("i386")); + assert_eq!(result.report.start_address.as_deref(), Some("not_hex")); + assert_eq!(result.report.start_address_value, None); + assert_eq!(result.report.sections.len(), 1); + assert_eq!(result.report.sections[0].name, ".text"); + assert_eq!(result.report.sections[0].size_value, None); + assert_eq!(result.report.sections[0].vma_value, Some(0x0040_1000)); + assert!(result.report.instructions.is_empty()); + assert!( + result + .warnings + .iter() + .any(|warning| warning.contains("invalid start address")) + ); + assert!( + result + .warnings + .iter() + .any(|warning| warning.contains("invalid section size")) + ); + assert!( + result + .warnings + .iter() + .any(|warning| warning.contains("invalid instruction bytes")) + ); + assert!( + result + .raw_lines + .iter() + .any(|line| line == "random trailer that is not recognized") + ); + } +} diff --git a/crates/llvmtools/src/parse_readobj.rs b/crates/llvmtools/src/parse_readobj.rs new file mode 100644 index 0000000..9790e2c --- /dev/null +++ b/crates/llvmtools/src/parse_readobj.rs @@ -0,0 +1,874 @@ +//! Parser for high-value `llvm-readobj` text blocks. + +use serde::Serialize; + +/// Structured data extracted from `llvm-readobj` output. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ReadobjReport { + /// Value from the top-level `File:` line. + pub file: Option, + /// Value from the top-level `Format:` line. + pub format: Option, + /// Value from the top-level `Arch:` line. + pub arch: Option, + /// Value from the top-level `AddressSize:` line. + pub address_size: Option, + /// Convenience copy of `ImageFileHeader.Machine`. + pub machine: Option, + /// Convenience copy of `ImageFileHeader.SectionCount`. + pub section_count: Option, + /// High-value COFF image header fields. + pub image_file_header: Option, + /// Section summaries from the `Sections [` block. + pub sections: Vec, + /// Import libraries and symbols. + pub imports: Vec, + /// Export records parsed from export blocks. + pub exports: Vec, + /// Convenience count for CLI summaries. + pub export_count: usize, + /// Flat debug hints retained for compact text/JSON consumers. + pub debug_hints: Vec, + /// Structured debug directory and `CodeView` hints. + pub debug: Vec, + /// Raw top-level blocks not yet mapped to a structured model. + pub raw_blocks: Vec, +} + +/// High-value fields from an `ImageFileHeader` block. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ImageFileHeader { + /// `Machine` value. + pub machine: Option, + /// `SectionCount` value. + pub section_count: Option, + /// `TimeDateStamp` value. + pub time_date_stamp: Option, + /// `PointerToSymbolTable` value. + pub pointer_to_symbol_table: Option, + /// `SymbolCount` value. + pub symbol_count: Option, + /// `StringTableSize` value. + pub string_table_size: Option, + /// `OptionalHeaderSize` value. + pub optional_header_size: Option, + /// COFF characteristics listed by `llvm-readobj`. + pub characteristics: Vec, +} + +/// Summary for one COFF section. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ReadobjSection { + /// Section number. + pub number: usize, + /// Section name. + pub name: String, + /// Section size, preferring `Size` then common COFF size aliases. + pub size: Option, + /// Section address, preferring `Address` then common RVA aliases. + pub address: Option, + /// Backward-compatible copy of `VirtualSize`. + pub virtual_size: Option, + /// Backward-compatible copy of `VirtualAddress`. + pub virtual_address: Option, + /// Backward-compatible copy of `RawDataSize`. + pub raw_data_size: Option, +} + +/// Imported library and imported symbols. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ImportLibrary { + /// Library name, kept for the initial JSON contract. + pub name: String, + /// Library name using the explicit readobj parser vocabulary. + pub library: String, + /// Imported symbol names or ordinal hints. + pub symbols: Vec, +} + +/// Export symbol or export table hint. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct ExportSymbol { + /// Exported symbol name. + pub name: Option, + /// Export ordinal. + pub ordinal: Option, + /// Export RVA/address hint. + pub rva: Option, + /// Additional export fields preserved as text. + pub hints: Vec, +} + +/// Debug directory or `CodeView` hint. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub(crate) struct DebugHint { + /// Debug record kind, for example `CodeView`. + pub kind: Option, + /// PDB path or file name when present. + pub pdb: Option, + /// GUID/signature value when present. + pub guid: Option, + /// Age value when present. + pub age: Option, + /// Other debug fields preserved as text. + pub hints: Vec, +} + +/// Raw fallback block retained for future parser coverage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ReadobjRawBlock { + /// Block or line label. + pub label: String, + /// Raw text content. + pub text: String, +} + +/// Result of parsing `llvm-readobj` text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReadobjParseResult { + /// Structured report. + pub report: ReadobjReport, + /// Non-fatal parser warnings. + pub warnings: Vec, + /// Raw lines or blocks preserved for callers that expose fallback text separately. + pub raw_lines: Vec, +} + +/// Parses `llvm-readobj` text output. +#[must_use] +pub(crate) fn parse_readobj_output(text: &str) -> ReadobjParseResult { + let lines = text.lines().map(str::to_string).collect::>(); + let mut report = ReadobjReport::default(); + let mut warnings = Vec::new(); + let mut index = 0; + + while index < lines.len() { + let trimmed = lines[index].trim(); + if trimmed.is_empty() { + index += 1; + continue; + } + + if let Some((key, value)) = split_key_value(trimmed) { + match key { + "File" => report.file = Some(value.to_owned()), + "Format" => report.format = Some(value.to_owned()), + "Arch" => report.arch = Some(value.to_owned()), + "AddressSize" => report.address_size = Some(value.to_owned()), + _ => push_raw_line(&mut report, &mut warnings, key, trimmed), + } + index += 1; + continue; + } + + if let Some(label) = block_label(trimmed) { + let (block, next_index) = collect_block(&lines, index, &mut warnings); + match label { + "ImageFileHeader" => { + let header = parse_image_file_header(&block.inner, &mut warnings); + report.machine.clone_from(&header.machine); + report.section_count = header.section_count; + report.image_file_header = Some(header); + } + "Sections" => parse_sections(&block.inner, &mut report.sections, &mut warnings), + "ImportTable" | "Imports" | "DelayImportTable" | "DelayImports" => { + parse_imports(&block.inner, &mut report.imports, &mut warnings); + } + "ExportTable" | "Exports" => { + parse_exports(&block.inner, &mut report.exports, &mut warnings); + } + "DebugDirectory" | "DebugInfo" | "CodeView" | "CodeViewDebugInfo" => { + parse_debug(&block.inner, &mut report.debug, &mut warnings); + } + _ => push_raw_block(&mut report, &mut warnings, label, block.text), + } + index = next_index; + continue; + } + + push_raw_line(&mut report, &mut warnings, &line_label(trimmed), trimmed); + index += 1; + } + + report.export_count = report.exports.len(); + report.debug_hints = flatten_debug_hints(&report.debug); + let raw_lines = report + .raw_blocks + .iter() + .map(|block| block.text.clone()) + .collect(); + + ReadobjParseResult { + report, + warnings, + raw_lines, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TextBlock { + text: String, + inner: Vec, +} + +fn split_key_value(line: &str) -> Option<(&str, &str)> { + let (key, value) = line.split_once(':')?; + let key = key.trim(); + if key.is_empty() || key.contains(['{', '[', '}', ']']) { + return None; + } + Some((key, value.trim().trim_matches('"'))) +} + +fn block_label(line: &str) -> Option<&str> { + let delimiter = line.find(['{', '['])?; + let label = line[..delimiter].trim(); + if label.is_empty() || line[..delimiter].contains(':') { + return None; + } + Some(label) +} + +fn line_label(line: &str) -> String { + split_key_value(line) + .map_or(line, |(key, _value)| key) + .to_owned() +} + +fn collect_block(lines: &[String], start: usize, warnings: &mut Vec) -> (TextBlock, usize) { + let mut balance = 0i64; + let mut end = start; + + for (offset, line) in lines[start..].iter().enumerate() { + balance += delimiter_delta(line); + end = start + offset; + if balance <= 0 && offset > 0 { + break; + } + } + + if balance > 0 { + warnings.push(format!( + "unterminated block `{}`", + block_label(lines[start].trim()).unwrap_or("unknown") + )); + } + + let text = lines[start..=end].join("\n"); + let inner = if end > start { + lines[(start + 1)..end].to_vec() + } else { + Vec::new() + }; + + (TextBlock { text, inner }, end + 1) +} + +fn delimiter_delta(line: &str) -> i64 { + line.chars().fold(0, |delta, character| match character { + '{' | '[' => delta + 1, + '}' | ']' => delta - 1, + _ => delta, + }) +} + +fn parse_image_file_header(lines: &[String], warnings: &mut Vec) -> ImageFileHeader { + let mut header = ImageFileHeader::default(); + let mut in_characteristics = false; + + for line in lines { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if in_characteristics { + if trimmed.starts_with(']') { + in_characteristics = false; + } else { + header.characteristics.push(trimmed.to_owned()); + } + continue; + } + + if trimmed.starts_with("Characteristics") && trimmed.contains('[') { + in_characteristics = true; + continue; + } + + let Some((key, value)) = split_key_value(trimmed) else { + continue; + }; + + match key { + "Machine" => header.machine = Some(value.to_owned()), + "SectionCount" => header.section_count = parse_usize_field(key, value, warnings), + "TimeDateStamp" => header.time_date_stamp = Some(value.to_owned()), + "PointerToSymbolTable" => header.pointer_to_symbol_table = Some(value.to_owned()), + "SymbolCount" => header.symbol_count = parse_u64_field(key, value, warnings), + "StringTableSize" => header.string_table_size = parse_u64_field(key, value, warnings), + "OptionalHeaderSize" => { + header.optional_header_size = parse_u64_field(key, value, warnings); + } + _ => {} + } + } + + header +} + +fn parse_sections( + lines: &[String], + sections: &mut Vec, + warnings: &mut Vec, +) { + for block in collect_named_inner_blocks(lines, "Section") { + let fallback_number = sections.len() + 1; + let mut section = ReadobjSection { + number: fallback_number, + ..ReadobjSection::default() + }; + + for line in block { + let trimmed = line.trim(); + let Some((key, value)) = split_key_value(trimmed) else { + continue; + }; + match key { + "Name" => value.clone_into(&mut section.name), + "Number" => { + if let Some(number) = parse_usize_field(key, value, warnings) { + section.number = number; + } + } + "Size" => section.size = parse_u64_field(key, value, warnings), + "VirtualSize" => { + section.virtual_size = parse_u64_field(key, value, warnings); + if section.size.is_none() { + section.size = section.virtual_size; + } + } + "RawDataSize" | "RawSize" => { + section.raw_data_size = parse_u64_field(key, value, warnings); + if section.size.is_none() { + section.size = section.raw_data_size; + } + } + "Address" | "RVA" => section.address = parse_u64_field(key, value, warnings), + "VirtualAddress" => { + section.virtual_address = parse_u64_field(key, value, warnings); + if section.address.is_none() { + section.address = section.virtual_address; + } + } + _ => {} + } + } + sections.push(section); + } +} + +fn parse_imports(lines: &[String], imports: &mut Vec, warnings: &mut Vec) { + let mut current_library = None::; + let mut index = 0; + + while index < lines.len() { + let trimmed = lines[index].trim(); + if block_label(trimmed) == Some("Import") { + let (block, next_index) = collect_inner_block(lines, index); + let mut block_library = None::; + let mut symbol = None::; + + for line in block { + let trimmed = line.trim(); + let Some((key, value)) = split_key_value(trimmed) else { + continue; + }; + + match key { + "DLLName" | "Library" | "ImportName" | "Module" => { + block_library = Some(value.to_owned()); + } + "Name" | "Symbol" | "Function" | "HintName" => symbol = Some(value.to_owned()), + "Ordinal" => symbol = Some(format!("Ordinal: {value}")), + _ => {} + } + } + + if let Some(library) = block_library { + current_library = Some(library.clone()); + ensure_import_library(imports, &library); + } + + if let Some(symbol) = symbol { + add_import_symbol(imports, current_library.as_deref(), &symbol, warnings); + } + + index = next_index; + continue; + } + + if block_label(trimmed).is_some() || trimmed.starts_with('}') || trimmed.starts_with(']') { + index += 1; + continue; + } + + let Some((key, value)) = split_key_value(trimmed) else { + index += 1; + continue; + }; + + match key { + "Name" | "DLLName" | "Library" | "DLL" + if current_library.is_none() || is_library_name(value) => + { + current_library = Some(value.to_owned()); + ensure_import_library(imports, value); + } + "Symbol" | "Function" | "HintName" => { + add_import_symbol(imports, current_library.as_deref(), value, warnings); + } + _ => {} + } + index += 1; + } +} + +fn parse_exports(lines: &[String], exports: &mut Vec, warnings: &mut Vec) { + for block in collect_named_inner_blocks(lines, "Export") { + let mut export = ExportSymbol::default(); + for line in block { + let trimmed = line.trim(); + let Some((key, value)) = split_key_value(trimmed) else { + continue; + }; + match key { + "Name" | "ExportName" => export.name = Some(value.to_owned()), + "Ordinal" => export.ordinal = parse_u64_field(key, value, warnings), + "RVA" | "Address" | "Value" => { + export.rva = parse_u64_field(key, value, warnings); + } + _ => export.hints.push(format!("{key}: {value}")), + } + } + exports.push(export); + } +} + +fn parse_debug(lines: &[String], debug: &mut Vec, warnings: &mut Vec) { + let mut blocks = collect_named_inner_blocks(lines, "DebugEntry"); + blocks.extend(collect_named_inner_blocks(lines, "CodeView")); + + if blocks.is_empty() + && lines + .iter() + .any(|line| split_key_value(line.trim()).is_some()) + { + blocks.push(lines.iter().map(String::as_str).collect()); + } + + for block in blocks { + let mut hint = DebugHint::default(); + for line in block { + let trimmed = line.trim(); + let Some((key, value)) = split_key_value(trimmed) else { + continue; + }; + match key { + "Type" | "Kind" => hint.kind = Some(value.to_owned()), + "PDBFileName" | "PdbFileName" | "PDB" | "PDBPath" => { + hint.pdb = Some(value.to_owned()); + } + "FileName" if value.to_ascii_lowercase().ends_with(".pdb") => { + hint.pdb = Some(value.to_owned()); + } + "GUID" | "Guid" | "Signature" => hint.guid = Some(value.to_owned()), + "Age" => hint.age = parse_u64_field(key, value, warnings), + _ => hint.hints.push(format!("{key}: {value}")), + } + } + debug.push(hint); + } +} + +fn collect_named_inner_blocks<'a>(lines: &'a [String], wanted_label: &str) -> Vec> { + let mut blocks = Vec::new(); + let mut index = 0; + + while index < lines.len() { + let trimmed = lines[index].trim(); + if block_label(trimmed) == Some(wanted_label) { + let (inner, next_index) = collect_inner_block(lines, index); + blocks.push(inner); + index = next_index; + } else { + index += 1; + } + } + + blocks +} + +fn collect_inner_block(lines: &[String], start: usize) -> (Vec<&str>, usize) { + let mut balance = 0i64; + let mut end = start; + + for (offset, line) in lines[start..].iter().enumerate() { + balance += delimiter_delta(line); + end = start + offset; + if balance <= 0 && offset > 0 { + break; + } + } + + let inner = if end > start { + lines[(start + 1)..end].iter().map(String::as_str).collect() + } else { + Vec::new() + }; + (inner, end + 1) +} + +fn ensure_import_library(imports: &mut Vec, library: &str) { + if imports + .iter() + .any(|entry| entry.name.eq_ignore_ascii_case(library)) + { + return; + } + imports.push(ImportLibrary { + name: library.to_owned(), + library: library.to_owned(), + symbols: Vec::new(), + }); +} + +fn add_import_symbol( + imports: &mut Vec, + library: Option<&str>, + symbol: &str, + warnings: &mut Vec, +) { + let Some(library) = library else { + warnings.push(format!("import symbol `{symbol}` has no library")); + return; + }; + + ensure_import_library(imports, library); + if let Some(entry) = imports + .iter_mut() + .find(|entry| entry.name.eq_ignore_ascii_case(library)) + { + entry.symbols.push(symbol.to_owned()); + } +} + +fn is_library_name(value: &str) -> bool { + let token = value.split_whitespace().next().unwrap_or(value); + std::path::Path::new(token) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("dll") + || extension.eq_ignore_ascii_case("exe") + || extension.eq_ignore_ascii_case("sys") + }) +} + +fn parse_usize_field(field: &str, value: &str, warnings: &mut Vec) -> Option { + parse_u64_field(field, value, warnings).and_then(|number| match usize::try_from(number) { + Ok(value) => Some(value), + Err(_error) => { + warnings.push(format!("numeric field `{field}` is too large: `{value}`")); + None + } + }) +} + +fn parse_u64_field(field: &str, value: &str, warnings: &mut Vec) -> Option { + let parsed = parse_u64(value); + if parsed.is_none() { + warnings.push(format!( + "could not parse numeric field `{field}` from `{value}`" + )); + } + parsed +} + +fn parse_u64(value: &str) -> Option { + let token = value + .trim() + .trim_matches(|character| character == '(' || character == ')') + .split_whitespace() + .next()?; + let token = token.trim_end_matches(','); + token + .strip_prefix("0x") + .or_else(|| token.strip_prefix("0X")) + .map_or_else( + || token.parse::().ok(), + |hex| u64::from_str_radix(hex, 16).ok(), + ) +} + +fn push_raw_line(report: &mut ReadobjReport, warnings: &mut Vec, label: &str, text: &str) { + warnings.push(format!("unparsed llvm-readobj line: {text}")); + report.raw_blocks.push(ReadobjRawBlock { + label: label.to_owned(), + text: text.to_owned(), + }); +} + +fn push_raw_block( + report: &mut ReadobjReport, + warnings: &mut Vec, + label: &str, + text: String, +) { + warnings.push(format!("unparsed llvm-readobj block: {label}")); + report.raw_blocks.push(ReadobjRawBlock { + label: label.to_owned(), + text, + }); +} + +fn flatten_debug_hints(debug: &[DebugHint]) -> Vec { + let mut hints = Vec::new(); + for item in debug { + if let Some(kind) = &item.kind { + hints.push(format!("Type: {kind}")); + } + if let Some(pdb) = &item.pdb { + hints.push(format!("PDB: {pdb}")); + } + if let Some(guid) = &item.guid { + hints.push(format!("GUID: {guid}")); + } + if let Some(age) = item.age { + hints.push(format!("Age: {age}")); + } + hints.extend(item.hints.iter().cloned()); + } + hints +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_report() -> &'static str { + r" +File: sample.obj +Format: COFF-x86-64 +Arch: x86_64 +AddressSize: 64bit +ImageFileHeader { + Machine: IMAGE_FILE_MACHINE_AMD64 (0x8664) + SectionCount: 3 + TimeDateStamp: 2026-05-11 10:20:30 (0x6821CAFE) + PointerToSymbolTable: 0x120 + SymbolCount: 18 + StringTableSize: 42 + OptionalHeaderSize: 0 + Characteristics [ (0x2022) + IMAGE_FILE_EXECUTABLE_IMAGE (0x2) + IMAGE_FILE_LARGE_ADDRESS_AWARE (0x20) + IMAGE_FILE_DLL (0x2000) + ] +} +Sections [ + Section { + Number: 1 + Name: .text + Size: 0x2A + Address: 0x140001000 + } + Section { + Number: 2 + Name: .rdata + VirtualSize: 96 + VirtualAddress: 0x140002000 + } +] +ImportTable { + Name: KERNEL32.dll + Import { + Name: GetLastError + } + Import { + Ordinal: 5 + } + DLLName: USER32.dll + Symbol: MessageBoxW +} +ExportTable { + Name: sample.dll + Export { + Ordinal: 1 + Name: Run + RVA: 0x1010 + } +} +DebugDirectory [ + DebugEntry { + Type: CodeView + PDBFileName: C:\build\sample.pdb + GUID: 00112233-4455-6677-8899-AABBCCDDEEFF + Age: 3 + } +] +LoadConfig { + GuardFlags: 0x100 +} +" + } + + #[test] + fn parses_top_level_header_and_image_file_header_fields() { + let parsed = parse_readobj_output(sample_report()); + let report = parsed.report; + assert_eq!(report.file.as_deref(), Some("sample.obj")); + assert_eq!(report.format.as_deref(), Some("COFF-x86-64")); + assert_eq!(report.arch.as_deref(), Some("x86_64")); + assert_eq!(report.address_size.as_deref(), Some("64bit")); + assert_eq!( + report.machine.as_deref(), + Some("IMAGE_FILE_MACHINE_AMD64 (0x8664)") + ); + assert_eq!(report.section_count, Some(3)); + + let Some(header) = report.image_file_header.as_ref() else { + panic!("missing image file header"); + }; + assert_eq!( + header.time_date_stamp.as_deref(), + Some("2026-05-11 10:20:30 (0x6821CAFE)") + ); + assert_eq!(header.pointer_to_symbol_table.as_deref(), Some("0x120")); + assert_eq!(header.symbol_count, Some(18)); + assert_eq!(header.string_table_size, Some(42)); + assert_eq!(header.optional_header_size, Some(0)); + assert_eq!( + header.characteristics, + vec![ + "IMAGE_FILE_EXECUTABLE_IMAGE (0x2)", + "IMAGE_FILE_LARGE_ADDRESS_AWARE (0x20)", + "IMAGE_FILE_DLL (0x2000)" + ] + ); + } + + #[test] + fn parses_sections_imports_exports_and_debug_hints() { + let parsed = parse_readobj_output(sample_report()); + let report = parsed.report; + + assert_eq!( + report.sections, + vec![ + ReadobjSection { + number: 1, + name: ".text".to_owned(), + size: Some(0x2A), + address: Some(0x0001_4000_1000), + virtual_size: None, + virtual_address: None, + raw_data_size: None, + }, + ReadobjSection { + number: 2, + name: ".rdata".to_owned(), + size: Some(96), + address: Some(0x0001_4000_2000), + virtual_size: Some(96), + virtual_address: Some(0x0001_4000_2000), + raw_data_size: None, + } + ] + ); + + assert_eq!( + report.imports, + vec![ + ImportLibrary { + name: "KERNEL32.dll".to_owned(), + library: "KERNEL32.dll".to_owned(), + symbols: vec!["GetLastError".to_owned(), "Ordinal: 5".to_owned()], + }, + ImportLibrary { + name: "USER32.dll".to_owned(), + library: "USER32.dll".to_owned(), + symbols: vec!["MessageBoxW".to_owned()], + } + ] + ); + assert_eq!(report.export_count, 1); + assert_eq!( + report.exports, + vec![ExportSymbol { + name: Some("Run".to_owned()), + ordinal: Some(1), + rva: Some(0x1010), + hints: Vec::new(), + }] + ); + assert_eq!( + report.debug, + vec![DebugHint { + kind: Some("CodeView".to_owned()), + pdb: Some(r"C:\build\sample.pdb".to_owned()), + guid: Some("00112233-4455-6677-8899-AABBCCDDEEFF".to_owned()), + age: Some(3), + hints: Vec::new(), + }] + ); + assert!(report.debug_hints.iter().any(|hint| hint.contains("PDB"))); + } + + #[test] + fn preserves_unknown_blocks_and_warns_on_malformed_numbers() { + let input = r" +File: odd.exe +Sections [ + Section { + Number: nope + Name: .bad + Size: 0xGG + } +] +MysteryBlock { + Alpha: Beta +} +Trailing: value +"; + + let parsed = parse_readobj_output(input); + assert_eq!(parsed.report.sections.len(), 1); + assert_eq!(parsed.report.sections[0].name, ".bad"); + assert_eq!(parsed.report.sections[0].number, 1); + assert_eq!(parsed.report.sections[0].size, None); + assert!( + parsed + .warnings + .iter() + .any(|warning| warning.contains("Number") && warning.contains("nope")) + ); + assert!( + parsed + .warnings + .iter() + .any(|warning| warning.contains("Size") && warning.contains("0xGG")) + ); + assert_eq!(parsed.report.raw_blocks.len(), 2); + assert_eq!(parsed.report.raw_blocks[0].label, "MysteryBlock"); + assert!(parsed.report.raw_blocks[0].text.contains("Alpha: Beta")); + assert_eq!(parsed.report.raw_blocks[1].label, "Trailing"); + assert_eq!(parsed.report.raw_blocks[1].text, "Trailing: value"); + assert_eq!( + parsed.raw_lines, + vec![ + "MysteryBlock {\n Alpha: Beta\n}".to_owned(), + "Trailing: value".to_owned() + ] + ); + } +} diff --git a/crates/llvmtools/src/pe_summary.rs b/crates/llvmtools/src/pe_summary.rs new file mode 100644 index 0000000..beb3732 --- /dev/null +++ b/crates/llvmtools/src/pe_summary.rs @@ -0,0 +1,284 @@ +//! Stable PE/COFF summary extracted with goblin. + +use std::fs; +use std::path::Path; + +use goblin::pe::{PE, header::machine_to_str, subsystem}; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct PeSummary { + pub machine: String, + pub architecture: String, + pub subsystem: String, + pub bitness: u8, + pub section_count: usize, + pub import_library_count: usize, + pub export_count: usize, + pub has_clr: bool, +} + +pub(crate) fn summarize_pe(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + let pe = PE::parse(&bytes).ok()?; + let machine = machine_to_str(pe.header.coff_header.machine).to_owned(); + Some(PeSummary { + architecture: architecture_from_machine(&machine).to_owned(), + machine, + subsystem: subsystem_label(pe.header.optional_header.as_ref()?.windows_fields.subsystem) + .to_owned(), + bitness: if pe.is_64 { 64 } else { 32 }, + section_count: pe.sections.len(), + import_library_count: pe.libraries.len(), + export_count: pe.exports.len(), + has_clr: pe.header.optional_header.as_ref().is_some_and(|optional| { + optional + .data_directories + .get_clr_runtime_header() + .is_some_and(|directory| directory.size > 0) + }), + }) +} + +const fn subsystem_label(value: u16) -> &'static str { + match value { + subsystem::IMAGE_SUBSYSTEM_UNKNOWN => "IMAGE_SUBSYSTEM_UNKNOWN", + subsystem::IMAGE_SUBSYSTEM_NATIVE => "IMAGE_SUBSYSTEM_NATIVE", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI => "IMAGE_SUBSYSTEM_WINDOWS_GUI", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI => "IMAGE_SUBSYSTEM_WINDOWS_CUI", + subsystem::IMAGE_SUBSYSTEM_OS2_CUI => "IMAGE_SUBSYSTEM_OS2_CUI", + subsystem::IMAGE_SUBSYSTEM_POSIX_CUI => "IMAGE_SUBSYSTEM_POSIX_CUI", + subsystem::IMAGE_SUBSYSTEM_NATIVE_WINDOWS => "IMAGE_SUBSYSTEM_NATIVE_WINDOWS", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_CE_GUI => "IMAGE_SUBSYSTEM_WINDOWS_CE_GUI", + subsystem::IMAGE_SUBSYSTEM_EFI_APPLICATION => "IMAGE_SUBSYSTEM_EFI_APPLICATION", + subsystem::IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER => { + "IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER" + } + subsystem::IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER => "IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER", + subsystem::IMAGE_SUBSYSTEM_EFI_ROM => "IMAGE_SUBSYSTEM_EFI_ROM", + subsystem::IMAGE_SUBSYSTEM_XBOX => "IMAGE_SUBSYSTEM_XBOX", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION => { + "IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION" + } + _ => "IMAGE_SUBSYSTEM_UNKNOWN_VALUE", + } +} + +fn architecture_from_machine(machine: &str) -> &'static str { + if machine.contains("x86-64") + || machine.contains("X86_64") + || machine.contains("AMD64") + || machine.eq_ignore_ascii_case("x86_64") + { + "x86_64" + } else if machine.contains("ARM64") { + "aarch64" + } else if machine.contains("i386") || machine.contains("I386") { + "x86" + } else { + "unknown" + } +} + +#[cfg(test)] +mod tests { + use super::{architecture_from_machine, subsystem_label, summarize_pe}; + use goblin::pe::subsystem; + + fn malformed_pe_missing_section_table() -> Vec { + let pe_offset = 0x80_usize; + let optional_size = 0xF0_u16; + let mut bytes = vec![0_u8; pe_offset + 4 + 20 + usize::from(optional_size)]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + + let coff = pe_offset + 4; + bytes[pe_offset..coff].copy_from_slice(b"PE\0\0"); + bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes()); + bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes()); + bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes()); + bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes()); + + let optional = coff + 20; + bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes()); + bytes[optional + 68..optional + 70] + .copy_from_slice(&subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI.to_le_bytes()); + bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes()); + bytes + } + + fn malformed_pe_with_data_directory_rva( + directory_index: usize, + rva: u32, + size: u32, + ) -> Vec { + let (mut bytes, _) = minimal_pe_with_one_section(); + let optional = 0x80_usize + 4 + 20; + let directory = optional + 112 + (directory_index * 8); + bytes[directory..directory + 4].copy_from_slice(&rva.to_le_bytes()); + bytes[directory + 4..directory + 8].copy_from_slice(&size.to_le_bytes()); + bytes + } + + fn malformed_pe_export_directory_with_bad_tables() -> Vec { + let (mut bytes, export_offset) = minimal_pe_with_one_section(); + let optional = 0x80_usize + 4 + 20; + bytes[optional + 112..optional + 116].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 116..optional + 120].copy_from_slice(&0x28_u32.to_le_bytes()); + bytes[export_offset + 12..export_offset + 16].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 16..export_offset + 20].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 20..export_offset + 24].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 24..export_offset + 28].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 28..export_offset + 32].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 32..export_offset + 36].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 36..export_offset + 40].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes + } + + fn minimal_pe_with_one_section() -> (Vec, usize) { + let pe_offset = 0x80_usize; + let optional_size = 0xF0_u16; + let section_size = 40_usize; + let section_raw_offset = pe_offset + 4 + 20 + usize::from(optional_size) + section_size; + let mut bytes = vec![0_u8; section_raw_offset + 0x200]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + + let coff = pe_offset + 4; + bytes[pe_offset..coff].copy_from_slice(b"PE\0\0"); + bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes()); + bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes()); + bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes()); + bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes()); + + let optional = coff + 20; + bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes()); + bytes[optional + 16..optional + 20].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 24..optional + 32].copy_from_slice(&0x1400_0000_u64.to_le_bytes()); + bytes[optional + 32..optional + 36].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 36..optional + 40].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[optional + 68..optional + 70] + .copy_from_slice(&subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI.to_le_bytes()); + bytes[optional + 80..optional + 84].copy_from_slice(&0x2000_u32.to_le_bytes()); + bytes[optional + 84..optional + 88].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes()); + + let section = optional + usize::from(optional_size); + bytes[section..section + 8].copy_from_slice(b".rdata\0\0"); + bytes[section + 8..section + 12].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[section + 12..section + 16].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[section + 16..section + 20].copy_from_slice(&0x200_u32.to_le_bytes()); + let section_raw_offset_u32 = + u32::try_from(section_raw_offset).expect("fixture raw offset fits u32"); + bytes[section + 20..section + 24].copy_from_slice(§ion_raw_offset_u32.to_le_bytes()); + (bytes, section_raw_offset) + } + + #[test] + fn recognizes_machine_names_and_subsystems() { + assert_eq!(architecture_from_machine("X86_64"), "x86_64"); + assert_eq!(architecture_from_machine("AMD64"), "x86_64"); + assert_eq!(architecture_from_machine("ARM64"), "aarch64"); + assert_eq!(architecture_from_machine("I386"), "x86"); + assert_eq!(architecture_from_machine("mystery"), "unknown"); + + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_UNKNOWN), + "IMAGE_SUBSYSTEM_UNKNOWN" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_NATIVE), + "IMAGE_SUBSYSTEM_NATIVE" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI), + "IMAGE_SUBSYSTEM_WINDOWS_GUI" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI), + "IMAGE_SUBSYSTEM_WINDOWS_CUI" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER), + "IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER" + ); + assert_eq!( + subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION), + "IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION" + ); + assert_eq!(subsystem_label(0xffff), "IMAGE_SUBSYSTEM_UNKNOWN_VALUE"); + } + + #[cfg(windows)] + #[test] + fn summarizes_current_test_binary_as_pe() { + let current = std::env::current_exe().expect("current exe"); + let summary = summarize_pe(¤t).expect("current test binary is PE"); + + assert_eq!(summary.bitness, 64); + assert!(!summary.machine.is_empty()); + assert!(!summary.architecture.is_empty()); + assert!(!summary.subsystem.is_empty()); + assert!(summary.section_count > 0); + } + + #[test] + fn non_pe_or_missing_files_return_none() { + let temp = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(temp.path(), b"not a pe").expect("write"); + assert_eq!(summarize_pe(temp.path()), None); + assert_eq!( + summarize_pe(std::path::Path::new("Z:/missing/not-pe.exe")), + None + ); + } + + #[test] + fn truncated_pe_headers_return_none_without_panicking() { + let temp = tempfile::NamedTempFile::new().expect("temp file"); + let mut bytes = vec![0_u8; 0x40]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + std::fs::write(temp.path(), bytes).expect("write"); + + assert_eq!(summarize_pe(temp.path()), None); + } + + #[test] + fn malformed_section_table_returns_none_without_panicking() { + let temp = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(temp.path(), malformed_pe_missing_section_table()).expect("write"); + + assert_eq!(summarize_pe(temp.path()), None); + } + + #[test] + fn malformed_import_or_resource_directory_returns_none_without_panicking() { + for directory_index in [1, 2] { + let temp = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write( + temp.path(), + malformed_pe_with_data_directory_rva(directory_index, 0x9000, 0x100), + ) + .expect("write"); + + assert_eq!(summarize_pe(temp.path()), None); + } + } + + #[test] + fn malformed_export_directory_stays_bounded_without_fake_exports() { + let temp = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(temp.path(), malformed_pe_export_directory_with_bad_tables()) + .expect("write"); + + let summary = summarize_pe(temp.path()).expect("summary"); + + assert_eq!(summary.section_count, 1); + assert_eq!(summary.export_count, 0); + assert_eq!(summary.import_library_count, 0); + } +} diff --git a/crates/llvmtools/tests/llvmtools_cli.rs b/crates/llvmtools/tests/llvmtools_cli.rs new file mode 100644 index 0000000..b5f90c8 --- /dev/null +++ b/crates/llvmtools/tests/llvmtools_cli.rs @@ -0,0 +1,260 @@ +//! Integration tests for the LLVM COFF helper commands. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::{TempDir, tempdir}; + +#[derive(Clone, Copy)] +struct ToolCase { + binary: &'static str, + backend: &'static str, +} + +const LLVM_TOOL_CASES: &[ToolCase] = &[ + ToolCase { + binary: "llvmreadobj", + backend: "llvm-readobj", + }, + ToolCase { + binary: "llvmobjdump", + backend: "llvm-objdump", + }, + ToolCase { + binary: "llvmnm", + backend: "llvm-nm", + }, +]; + +fn llvm_command(binary: &str) -> Command { + Command::cargo_bin(binary).expect("binary") +} + +#[test] +fn help_mentions_examples_backend_and_shared_output_flags() { + for case in LLVM_TOOL_CASES { + let mut command = llvm_command(case.binary); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Examples")) + .stdout(predicate::str::contains("--llvm-bin-dir")) + .stdout(predicate::str::contains("--llvm-arg")) + .stdout(predicate::str::contains("--json")) + .stdout(predicate::str::contains("--toon")); + } +} + +#[test] +fn json_output_exposes_common_top_level_contract() { + for case in LLVM_TOOL_CASES { + let backend_dir = fake_backend_dir(LLVM_TOOL_CASES); + let input_dir = tempdir().expect("input tempdir"); + let object = write_coff_placeholder(input_dir.path(), case.binary); + + let mut command = llvm_command(case.binary); + add_fake_backend(&mut command, backend_dir.path()); + let output = command + .arg("--json") + .arg(&object) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json = serde_json::from_slice::(&output).expect("json output"); + assert_common_json_contract(case.binary, &json); + assert!( + json["files"] + .as_array() + .is_some_and(|files| !files.is_empty()), + "{}: expected at least one file entry in {json}", + case.binary + ); + } +} + +#[test] +fn stdin_lines_feed_multiple_files() { + for case in LLVM_TOOL_CASES { + let backend_dir = fake_backend_dir(LLVM_TOOL_CASES); + let input_dir = tempdir().expect("input tempdir"); + let first = write_coff_placeholder(input_dir.path(), &format!("{}-first", case.binary)); + let second = write_coff_placeholder(input_dir.path(), &format!("{}-second", case.binary)); + let stdin = format!("{}\n{}\n", first.display(), second.display()); + + let mut command = llvm_command(case.binary); + add_fake_backend(&mut command, backend_dir.path()); + let output = command + .arg("--json") + .write_stdin(stdin) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json = serde_json::from_slice::(&output).expect("json output"); + assert_common_json_contract(case.binary, &json); + let files = json["files"].as_array().expect("files array"); + assert_eq!( + files.len(), + 2, + "{}: expected stdin line input to produce two file entries in {json}", + case.binary + ); + } +} + +#[test] +fn objdump_symbol_and_address_flags_are_recorded_in_backend_argv() { + let backend_dir = fake_backend_dir(LLVM_TOOL_CASES); + let input_dir = tempdir().expect("input tempdir"); + let object = write_coff_placeholder(input_dir.path(), "objdump-argv"); + + let mut command = llvm_command("llvmobjdump"); + add_fake_backend(&mut command, backend_dir.path()); + let output = command + .arg("--symbol") + .arg("main") + .arg("--start-address") + .arg("0x0") + .arg("--stop-address") + .arg("0x20") + .arg("--json") + .arg(&object) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json = serde_json::from_slice::(&output).expect("json output"); + let argv = json["backend"]["argv"] + .as_array() + .expect("backend argv") + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(argv.contains(&"--disassemble-symbols=main")); + assert!(argv.contains(&"--start-address=0x0")); + assert!(argv.contains(&"--stop-address=0x20")); +} + +#[test] +fn missing_backend_fails_with_actionable_message() { + for case in LLVM_TOOL_CASES { + let empty_backend_dir = tempdir().expect("backend tempdir"); + let input_dir = tempdir().expect("input tempdir"); + let object = write_coff_placeholder(input_dir.path(), case.binary); + + let mut command = llvm_command(case.binary); + command + .arg("--llvm-bin-dir") + .arg(empty_backend_dir.path()) + .arg(&object) + .assert() + .failure() + .stderr(predicate::str::contains(case.backend)) + .stderr(predicate::str::contains("--llvm-bin-dir")) + .stderr( + predicate::str::contains("backend") + .or(predicate::str::contains("LLVM")) + .or(predicate::str::contains("not found")), + ); + } +} + +fn assert_common_json_contract(binary: &str, json: &Value) { + assert!( + json.get("backend").is_some(), + "{binary}: missing top-level backend in {json}" + ); + assert!( + json.get("summary").is_some(), + "{binary}: missing top-level summary in {json}" + ); + assert!( + json.get("files").and_then(Value::as_array).is_some(), + "{binary}: missing top-level files array in {json}" + ); + assert!( + json.get("parse_warnings") + .and_then(Value::as_array) + .is_some(), + "{binary}: missing top-level parse_warnings array in {json}" + ); +} + +fn add_fake_backend(command: &mut Command, backend_dir: &Path) { + command.arg("--llvm-bin-dir").arg(backend_dir); + for arg in fake_backend_args() { + command.arg("--llvm-arg").arg(arg); + } +} + +fn fake_backend_dir(cases: &[ToolCase]) -> TempDir { + let dir = tempdir().expect("backend tempdir"); + let shell = host_shell(); + for case in cases { + for file_name in backend_file_names(case.backend) { + let destination = dir.path().join(file_name); + fs::copy(&shell, &destination).expect("copy fake backend executable"); + make_executable(&destination); + } + } + dir +} + +fn host_shell() -> PathBuf { + if cfg!(windows) { + env::var_os("COMSPEC").map_or_else( + || PathBuf::from(r"C:\Windows\System32\cmd.exe"), + PathBuf::from, + ) + } else { + PathBuf::from("/bin/sh") + } +} + +fn backend_file_names(backend: &str) -> Vec { + if cfg!(windows) { + vec![backend.to_owned(), format!("{backend}.exe")] + } else { + vec![backend.to_owned()] + } +} + +fn fake_backend_args() -> Vec<&'static str> { + if cfg!(windows) { + vec!["/D", "/S", "/C", "echo fake llvm coff output"] + } else { + vec!["-c", "printf 'fake llvm coff output\\n'"] + } +} + +fn write_coff_placeholder(dir: &Path, stem: &str) -> PathBuf { + let path = dir.join(format!("{stem}.obj")); + fs::write(&path, b"MZ fake COFF placeholder\n").expect("write object placeholder"); + path +} + +#[cfg(unix)] +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + let mut permissions = fs::metadata(path) + .expect("fake backend metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("fake backend permissions"); +} + +#[cfg(not(unix))] +const fn make_executable(_path: &Path) {} diff --git a/crates/logshape/Cargo.toml b/crates/logshape/Cargo.toml new file mode 100644 index 0000000..a8e2554 --- /dev/null +++ b/crates/logshape/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "logshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Summarize repetitive logs into high-signal templates." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/logshape/src/lib.rs b/crates/logshape/src/lib.rs new file mode 100644 index 0000000..9e907fb --- /dev/null +++ b/crates/logshape/src/lib.rs @@ -0,0 +1,931 @@ +//! The `logshape` command summarizes repetitive logs. + +use std::cmp::Reverse; +use std::collections::{HashMap, hash_map::Entry}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, BufRead, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const PLACEHOLDER_LEVEL: &str = ""; +const PLACEHOLDER_MILLISECONDS: &str = "ms"; +const PLACEHOLDER_PATH: &str = ""; +const PLACEHOLDER_RETRY_SECONDS: &str = "s"; +const PLACEHOLDER_TIMESTAMP: &str = ""; + +const HELP: &str = "\ +Summarize repetitive logs into high-signal templates. + +Usage: + logshape [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --top Maximum number of grouped patterns to emit + --min-count Minimum count required for a group to emit + --keep-level Preserve INFO/WARN/ERROR tokens in the pattern + -h, --help Show this help text + -V, --version Show the command version + +Examples: + logshape .\\fixtures\\logs\\repetitive.log + bat --style=plain --paging=never .\\fixtures\\logs\\repetitive.log | logshape --json | ConvertFrom-Json | Select-Object -ExpandProperty groups + logshape .\\fixtures\\logs\\repetitive.log --min-count 2 --top 5 +"; + +/// CLI arguments for the `logshape` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Maximum number of grouped patterns to emit. + top: usize, + /// Minimum number of matching lines required for a group to be emitted. + min_count: usize, + /// Preserve explicit INFO/WARN/ERROR tokens in the normalized pattern. + keep_level: bool, + /// Optional log files when stdin is empty. + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LogGroup { + pattern: String, + count: usize, + first_line: usize, + last_line: usize, + sample: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LogShapeSummary { + line_count: usize, + group_count: usize, + top: usize, + min_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LogShapeReport { + groups: Vec, + summary: LogShapeSummary, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GroupState { + count: usize, + first_line: usize, + last_line: usize, + sample: String, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("logshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + top: 10, + min_count: 1, + keep_level: false, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("top") => { + cli.top = parse_positive_usize_flag( + "--top", + &parser_value_string(&mut parser, "--top")?, + )?; + } + Long("min-count") => { + cli.min_count = parse_positive_usize_flag( + "--min-count", + &parser_value_string(&mut parser, "--min-count")?, + )?; + } + Long("keep-level") => cli.keep_level = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = parse_usize_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn run(cli: &Cli) -> Result { + let lines = load_lines(cli)?; + let groups = group_lines(&lines, cli.keep_level, cli.min_count, cli.top); + let report = build_report(lines.len(), groups, cli); + + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => { + if report.groups.is_empty() { + if !cli.common.quiet { + println!("0 groups"); + } + } else { + print!("{}", render_groups(&report.groups)); + } + } + } + + Ok(map_result_count(report.groups.len())) +} + +fn build_report(line_count: usize, groups: Vec, cli: &Cli) -> LogShapeReport { + LogShapeReport { + summary: LogShapeSummary { + line_count, + group_count: groups.len(), + top: cli.top, + min_count: cli.min_count, + }, + groups, + } +} + +fn load_lines(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.trim().is_empty() { + return parse_lines(&buffer, cli.common.input_format); + } + } + + if cli.paths.is_empty() { + return Err(CliError::usage( + "provide at least one log path or pipe log lines into stdin", + )); + } + + let mut lines = Vec::new(); + let paths = common::expand_input_patterns(&cli.paths, "logshape")?; + for path in &paths { + let content = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + lines.extend(parse_lines(&content, cli.common.input_format)?); + } + + Ok(lines) +} + +fn parse_lines(content: &str, input_format: InputFormat) -> Result, CliError> { + let mut lines = Vec::new(); + + for (index, line) in io::Cursor::new(content).lines().enumerate() { + let raw = + line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?; + let trimmed = raw.trim().trim_start_matches('\u{feff}'); + if trimmed.is_empty() { + continue; + } + if is_low_signal_separator_line(trimmed) { + continue; + } + + match input_format { + InputFormat::Lines | InputFormat::Auto => { + if let Some(cleaned) = normalize_reader_wrapped_line(trimmed) { + lines.push(cleaned); + } + } + InputFormat::Jsonl => { + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::runtime(format!("invalid JSONL log line at {}: {error}", index + 1)) + })?; + lines.push(json_line_value(&value, index + 1)?); + } + } + } + + Ok(lines) +} + +fn json_line_value(value: &Value, line_number: usize) -> Result { + match value { + Value::String(text) => Ok(text.clone()), + Value::Object(object) => object + .get("line") + .or_else(|| object.get("message")) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + CliError::runtime(format!( + "JSON log line at {line_number} must be a string or object with line/message" + )) + }), + Value::Array(_) | Value::Bool(_) | Value::Null | Value::Number(_) => { + Err(CliError::runtime(format!( + "JSON log line at {line_number} must be a string or object" + ))) + } + } +} + +fn normalize_reader_wrapped_line(line: &str) -> Option { + if is_mercury_reader_header(line) { + return None; + } + if let Some(stripped) = strip_numbered_reader_prefix(line) { + return Some(stripped.to_owned()); + } + Some(line.to_owned()) +} + +fn is_low_signal_separator_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.len() < 8 || trimmed.chars().any(|ch| ch.is_ascii_alphanumeric()) { + return false; + } + let mut chars = trimmed.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_punctuation() && chars.all(|ch| ch == first) +} + +fn is_mercury_reader_header(line: &str) -> bool { + line.starts_with("path=") + && line.contains(" lines=") + && (line.contains(" reason=") || line.contains(" chunk=")) +} + +fn strip_numbered_reader_prefix(line: &str) -> Option<&str> { + let bytes = line.as_bytes(); + let mut index = 0_usize; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if index == 0 || index + 1 >= bytes.len() || bytes[index] != b':' || bytes[index + 1] != b' ' { + return None; + } + Some(&line[index + 2..]) +} + +fn group_lines(lines: &[String], keep_level: bool, min_count: usize, top: usize) -> Vec { + let mut groups = HashMap::::with_capacity(lines.len()); + + for (index, line) in lines.iter().enumerate() { + let pattern = normalize_line(line, keep_level); + match groups.entry(pattern) { + Entry::Occupied(mut entry) => { + let group = entry.get_mut(); + group.count += 1; + group.last_line = index + 1; + } + Entry::Vacant(entry) => { + entry.insert(GroupState { + count: 1, + first_line: index + 1, + last_line: index + 1, + sample: line.clone(), + }); + } + } + } + + let mut rendered = groups + .into_iter() + .filter(|(_, group)| group.count >= min_count) + .map(|(pattern, group)| LogGroup { + pattern, + count: group.count, + first_line: group.first_line, + last_line: group.last_line, + sample: group.sample, + }) + .collect::>(); + + rendered.sort_unstable_by(|left, right| { + (Reverse(left.count), left.first_line, left.pattern.as_str()).cmp(&( + Reverse(right.count), + right.first_line, + right.pattern.as_str(), + )) + }); + rendered.truncate(top); + rendered +} + +fn normalize_line(line: &str, keep_level: bool) -> String { + let normalized = replace_timestamps(line); + let normalized = replace_windows_paths(&normalized); + let normalized = replace_named_numeric_fields(&normalized, "worker"); + let normalized = replace_named_numeric_fields(&normalized, "user"); + let normalized = replace_named_numeric_fields(&normalized, "id"); + let normalized = replace_generic_numeric_fields(&normalized); + let normalized = replace_millisecond_durations(&normalized); + let normalized = replace_retry_seconds(&normalized); + let normalized = replace_tcp_ports(&normalized); + if keep_level { + normalized + } else { + replace_levels(&normalized) + } +} + +fn replace_timestamps(input: &str) -> String { + replace_matches(input, |value, index| { + timestamp_match_len(value, index) + .map(|length| (index + length, PLACEHOLDER_TIMESTAMP.to_owned())) + }) +} + +fn replace_windows_paths(input: &str) -> String { + replace_matches(input, |value, index| { + windows_path_match_len(value, index) + .map(|length| (index + length, PLACEHOLDER_PATH.to_owned())) + }) +} + +fn replace_named_numeric_fields(input: &str, name: &str) -> String { + replace_matches(input, |value, index| { + named_numeric_field_match_len(value, index, name) + .map(|length| (index + length, format!("{name}="))) + }) +} + +fn replace_generic_numeric_fields(input: &str) -> String { + replace_matches(input, |value, index| { + generic_numeric_field_match(value, index).map(|(length, key_length)| { + ( + index + length, + format!("{}=", &value[index..index + key_length]), + ) + }) + }) +} + +fn replace_millisecond_durations(input: &str) -> String { + replace_matches(input, |value, index| { + millisecond_duration_match_len(value, index) + .map(|length| (index + length, PLACEHOLDER_MILLISECONDS.to_owned())) + }) +} + +fn replace_retry_seconds(input: &str) -> String { + replace_matches(input, |value, index| { + retry_seconds_match_len(value, index) + .map(|length| (index + length, PLACEHOLDER_RETRY_SECONDS.to_owned())) + }) +} + +fn replace_tcp_ports(input: &str) -> String { + replace_matches(input, |value, index| { + tcp_port_match_len(value, index).map(|length| { + let prefix_end = tcp_port_prefix_end(value, index); + ( + index + length, + format!("{}", &value[index..prefix_end]), + ) + }) + }) +} + +fn replace_levels(input: &str) -> String { + replace_matches(input, |value, index| { + level_match_len(value, index).map(|length| (index + length, PLACEHOLDER_LEVEL.to_owned())) + }) +} + +fn replace_matches( + input: &str, + mut replacer: impl FnMut(&str, usize) -> Option<(usize, String)>, +) -> String { + let mut output = String::with_capacity(input.len()); + let mut cursor = 0_usize; + let mut last_copied = 0_usize; + + while cursor < input.len() { + if let Some((end, replacement)) = replacer(input, cursor) { + output.push_str(&input[last_copied..cursor]); + output.push_str(&replacement); + last_copied = end; + cursor = end; + continue; + } + + cursor = next_char_boundary(input, cursor); + } + + if last_copied == 0 { + return input.to_owned(); + } + + output.push_str(&input[last_copied..]); + output +} + +fn next_char_boundary(input: &str, index: usize) -> usize { + input.get(index..).map_or(index, |suffix| { + suffix + .chars() + .next() + .map_or(input.len(), |value| index + value.len_utf8()) + }) +} + +fn timestamp_match_len(input: &str, start: usize) -> Option { + const TIMESTAMP_LEN: usize = 20; + let slice = input.get(start..start + TIMESTAMP_LEN)?; + let bytes = slice.as_bytes(); + let is_match = bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[4] == b'-' + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[7] == b'-' + && bytes[8..10].iter().all(u8::is_ascii_digit) + && bytes[10] == b'T' + && bytes[11..13].iter().all(u8::is_ascii_digit) + && bytes[13] == b':' + && bytes[14..16].iter().all(u8::is_ascii_digit) + && bytes[16] == b':' + && bytes[17..19].iter().all(u8::is_ascii_digit) + && bytes[19] == b'Z'; + if is_match && has_word_boundary(input, start, start + TIMESTAMP_LEN) { + Some(TIMESTAMP_LEN) + } else { + None + } +} + +fn windows_path_match_len(input: &str, start: usize) -> Option { + let bytes = input.as_bytes(); + let drive = *bytes.get(start)?; + if !drive.is_ascii_alphabetic() || bytes.get(start + 1).copied() != Some(b':') { + return None; + } + if bytes.get(start + 2).copied() != Some(b'\\') { + return None; + } + + let mut end = start + 3; + while let Some(byte) = bytes.get(end) { + if *byte == b' ' { + break; + } + end += 1; + } + + (end > start + 3).then_some(end - start) +} + +fn named_numeric_field_match_len(input: &str, start: usize, name: &str) -> Option { + let suffix = input.get(start..)?.strip_prefix(name)?.strip_prefix('=')?; + let digits = ascii_digit_prefix_len(suffix); + (digits > 0).then_some(name.len() + 1 + digits) +} + +fn generic_numeric_field_match(input: &str, start: usize) -> Option<(usize, usize)> { + if start > 0 && input[..start].chars().next_back().is_some_and(is_word_char) { + return None; + } + + let bytes = input.as_bytes(); + let first = *bytes.get(start)?; + if !(first == b'_' || first.is_ascii_alphabetic()) { + return None; + } + + let mut key_end = start + 1; + while let Some(byte) = bytes.get(key_end).copied() { + if byte == b'_' || byte == b'-' || byte.is_ascii_alphanumeric() { + key_end += 1; + } else { + break; + } + } + if bytes.get(key_end).copied() != Some(b'=') { + return None; + } + + let value_start = key_end + 1; + let digits = ascii_digit_prefix_len(input.get(value_start..)?); + if digits == 0 { + return None; + } + let end = value_start + digits; + if matches!(bytes.get(end).copied(), Some(b'.' | b':')) { + return None; + } + + has_word_boundary(input, start, end).then_some((end - start, key_end - start)) +} + +fn retry_seconds_match_len(input: &str, start: usize) -> Option { + let suffix = input.get(start..)?; + let digits = ascii_digit_prefix_len(suffix); + if digits == 0 || suffix.as_bytes().get(digits).copied() != Some(b's') { + return None; + } + + let end = start + digits + 1; + has_word_boundary(input, start, end).then_some(digits + 1) +} + +fn millisecond_duration_match_len(input: &str, start: usize) -> Option { + let suffix = input.get(start..)?; + let digits = ascii_digit_prefix_len(suffix); + if digits == 0 { + return None; + } + + let space_len = suffix[digits..] + .bytes() + .take_while(|byte| *byte == b' ') + .count(); + let unit_start = digits + space_len; + if !suffix[unit_start..].starts_with("ms") { + return None; + } + + let length = unit_start + 2; + has_word_boundary(input, start, start + length).then_some(length) +} + +fn tcp_port_match_len(input: &str, start: usize) -> Option { + let suffix = input.get(start..)?.strip_prefix("tcp://")?; + let host_len = suffix + .bytes() + .take_while(|byte| byte.is_ascii_digit() || *byte == b'.') + .count(); + if host_len == 0 || suffix.as_bytes().get(host_len).copied() != Some(b':') { + return None; + } + + let port_start = host_len + 1; + let port_len = ascii_digit_prefix_len(&suffix[port_start..]); + (port_len > 0).then_some("tcp://".len() + host_len + 1 + port_len) +} + +fn tcp_port_prefix_end(input: &str, start: usize) -> usize { + let suffix = input + .get(start..) + .unwrap_or_default() + .strip_prefix("tcp://") + .unwrap_or_default(); + let host_len = suffix + .bytes() + .take_while(|byte| byte.is_ascii_digit() || *byte == b'.') + .count(); + start + "tcp://".len() + host_len + 1 +} + +fn level_match_len(input: &str, start: usize) -> Option { + ["INFO", "WARN", "ERROR"] + .into_iter() + .find(|candidate| { + input.get(start..).is_some_and(|suffix| { + suffix.starts_with(candidate) + && has_word_boundary(input, start, start + candidate.len()) + }) + }) + .map(str::len) +} + +fn ascii_digit_prefix_len(input: &str) -> usize { + input.bytes().take_while(u8::is_ascii_digit).count() +} + +fn has_word_boundary(input: &str, start: usize, end: usize) -> bool { + let before_is_word = input[..start].chars().next_back().is_some_and(is_word_char); + let after_is_word = input[end..].chars().next().is_some_and(is_word_char); + before_is_word != input[start..end].chars().next().is_some_and(is_word_char) + && input[..end].chars().next_back().is_some_and(is_word_char) != after_is_word +} + +fn is_word_char(value: char) -> bool { + value == '_' || value.is_alphanumeric() +} + +fn render_groups(groups: &[LogGroup]) -> String { + let mut rendered = String::new(); + + for group in groups { + writeln!( + rendered, + "count={} first_line={} last_line={} pattern={} sample={}", + group.count, group.first_line, group.last_line, group.pattern, group.sample + ) + .expect("writing to a String cannot fail"); + } + + rendered +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn normalization_and_grouping_cover_high_signal_patterns() { + let normalized = normalize_line( + "2026-04-21T12:00:05Z ERROR worker=22 user=98 failed request id=992 path=C:\\games\\demo\\mods\\plugin.dll", + false, + ); + assert!(normalized.contains("")); + assert!(normalized.contains("worker=")); + assert!(normalized.contains("path=")); + assert!(normalized.contains("")); + + let duration = normalize_line("retry took 120ms then 125 ms", false); + assert_eq!(duration, "retry took ms then ms"); + let named_duration = normalize_line( + "INFO finished job duration_ms=141 rows=12 duration_ms=156", + false, + ); + assert_eq!( + named_duration, + " finished job duration_ms= rows= duration_ms=" + ); + + let groups = group_lines( + &[ + "2026-04-21T12:00:01Z INFO worker=12 user=42 connected to tcp://127.0.0.1:8080" + .to_string(), + "2026-04-21T12:00:02Z INFO worker=18 user=57 connected to tcp://127.0.0.1:8080" + .to_string(), + ], + true, + 1, + 10, + ); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].count, 2); + assert_eq!(groups[0].first_line, 1); + } + + #[test] + fn json_line_parsing_and_run_usage_errors_cover_helpers() { + assert_eq!( + parse_lines( + "{\"line\":\"hello\"}\n{\"message\":\"world\"}\n", + InputFormat::Jsonl, + ) + .expect("jsonl"), + vec!["hello".to_string(), "world".to_string()] + ); + assert!(json_line_value(&json!("plain"), 1).is_ok()); + + let error = parse_cli_from(["logshape", "--top", "0", "demo.log"]) + .expect_err("zero top should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("--top must be greater than 0") + )); + + let rendered = render_groups(&[LogGroup { + pattern: "demo".to_string(), + count: 2, + first_line: 1, + last_line: 2, + sample: "sample".to_string(), + }]); + assert!(rendered.contains("count=2")); + } + + #[test] + fn parsing_and_grouping_cover_json_errors_sorting_and_level_preservation() { + assert_eq!( + parse_lines("alpha\nbeta\n", InputFormat::Lines).expect("line input"), + vec!["alpha".to_string(), "beta".to_string()] + ); + assert_eq!( + parse_lines("\"hello\"\n", InputFormat::Jsonl).expect("json string"), + vec!["hello".to_string()] + ); + + let invalid_json = parse_lines("{\"other\":\"field\"}\n", InputFormat::Jsonl) + .expect_err("missing field should fail"); + assert!(matches!( + invalid_json, + CliError::Runtime(message) if message.contains("line/message") + )); + let invalid_value = json_line_value(&json!(3), 7).expect_err("number should fail"); + assert!(matches!( + invalid_value, + CliError::Runtime(message) if message.contains("must be a string or object") + )); + + let groups = group_lines( + &[ + "2026-04-21T12:00:01Z ERROR worker=12 failed".to_string(), + "2026-04-21T12:00:02Z WARN worker=12 failed".to_string(), + "2026-04-21T12:00:03Z ERROR worker=99 failed".to_string(), + ], + true, + 1, + 2, + ); + assert_eq!(groups.len(), 2); + assert!(groups[0].pattern.contains("ERROR")); + + let kept_level = normalize_line("2026-04-21T12:00:01Z INFO worker=1 connected", true); + assert!(kept_level.contains("INFO")); + } + + #[test] + fn parse_lines_strips_chunkcat_and_snip_wrappers_from_stdin_text() { + let parsed = parse_lines( + concat!( + "path=C:\\temp\\log.txt chunk=3 lines=24:25 count=2\n", + "24: [INFO] service started\n", + "25: [ERROR] worker=8 failed id=44\n", + "path=C:\\temp\\log.txt lines=40:41 reason=lines\n", + "40: [INFO] service started\n" + ), + InputFormat::Lines, + ) + .expect("wrapped lines"); + + assert_eq!( + parsed, + vec![ + "[INFO] service started".to_string(), + "[ERROR] worker=8 failed id=44".to_string(), + "[INFO] service started".to_string(), + ] + ); + } + + #[test] + fn load_lines_and_run_cover_file_errors_and_no_results() { + let temp = tempdir().expect("tempdir"); + let log_path = temp.path().join("unique.log"); + fs::write(&log_path, "alpha\nbeta\n").expect("fixture"); + + let exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + top: 5, + min_count: 2, + keep_level: false, + paths: vec![log_path], + }) + .expect("run should succeed"); + assert_eq!(exit, ExitCode::NoResults); + + let missing = load_lines(&Cli { + common: common_args(false, InputFormat::Auto), + top: 5, + min_count: 1, + keep_level: false, + paths: vec![temp.path().join("missing.log")], + }) + .expect_err("missing file should fail"); + assert!(matches!( + missing, + CliError::Runtime(message) if message.contains("failed to read") + )); + + assert!(render_groups(&[]).is_empty()); + } + + #[test] + fn build_report_wraps_groups_in_stable_json_shape() { + let report = build_report( + 3, + vec![LogGroup { + pattern: "demo".to_string(), + count: 2, + first_line: 1, + last_line: 2, + sample: "alpha".to_string(), + }], + &Cli { + common: common_args(true, InputFormat::Auto), + top: 5, + min_count: 1, + keep_level: false, + paths: Vec::new(), + }, + ); + + assert_eq!(report.summary.line_count, 3); + assert_eq!(report.summary.group_count, 1); + assert_eq!(report.groups[0].pattern, "demo"); + } +} diff --git a/crates/logshape/src/main.rs b/crates/logshape/src/main.rs new file mode 100644 index 0000000..33c56c3 --- /dev/null +++ b/crates/logshape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `logshape`. + +fn main() { + std::process::exit(logshape::main_entry()); +} diff --git a/crates/logshape/tests/logshape_cli.rs b/crates/logshape/tests/logshape_cli.rs new file mode 100644 index 0000000..c554f98 --- /dev/null +++ b/crates/logshape/tests/logshape_cli.rs @@ -0,0 +1,161 @@ +//! Integration tests for the `logshape` command. + +use std::fs; +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("logshape").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn fixture_relative(path: &str) -> PathBuf { + PathBuf::from("fixtures").join(path) +} + +fn fixture(path: &str) -> PathBuf { + workspace_root().join(fixture_relative(path)) +} + +fn relative_to_workspace(path: &str) -> String { + fixture_relative(path).display().to_string() +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn groups_repetitive_logs_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("logs/repetitive.log")) + .assert() + .success() + .stdout(predicate::str::contains("count=2")) + .stdout(predicate::str::contains( + "connected to tcp://127.0.0.1:", + )) + .stdout(predicate::str::contains( + "failed request id= path=", + )); +} + +#[test] +fn emits_json_groups_with_limits() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("--top") + .arg("2") + .arg("--min-count") + .arg("2") + .arg(fixture("logs/repetitive.log")) + .assert() + .success() + .stdout(predicate::str::contains("\"count\":2")) + .stdout(predicate::str::contains("\"pattern\"")) + .stdout(predicate::str::contains("\"sample\"")) + .stdout(predicate::str::contains("retry in").not()); +} + +#[test] +fn supports_powershell_pipeline_input() { + let binary = assert_cmd::cargo::cargo_bin("logshape"); + let input = relative_to_workspace("logs/repetitive.log"); + let script = format!( + "[System.IO.File]::ReadLines('{}') | & '{}' --json --top 1", + input, + binary.display() + ); + + let mut command = pwsh_command(script); + command + .current_dir(workspace_root()) + .assert() + .success() + .stdout(predicate::str::contains("\"count\":2")) + .stdout(predicate::str::contains("\"first_line\":1")); +} + +#[test] +fn utf8_bom_stdin_groups_first_line_with_following_lines() { + let mut command = cargo_command(); + command + .args(["--json", "--min-count", "2"]) + .write_stdin( + "\u{feff}INFO connected to tcp://127.0.0.1:5000\nINFO connected to tcp://127.0.0.1:5001\n", + ) + .assert() + .success() + .stdout(predicate::str::contains("\"count\":2")) + .stdout(predicate::str::contains("\"first_line\":1")); +} + +#[test] +fn utf8_bom_log_file_groups_first_line_with_following_lines() { + let root = tempdir().expect("tempdir"); + let log_path = root.path().join("bom.log"); + fs::write( + &log_path, + "\u{feff}INFO connected to tcp://127.0.0.1:5000\nINFO connected to tcp://127.0.0.1:5001\n", + ) + .expect("bom log"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("--min-count") + .arg("2") + .arg(&log_path) + .assert() + .success() + .stdout(predicate::str::contains("\"count\":2")) + .stdout(predicate::str::contains( + "connected to tcp://127.0.0.1:", + )); +} + +#[test] +fn invalid_utf8_log_file_reports_read_error_instead_of_grouping() { + let root = tempdir().expect("tempdir"); + let log_path = root.path().join("invalid.log"); + fs::write(&log_path, b"INFO ok\n\xff\xfe\xfd\n").expect("invalid log"); + + let mut command = cargo_command(); + command + .arg(&log_path) + .assert() + .failure() + .stderr(predicate::str::contains("failed to read")) + .stderr(predicate::str::contains("valid UTF-8")); +} + +#[test] +fn help_includes_grouping_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--min-count")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains( + "logshape .\\fixtures\\logs\\repetitive.log", + )); +} diff --git a/crates/managed/Cargo.toml b/crates/managed/Cargo.toml new file mode 100644 index 0000000..4ce13f9 --- /dev/null +++ b/crates/managed/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "managed" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared managed assembly metadata helpers for Mercury Toolbox." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +clrmeta.workspace = true +goblin.workspace = true +regex-lite.workspace = true +serde.workspace = true +thiserror.workspace = true diff --git a/crates/managed/src/flow.rs b/crates/managed/src/flow.rs new file mode 100644 index 0000000..94846c1 --- /dev/null +++ b/crates/managed/src/flow.rs @@ -0,0 +1,1471 @@ +//! Shared managed IL flow helpers. + +use std::path::PathBuf; + +use clrmeta::{FieldSig, MethodSig, TableId}; +use regex_lite::Regex; +use serde::Serialize; + +use super::*; + +/// Query settings for managed method discovery. +#[derive(Debug, Clone, Default)] +pub struct MethodQuery { + /// Optional regex matched against the method name, declaring type, qualified name, or signature. + pub match_pattern: Option, + /// Optional regex matched only against the declaring type full name. + pub type_pattern: Option, + /// Optional regex matched only against the rendered method signature. + pub signature_pattern: Option, + /// Maximum number of rows to return. + pub limit: Option, +} + +/// Exact method target settings used by `asmflow body` and `asmflow xref`. +#[derive(Debug, Clone)] +pub struct MethodTargetQuery { + /// Exact declaring type full name. + pub type_name: String, + /// Exact method name. + pub method_name: String, + /// Optional regex used to disambiguate overloads by rendered signature. + pub signature_pattern: Option, +} + +/// Cross-reference traversal direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum XrefDirection { + /// Report callers of the target method. + Callers, + /// Report callees from the target method body. + Callees, + /// Report both callers and callees. + Both, +} + +/// One discovered managed method. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedMethodDescriptor { + /// Assembly file path. + pub assembly_path: PathBuf, + /// Simple assembly name. + pub assembly_name: String, + /// Declaring type full name. + pub type_name: String, + /// Method name. + pub name: String, + /// `Type::Method` form used by the CLI. + pub qualified_name: String, + /// Human-readable method signature. + pub signature: String, + /// Visibility label. + pub visibility: String, + /// Whether the method is static. + pub is_static: bool, + /// Whether the method is virtual. + pub is_virtual: bool, + /// Whether the method is abstract. + pub is_abstract: bool, + /// Metadata token for the method. + pub metadata_token: u32, + /// Method RVA when present. + pub rva: u32, + /// IL instruction count for the method body. + pub instruction_count: usize, + /// Method-reference count found in the body. + pub call_count: usize, + /// User-string literal count found in the body. + pub string_literal_count: usize, +} + +/// One decoded IL instruction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedInstruction { + /// Byte offset within the method body. + pub offset: usize, + /// Opcode name. + pub opcode: String, + /// Rendered operand, when the opcode carries one. + pub operand: Option, +} + +/// One resolved method reference. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedMethodReference { + /// Metadata token for the referenced method. + pub metadata_token: u32, + /// `Type::Method` style name when the declaring type is known. + pub qualified_name: String, + /// Rendered referenced signature. + pub signature: String, + /// Metadata table used to resolve the reference. + pub source_table: String, + /// Whether the reference points outside the current assembly's `MethodDef` table. + pub external: bool, +} + +/// One resolved field reference. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedFieldReference { + /// Metadata token for the referenced field. + pub metadata_token: u32, + /// `Type::Field` style name when the declaring type is known. + pub qualified_name: String, + /// Rendered field signature. + pub signature: String, + /// Metadata table used to resolve the reference. + pub source_table: String, + /// Whether the reference points outside the current assembly's `Field` table. + pub external: bool, +} + +/// Full decoded report for one managed method body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedMethodBodyReport { + /// Method metadata and summary counters. + pub method: ManagedMethodDescriptor, + /// Decoded IL instructions. + pub instructions: Vec, + /// Resolved method references reached from call-like opcodes. + pub calls: Vec, + /// Resolved method references reached from delegate-target opcodes such as `ldftn`. + pub delegate_targets: Vec, + /// Resolved field reads reached from load-like field opcodes. + pub field_reads: Vec, + /// Resolved field writes reached from store-like field opcodes. + pub field_writes: Vec, + /// Resolved user-string literals in encounter order. + pub string_literals: Vec, +} + +/// Cross-reference report for one target method. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedXrefReport { + /// The resolved target method. + pub target: ManagedMethodDescriptor, + /// Base or override family members considered equivalent for virtual-dispatch matching. + pub virtual_targets: Vec, + /// Methods that call the target. + pub callers: Vec, + /// Methods reached from the target body. + pub callees: Vec, + /// Delegate or thread target hookups reached from the target body. + pub delegate_targets: Vec, +} + +/// Lists managed methods for one or more assembly paths. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when an assembly cannot be read or parsed. +pub fn find_methods( + paths: &[PathBuf], + query: &MethodQuery, +) -> Result, ManagedError> { + let mut rows = Vec::new(); + for path in paths { + let assembly = ManagedAssembly::load(path)?; + rows.extend( + assembly + .matching_methods(query)? + .into_iter() + .map(|identity| identity.descriptor), + ); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + if let Some(limit) = query.limit { + rows.truncate(limit); + } + break; + } + } + Ok(rows) +} + +/// Decodes one managed method body from one or more assembly paths. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when the target cannot be resolved uniquely or when the method body +/// cannot be decoded. +pub fn inspect_method_body( + paths: &[PathBuf], + target: &MethodTargetQuery, +) -> Result { + let identity = resolve_target(paths, target)?; + let assembly = ManagedAssembly::load(&identity.assembly_path)?; + assembly + .decode_method_body(identity.method_index)? + .map(DecodedMethodBody::into_report) + .ok_or_else(|| ManagedError::Query { + message: format!( + "method {} in {} does not contain an IL body", + identity.descriptor.qualified_name, + identity.assembly_path.display() + ), + }) +} + +/// Computes callers or callees for one managed method. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when the target cannot be resolved uniquely or when an assembly cannot +/// be decoded. +pub fn inspect_method_xref( + paths: &[PathBuf], + target: &MethodTargetQuery, + direction: XrefDirection, + include_virtual_targets: bool, +) -> Result { + let identity = resolve_target(paths, target)?; + let virtual_targets = if include_virtual_targets { + collect_virtual_targets(paths, &identity)? + } else { + vec![identity.descriptor.clone()] + }; + let mut caller_methods = Vec::new(); + let mut callee_references = Vec::new(); + let mut delegate_target_references = Vec::new(); + + if matches!(direction, XrefDirection::Callers | XrefDirection::Both) { + for path in paths { + let assembly = ManagedAssembly::load(path)?; + let Some(last_method_index) = u32::try_from(assembly.metadata.method_defs.len()).ok() + else { + continue; + }; + for method_index in 1..=last_method_index { + let Some(body) = assembly.decode_method_body(method_index)? else { + continue; + }; + if body + .calls + .iter() + .chain(body.delegate_targets.iter()) + .any(|reference| { + virtual_targets + .iter() + .any(|candidate| method_reference_matches(reference, candidate)) + }) + { + caller_methods.push(body.descriptor); + } + } + } + caller_methods.sort_by(|left, right| { + ( + left.assembly_path.as_os_str(), + left.type_name.as_str(), + left.name.as_str(), + left.signature.as_str(), + ) + .cmp(&( + right.assembly_path.as_os_str(), + right.type_name.as_str(), + right.name.as_str(), + right.signature.as_str(), + )) + }); + caller_methods.dedup_by(|left, right| { + left.assembly_path == right.assembly_path + && left.qualified_name == right.qualified_name + && left.signature == right.signature + }); + } + + if matches!(direction, XrefDirection::Callees | XrefDirection::Both) { + let assembly = ManagedAssembly::load(&identity.assembly_path)?; + if let Some(body) = assembly.decode_method_body(identity.method_index)? { + callee_references = body.calls; + delegate_target_references = body.delegate_targets; + } + sort_and_dedup_method_references(&mut callee_references); + sort_and_dedup_method_references(&mut delegate_target_references); + } + + Ok(ManagedXrefReport { + target: identity.descriptor, + virtual_targets, + callers: caller_methods, + callees: callee_references, + delegate_targets: delegate_target_references, + }) +} + +fn sort_and_dedup_method_references(references: &mut Vec) { + references.sort_by(|left, right| { + ( + left.external, + left.qualified_name.as_str(), + left.signature.as_str(), + left.source_table.as_str(), + left.metadata_token, + ) + .cmp(&( + right.external, + right.qualified_name.as_str(), + right.signature.as_str(), + right.source_table.as_str(), + right.metadata_token, + )) + }); + references.dedup_by(|left, right| { + left.external == right.external + && left.qualified_name == right.qualified_name + && left.signature == right.signature + && left.source_table == right.source_table + && left.metadata_token == right.metadata_token + }); +} + +impl MethodTargetQuery { + /// Parses the CLI `Type::Method` target form. + /// + /// # Errors + /// + /// Returns [`ManagedError::Query`] when the input does not contain both type and method parts. + pub fn parse(target: &str, signature_pattern: Option) -> Result { + let Some((type_name, method_name)) = target.rsplit_once("::") else { + return Err(ManagedError::Query { + message: format!("invalid method target '{target}'; expected Type::Method"), + }); + }; + if type_name.is_empty() || method_name.is_empty() { + return Err(ManagedError::Query { + message: format!("invalid method target '{target}'; expected Type::Method"), + }); + } + Ok(Self { + type_name: type_name.to_string(), + method_name: method_name.to_string(), + signature_pattern, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MethodIdentity { + assembly_path: PathBuf, + method_index: u32, + descriptor: ManagedMethodDescriptor, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DecodedMethodBody { + descriptor: ManagedMethodDescriptor, + instructions: Vec, + calls: Vec, + delegate_targets: Vec, + field_reads: Vec, + field_writes: Vec, + string_literals: Vec, +} + +impl DecodedMethodBody { + fn into_report(self) -> ManagedMethodBodyReport { + ManagedMethodBodyReport { + method: self.descriptor, + instructions: self.instructions, + calls: self.calls, + delegate_targets: self.delegate_targets, + field_reads: self.field_reads, + field_writes: self.field_writes, + string_literals: self.string_literals, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OperandKind { + InlineNone, + ShortInlineI, + ShortInlineVar, + InlineVar, + InlineI, + InlineI8, + ShortInlineR, + InlineR, + ShortInlineBrTarget, + InlineBrTarget, + InlineSwitch, + InlineMethod, + InlineSig, + InlineType, + InlineString, + InlineField, + InlineTok, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct OpcodeInfo { + name: &'static str, + operand: OperandKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MethodBodyHeader { + code_offset: usize, + code_size: usize, +} + +impl ManagedAssembly { + fn virtual_family_methods( + &self, + target: &ManagedMethodDescriptor, + ) -> Result, ManagedError> { + let mut rows = Vec::new(); + let Some(last_method_index) = u32::try_from(self.metadata.method_defs.len()).ok() else { + return Ok(rows); + }; + for method_index in 1..=last_method_index { + let Some(base_descriptor) = self.method_descriptor_base(method_index) else { + continue; + }; + if base_descriptor.name != target.name || base_descriptor.signature != target.signature + { + continue; + } + if !self.type_names_related(&base_descriptor.type_name, &target.type_name) { + continue; + } + let descriptor = self + .decode_method_body(method_index)? + .map(|body| body.descriptor) + .unwrap_or(base_descriptor); + rows.push(descriptor); + } + Ok(rows) + } + + fn matching_methods(&self, query: &MethodQuery) -> Result, ManagedError> { + let mut rows = Vec::new(); + let Some(last_method_index) = u32::try_from(self.metadata.method_defs.len()).ok() else { + return Ok(rows); + }; + for method_index in 1..=last_method_index { + let Some(base_descriptor) = self.method_descriptor_base(method_index) else { + continue; + }; + if !method_matches(&base_descriptor, query) { + continue; + } + let descriptor = self + .decode_method_body(method_index)? + .map(|body| body.descriptor) + .unwrap_or(base_descriptor); + rows.push(MethodIdentity { + assembly_path: self.path.clone(), + method_index, + descriptor, + }); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + break; + } + } + Ok(rows) + } + + fn exact_method_matches( + &self, + target: &MethodTargetQuery, + ) -> Result, ManagedError> { + let mut rows = Vec::new(); + let Some(last_method_index) = u32::try_from(self.metadata.method_defs.len()).ok() else { + return Ok(rows); + }; + for method_index in 1..=last_method_index { + let Some(base_descriptor) = self.method_descriptor_base(method_index) else { + continue; + }; + let base_name_pair = (&base_descriptor.type_name, &base_descriptor.name); + let target_name_pair = (&target.type_name, &target.method_name); + if base_name_pair != target_name_pair { + continue; + } + if let Some(pattern) = &target.signature_pattern + && !pattern.is_match(&base_descriptor.signature) + { + continue; + } + let descriptor = self + .decode_method_body(method_index)? + .map(|body| body.descriptor) + .unwrap_or(base_descriptor); + rows.push(MethodIdentity { + assembly_path: self.path.clone(), + method_index, + descriptor, + }); + } + Ok(rows) + } + + fn type_names_related(&self, left: &str, right: &str) -> bool { + left == right + || self.type_inherits_from(left, right) + || self.type_inherits_from(right, left) + } + + fn type_inherits_from(&self, descendant_name: &str, ancestor_name: &str) -> bool { + let mut current = descendant_name.to_string(); + loop { + let Some(type_index) = self.type_index_by_name(¤t) else { + return false; + }; + let Some(base_type) = self.metadata.get_base_type(type_index) else { + return false; + }; + let base_name = self.resolved_type_name(&base_type); + if base_name == ancestor_name { + return true; + } + current = base_name; + } + } + + fn type_index_by_name(&self, full_name: &str) -> Option { + self.metadata + .type_defs + .iter() + .enumerate() + .find_map(|(index, _)| { + let type_index = u32::try_from(index + 1).ok()?; + (self.full_type_name(type_index) == full_name).then_some(type_index) + }) + } + + fn method_descriptor_base(&self, method_index: u32) -> Option { + let row = method_row(&self.metadata, method_index)?; + let (type_index, _) = self.metadata.get_method_owner(method_index)?; + let type_name = self.full_type_name(type_index); + let name = self.string_or_empty(row.name); + let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; + let signature = MethodSig::parse_blob(signature_bytes).ok()?; + let return_type = self.format_type_sig(&signature.return_type); + let parameters = self.method_parameters(method_index, &signature); + let signature_text = format_method_signature(&name, &return_type, ¶meters); + + Some(ManagedMethodDescriptor { + assembly_path: self.descriptor.path.clone(), + assembly_name: self.descriptor.assembly_name.clone(), + type_name: type_name.clone(), + name: name.clone(), + qualified_name: format!("{type_name}::{name}"), + signature: signature_text, + visibility: member_visibility_label(row.flags).to_string(), + is_static: (row.flags & 0x0010) != 0, + is_virtual: (row.flags & 0x0040) != 0, + is_abstract: (row.flags & 0x0400) != 0, + metadata_token: metadata_token(TableId::MethodDef, method_index), + rva: row.rva, + instruction_count: 0, + call_count: 0, + string_literal_count: 0, + }) + } + + fn decode_method_body( + &self, + method_index: u32, + ) -> Result, ManagedError> { + let Some(descriptor) = self.method_descriptor_base(method_index) else { + return Ok(None); + }; + if descriptor.rva == 0 { + return Ok(None); + } + let header = self.parse_method_body_header(descriptor.rva)?; + let code = self + .bytes_at_rva( + descriptor.rva + u32::try_from(header.code_offset).unwrap_or(0), + header.code_size, + ) + .ok_or_else(|| ManagedError::Metadata { + path: self.path.clone(), + message: format!( + "method body bytes are out of range for token 0x{:08x}", + descriptor.metadata_token + ), + })?; + + let mut instructions = Vec::new(); + let mut calls = Vec::new(); + let mut delegate_targets = Vec::new(); + let mut field_reads = Vec::new(); + let mut field_writes = Vec::new(); + let mut string_literals = Vec::new(); + let mut cursor = 0_usize; + + while cursor < code.len() { + let offset = cursor; + let (opcode_value, opcode_size) = + read_opcode(code, cursor).ok_or_else(|| ManagedError::Metadata { + path: self.path.clone(), + message: format!( + "truncated opcode at offset {offset} for token 0x{:08x}", + descriptor.metadata_token + ), + })?; + cursor += opcode_size; + let Some(info) = opcode_info(opcode_value) else { + return Err(ManagedError::Metadata { + path: self.path.clone(), + message: format!( + "unsupported IL opcode 0x{:04x} at offset {offset} for token 0x{:08x}", + opcode_hex_value(opcode_value), + descriptor.metadata_token + ), + }); + }; + let operand = self.decode_operand( + info, + info.name, + code, + &mut cursor, + &mut calls, + &mut delegate_targets, + &mut field_reads, + &mut field_writes, + &mut string_literals, + )?; + instructions.push(ManagedInstruction { + offset, + opcode: info.name.to_string(), + operand, + }); + } + + let mut descriptor = descriptor; + descriptor.instruction_count = instructions.len(); + descriptor.call_count = calls.len(); + descriptor.string_literal_count = string_literals.len(); + + Ok(Some(DecodedMethodBody { + descriptor, + instructions, + calls, + delegate_targets, + field_reads, + field_writes, + string_literals, + })) + } + + fn parse_method_body_header(&self, rva: u32) -> Result { + let bytes = self + .bytes_at_rva(rva, 12) + .ok_or_else(|| ManagedError::Metadata { + path: self.path.clone(), + message: format!("method body RVA 0x{rva:08x} is out of range"), + })?; + let first = bytes[0]; + match first & 0x3 { + 0x2 => Ok(MethodBodyHeader { + code_offset: 1, + code_size: usize::from(first >> 2), + }), + 0x3 => { + let flags_and_size = u16::from_le_bytes([bytes[0], bytes[1]]); + let header_dwords = usize::from(flags_and_size >> 12); + if header_dwords < 3 { + return Err(ManagedError::Metadata { + path: self.path.clone(), + message: format!( + "invalid fat method header size {header_dwords} at RVA 0x{rva:08x}" + ), + }); + } + let code_size = + usize::try_from(u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]])) + .map_err(|_| ManagedError::Metadata { + path: self.path.clone(), + message: format!("method body size overflow at RVA 0x{rva:08x}"), + })?; + Ok(MethodBodyHeader { + code_offset: header_dwords * 4, + code_size, + }) + } + _ => Err(ManagedError::Metadata { + path: self.path.clone(), + message: format!("unknown IL method header at RVA 0x{rva:08x}"), + }), + } + } + + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] + fn decode_operand( + &self, + info: OpcodeInfo, + opcode_name: &str, + code: &[u8], + cursor: &mut usize, + calls: &mut Vec, + delegate_targets: &mut Vec, + field_reads: &mut Vec, + field_writes: &mut Vec, + string_literals: &mut Vec, + ) -> Result, ManagedError> { + match info.operand { + OperandKind::InlineNone => Ok(None), + OperandKind::ShortInlineI => Ok(Some(read_i8(code, cursor)?.to_string())), + OperandKind::ShortInlineVar => Ok(Some(read_u8(code, cursor)?.to_string())), + OperandKind::InlineVar => Ok(Some(read_u16(code, cursor)?.to_string())), + OperandKind::InlineI => Ok(Some(read_i32(code, cursor)?.to_string())), + OperandKind::InlineI8 => Ok(Some(read_i64(code, cursor)?.to_string())), + OperandKind::ShortInlineR => Ok(Some(read_f32(code, cursor)?.to_string())), + OperandKind::InlineR => Ok(Some(read_f64(code, cursor)?.to_string())), + OperandKind::ShortInlineBrTarget => { + let delta = i32::from(read_i8(code, cursor)?); + Ok(Some(self.branch_target_text(*cursor, delta)?)) + } + OperandKind::InlineBrTarget => { + let delta = read_i32(code, cursor)?; + Ok(Some(self.branch_target_text(*cursor, delta)?)) + } + OperandKind::InlineSwitch => { + let count = usize::try_from(read_u32(code, cursor)?).map_err(|_| { + ManagedError::Metadata { + path: self.path.clone(), + message: "switch target count overflow".to_string(), + } + })?; + let target_bytes = count.checked_mul(4).ok_or_else(|| ManagedError::Metadata { + path: self.path.clone(), + message: "switch target table overflow".to_string(), + })?; + let remaining = code.len().saturating_sub(*cursor); + if target_bytes > remaining { + return Err(ManagedError::Metadata { + path: self.path.clone(), + message: format!( + "switch target table is truncated: count {count} requires {target_bytes} bytes, only {remaining} remain" + ), + }); + } + let mut targets = Vec::with_capacity(count); + let base = + (*cursor) + .checked_add(target_bytes) + .ok_or_else(|| ManagedError::Metadata { + path: self.path.clone(), + message: "switch target base overflow".to_string(), + })?; + for _ in 0..count { + let delta = read_i32(code, cursor)?; + targets.push(self.branch_target_text(base, delta)?); + } + Ok(Some(targets.join(","))) + } + OperandKind::InlineMethod => { + let token = read_u32(code, cursor)?; + let reference = self.resolve_method_token(token); + if let Some(reference) = &reference { + if matches!(opcode_name, "call" | "callvirt" | "newobj" | "jmp") { + calls.push(reference.clone()); + } else if matches!(opcode_name, "ldftn" | "ldvirtftn") { + delegate_targets.push(reference.clone()); + } + } + Ok(Some(reference.map_or_else( + || format!("0x{token:08x}"), + |reference| reference.qualified_name, + ))) + } + OperandKind::InlineField => { + let token = read_u32(code, cursor)?; + let reference = self.resolve_field_token(token); + if let Some(reference) = &reference { + if matches!(opcode_name, "stfld" | "stsfld") { + field_writes.push(reference.clone()); + } else { + field_reads.push(reference.clone()); + } + } + Ok(Some(reference.map_or_else( + || format!("0x{token:08x}"), + |reference| reference.qualified_name, + ))) + } + OperandKind::InlineString => { + let token = read_u32(code, cursor)?; + let value = self.resolve_user_string_token(token); + if let Some(value) = &value { + string_literals.push(value.clone()); + } + Ok(Some(value.unwrap_or_else(|| format!("0x{token:08x}")))) + } + OperandKind::InlineType => { + let token = read_u32(code, cursor)?; + Ok(Some( + self.resolve_type_token(token) + .unwrap_or_else(|| format!("0x{token:08x}")), + )) + } + OperandKind::InlineSig | OperandKind::InlineTok => { + let token = read_u32(code, cursor)?; + Ok(Some(self.resolve_any_token(token))) + } + } + } + + fn resolve_any_token(&self, token: u32) -> String { + self.resolve_method_token(token) + .map(|reference| reference.qualified_name) + .or_else(|| { + self.resolve_field_token(token) + .map(|reference| reference.qualified_name) + }) + .or_else(|| self.resolve_type_token(token)) + .unwrap_or_else(|| format!("0x{token:08x}")) + } + + fn resolve_user_string_token(&self, token: u32) -> Option { + user_string_offset(token).and_then(|offset| self.metadata.user_strings.get(offset).ok()) + } + + fn branch_target_text(&self, base: usize, delta: i32) -> Result { + let base = i64::try_from(base).map_err(|_| ManagedError::Metadata { + path: self.path.clone(), + message: "branch target base exceeds supported range".to_string(), + })?; + Ok(base.saturating_add(i64::from(delta)).to_string()) + } + + fn resolve_type_token(&self, token: u32) -> Option { + let (table, row) = parse_metadata_token(token)?; + match table { + TableId::TypeDef => Some(self.full_type_name(row)), + TableId::TypeRef => self.type_ref_full_name(row), + TableId::TypeSpec => self.type_spec_full_name(row), + _ => None, + } + } + + fn resolve_method_token(&self, token: u32) -> Option { + let (table, row) = parse_metadata_token(token)?; + match table { + TableId::MethodDef => self.method_reference_from_def(row), + TableId::MemberRef => self.method_reference_from_member_ref(row), + TableId::MethodSpec => self.method_reference_from_method_spec(row), + _ => None, + } + } + + fn resolve_field_token(&self, token: u32) -> Option { + let (table, row) = parse_metadata_token(token)?; + match table { + TableId::Field => self.field_reference_from_def(row), + TableId::MemberRef => self.field_reference_from_member_ref(row), + _ => None, + } + } + + fn method_reference_from_def(&self, method_index: u32) -> Option { + let descriptor = self.method_descriptor_base(method_index)?; + Some(ManagedMethodReference { + metadata_token: metadata_token(TableId::MethodDef, method_index), + qualified_name: descriptor.qualified_name, + signature: descriptor.signature, + source_table: TableId::MethodDef.name().to_string(), + external: false, + }) + } + + fn method_reference_from_member_ref( + &self, + member_ref_index: u32, + ) -> Option { + let row = self + .metadata + .member_refs + .get(member_ref_index.checked_sub(1)? as usize)?; + let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; + let signature = MethodSig::parse_blob(signature_bytes).ok()?; + let name = self.string_or_empty(row.name); + let parent = self.member_ref_parent_name(row.class); + let return_type = self.format_type_sig(&signature.return_type); + let parameters = signature + .params + .iter() + .map(|parameter| ParameterDescriptor { + name: None, + parameter_type: self.format_type_sig(parameter), + }) + .collect::>(); + let signature_text = format_method_signature(&name, &return_type, ¶meters); + let qualified_name = parent + .map(|parent| format!("{parent}::{name}")) + .unwrap_or(name); + Some(ManagedMethodReference { + metadata_token: metadata_token(TableId::MemberRef, member_ref_index), + qualified_name, + signature: signature_text, + source_table: TableId::MemberRef.name().to_string(), + external: true, + }) + } + + fn method_reference_from_method_spec( + &self, + method_spec_index: u32, + ) -> Option { + let row = self + .metadata + .method_specs + .get(method_spec_index.checked_sub(1)? as usize)?; + let mut reference = match row.method.table? { + TableId::MethodDef => self.method_reference_from_def(row.method.row)?, + TableId::MemberRef => self.method_reference_from_member_ref(row.method.row)?, + _ => return None, + }; + reference.metadata_token = metadata_token(TableId::MethodSpec, method_spec_index); + reference.source_table = TableId::MethodSpec.name().to_string(); + reference.external = true; + Some(reference) + } + + fn field_reference_from_def(&self, field_index: u32) -> Option { + let row = self + .metadata + .fields + .get(field_index.checked_sub(1)? as usize)?; + let (_, owner) = self.metadata.get_field_owner(field_index)?; + let owner_name = self.full_type_name( + u32::try_from( + self.metadata + .type_defs + .iter() + .position(|candidate| std::ptr::eq(candidate, owner))? + + 1, + ) + .ok()?, + ); + let name = self.string_or_empty(row.name); + let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; + let signature = FieldSig::parse_blob(signature_bytes).ok()?; + let field_type = self.format_type_sig(&signature.field_type); + Some(ManagedFieldReference { + metadata_token: metadata_token(TableId::Field, field_index), + qualified_name: format!("{owner_name}::{name}"), + signature: format!("{field_type} {name}"), + source_table: TableId::Field.name().to_string(), + external: false, + }) + } + + fn field_reference_from_member_ref( + &self, + member_ref_index: u32, + ) -> Option { + let row = self + .metadata + .member_refs + .get(member_ref_index.checked_sub(1)? as usize)?; + let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; + let signature = FieldSig::parse_blob(signature_bytes).ok()?; + let name = self.string_or_empty(row.name); + let parent = self.member_ref_parent_name(row.class); + let field_type = self.format_type_sig(&signature.field_type); + let qualified_name = + parent.map_or_else(|| name.clone(), |parent| format!("{parent}::{name}")); + Some(ManagedFieldReference { + metadata_token: metadata_token(TableId::MemberRef, member_ref_index), + qualified_name, + signature: format!("{field_type} {name}"), + source_table: TableId::MemberRef.name().to_string(), + external: true, + }) + } + + fn member_ref_parent_name(&self, parent: CodedIndex) -> Option { + match parent.table? { + TableId::TypeDef => Some(self.full_type_name(parent.row)), + TableId::TypeRef => self.type_ref_full_name(parent.row), + TableId::TypeSpec => self.type_spec_full_name(parent.row), + TableId::MethodDef => self + .metadata + .get_method_owner(parent.row) + .map(|(type_index, _)| self.full_type_name(type_index)), + TableId::ModuleRef => self + .metadata + .module_refs + .get(parent.row.checked_sub(1)? as usize) + .and_then(|row| self.metadata.strings.get(row.name).ok()) + .map(ToString::to_string), + _ => None, + } + } + + fn type_ref_full_name(&self, type_ref_index: u32) -> Option { + let row = self + .metadata + .type_refs + .get(type_ref_index.checked_sub(1)? as usize)?; + let name = self.string_or_empty(row.type_name); + let namespace = self.string_option(row.type_namespace); + Some(namespace.map_or_else(|| name.clone(), |namespace| format!("{namespace}.{name}"))) + } + + fn type_spec_full_name(&self, type_spec_index: u32) -> Option { + let row = self + .metadata + .type_specs + .get(type_spec_index.checked_sub(1)? as usize)?; + let signature = self.metadata.blobs.get(row.signature).ok()?; + let type_sig = parse_type_sig_blob(signature)?; + Some(self.format_type_sig(&type_sig)) + } +} + +fn resolve_target( + paths: &[PathBuf], + target: &MethodTargetQuery, +) -> Result { + let mut matches = Vec::new(); + for path in paths { + let assembly = ManagedAssembly::load(path)?; + matches.extend(assembly.exact_method_matches(target)?); + } + match matches.len() { + 0 => Err(ManagedError::Query { + message: format!( + "no managed method matched {}::{}", + target.type_name, target.method_name + ), + }), + 1 => Ok(matches.remove(0)), + _ => Err(ManagedError::Query { + message: format!( + "method {}::{} is ambiguous across {} matches; narrow it with --signature", + target.type_name, + target.method_name, + matches.len() + ), + }), + } +} + +fn method_matches(descriptor: &ManagedMethodDescriptor, query: &MethodQuery) -> bool { + if let Some(pattern) = &query.match_pattern + && !pattern.is_match(&descriptor.name) + && !pattern.is_match(&descriptor.type_name) + && !pattern.is_match(&descriptor.qualified_name) + && !pattern.is_match(&descriptor.signature) + { + return false; + } + if let Some(pattern) = &query.type_pattern + && !pattern.is_match(&descriptor.type_name) + { + return false; + } + if let Some(pattern) = &query.signature_pattern + && !pattern.is_match(&descriptor.signature) + { + return false; + } + true +} + +fn method_reference_matches( + reference: &ManagedMethodReference, + descriptor: &ManagedMethodDescriptor, +) -> bool { + reference.qualified_name == descriptor.qualified_name + && reference.signature == descriptor.signature +} + +fn collect_virtual_targets( + paths: &[PathBuf], + identity: &MethodIdentity, +) -> Result, ManagedError> { + let mut matches = Vec::new(); + for path in paths { + let assembly = ManagedAssembly::load(path)?; + matches.extend(assembly.virtual_family_methods(&identity.descriptor)?); + } + matches.sort_by(|left, right| { + ( + left.assembly_path.as_os_str(), + left.type_name.as_str(), + left.name.as_str(), + left.signature.as_str(), + ) + .cmp(&( + right.assembly_path.as_os_str(), + right.type_name.as_str(), + right.name.as_str(), + right.signature.as_str(), + )) + }); + matches.dedup_by(|left, right| { + left.assembly_path == right.assembly_path + && left.qualified_name == right.qualified_name + && left.signature == right.signature + }); + if matches.is_empty() { + matches.push(identity.descriptor.clone()); + } + Ok(matches) +} + +fn method_row(metadata: &Metadata, method_index: u32) -> Option<&clrmeta::tables::MethodDefRow> { + metadata + .method_defs + .get(method_index.checked_sub(1)? as usize) +} + +const fn metadata_token(table: TableId, row: u32) -> u32 { + ((table as u32) << 24) | row +} + +const fn opcode_hex_value(value: i16) -> u16 { + u16::from_be_bytes(value.to_be_bytes()) +} + +const fn user_string_offset(token: u32) -> Option { + if (token >> 24) == 0x70 { + Some(token & 0x00ff_ffff) + } else { + None + } +} + +fn parse_metadata_token(token: u32) -> Option<(TableId, u32)> { + let table = TableId::try_from((token >> 24) as u8).ok()?; + let row = token & 0x00ff_ffff; + (row != 0).then_some((table, row)) +} + +fn read_opcode(code: &[u8], cursor: usize) -> Option<(i16, usize)> { + let first = *code.get(cursor)?; + if first == 0xfe { + let second = *code.get(cursor + 1)?; + Some((i16::from_be_bytes([0xfe, second]), 2)) + } else { + Some((i16::from(first), 1)) + } +} + +fn read_u8(code: &[u8], cursor: &mut usize) -> Result { + let value = *code.get(*cursor).ok_or_else(|| ManagedError::Query { + message: "unexpected end of IL stream".to_string(), + })?; + *cursor += 1; + Ok(value) +} + +fn read_i8(code: &[u8], cursor: &mut usize) -> Result { + Ok(i8::from_ne_bytes([read_u8(code, cursor)?])) +} + +fn read_u16(code: &[u8], cursor: &mut usize) -> Result { + let bytes = read_exact::<2>(code, cursor)?; + Ok(u16::from_le_bytes(bytes)) +} + +fn read_u32(code: &[u8], cursor: &mut usize) -> Result { + let bytes = read_exact::<4>(code, cursor)?; + Ok(u32::from_le_bytes(bytes)) +} + +fn read_i32(code: &[u8], cursor: &mut usize) -> Result { + let bytes = read_exact::<4>(code, cursor)?; + Ok(i32::from_le_bytes(bytes)) +} + +fn read_i64(code: &[u8], cursor: &mut usize) -> Result { + let bytes = read_exact::<8>(code, cursor)?; + Ok(i64::from_le_bytes(bytes)) +} + +fn read_f32(code: &[u8], cursor: &mut usize) -> Result { + let bytes = read_exact::<4>(code, cursor)?; + Ok(f32::from_le_bytes(bytes)) +} + +fn read_f64(code: &[u8], cursor: &mut usize) -> Result { + let bytes = read_exact::<8>(code, cursor)?; + Ok(f64::from_le_bytes(bytes)) +} + +fn read_exact(code: &[u8], cursor: &mut usize) -> Result<[u8; N], ManagedError> { + let end = cursor.saturating_add(N); + let slice = code.get(*cursor..end).ok_or_else(|| ManagedError::Query { + message: "unexpected end of IL stream".to_string(), + })?; + let mut bytes = [0_u8; N]; + bytes.copy_from_slice(slice); + *cursor = end; + Ok(bytes) +} + +include!("flow_opcode_table.rs"); + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use regex_lite::RegexBuilder; + + use super::*; + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") + } + + fn fixture_assembly() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + .join("GameAssembly.dll") + } + + fn regex(value: &str) -> Regex { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .expect("regex") + } + + #[test] + fn method_query_and_target_parsing_find_fixture_methods() { + let rows = find_methods( + &[fixture_assembly()], + &MethodQuery { + match_pattern: Some(regex("StartProject|QueueVehicle")), + type_pattern: None, + signature_pattern: None, + limit: None, + }, + ) + .expect("method rows"); + + assert!(rows.iter().any(|row| { + row.qualified_name + == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject" + })); + assert!( + MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject", + None + ) + .is_ok() + ); + } + + #[test] + fn method_body_and_xref_reports_capture_calls_fields_and_strings() { + let start_project = MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject", + None, + ) + .expect("target"); + let body = inspect_method_body(&[fixture_assembly()], &start_project).expect("body"); + assert!(!body.instructions.is_empty()); + assert_eq!(body.method.call_count, 1); + assert_eq!(body.field_reads.len(), 2); + assert_eq!(body.field_writes.len(), 2); + + let constructor = MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::.ctor", + None, + ) + .expect("constructor target"); + let constructor_body = + inspect_method_body(&[fixture_assembly()], &constructor).expect("constructor body"); + assert!( + constructor_body + .string_literals + .iter() + .any(|value| value == "Mercury") + ); + + let launch_target = MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartLaunch", + None, + ) + .expect("launch target"); + let xref = inspect_method_xref( + &[fixture_assembly()], + &launch_target, + XrefDirection::Both, + false, + ) + .expect("xref"); + assert!(xref.callees.iter().any(|reference| { + reference + .qualified_name + .contains("MercuryFixture.Support.LaunchVehicleBase::StartLaunch") + })); + } + + #[test] + fn helper_parsing_and_token_utilities_cover_edge_cases() { + assert!(MethodTargetQuery::parse("MissingSeparator", None).is_err()); + assert!(MethodTargetQuery::parse("TypeOnly::", None).is_err()); + assert!(MethodTargetQuery::parse("::MethodOnly", None).is_err()); + + assert_eq!(metadata_token(TableId::MethodDef, 7), 0x0600_0007); + assert_eq!( + parse_metadata_token(metadata_token(TableId::MemberRef, 9)), + Some((TableId::MemberRef, 9)) + ); + assert_eq!(parse_metadata_token(0), None); + + assert_eq!(user_string_offset(0x7000_0012), Some(0x12)); + assert_eq!(user_string_offset(0x0600_0012), None); + + assert_eq!(opcode_hex_value(i16::from_be_bytes([0xfe, 0x16])), 0xfe16); + assert_eq!(read_opcode(&[0x2a], 0), Some((42, 1))); + assert_eq!( + read_opcode(&[0xfe, 0x16], 0), + Some((i16::from_be_bytes([0xfe, 0x16]), 2)) + ); + assert_eq!(read_opcode(&[0xfe], 0), None); + } + + #[test] + fn inline_switch_rejects_count_larger_than_remaining_body_before_targets() { + let assembly = ManagedAssembly::load(&fixture_assembly()).expect("fixture assembly"); + let mut cursor = 0; + let code = 4096_u32.to_le_bytes(); + let mut calls = Vec::new(); + let mut delegate_targets = Vec::new(); + let mut field_reads = Vec::new(); + let mut field_writes = Vec::new(); + let mut string_literals = Vec::new(); + + let error = assembly + .decode_operand( + OpcodeInfo { + name: "switch", + operand: OperandKind::InlineSwitch, + }, + "switch", + &code, + &mut cursor, + &mut calls, + &mut delegate_targets, + &mut field_reads, + &mut field_writes, + &mut string_literals, + ) + .expect_err("truncated switch target table should fail"); + + assert!(matches!( + error, + ManagedError::Metadata { message, .. } + if message.contains("switch target table is truncated") + )); + assert_eq!(cursor, 4); + assert!(calls.is_empty()); + assert!(delegate_targets.is_empty()); + assert!(field_reads.is_empty()); + assert!(field_writes.is_empty()); + assert!(string_literals.is_empty()); + } + + #[test] + fn fat_method_header_with_zero_dword_size_fails_closed() { + let mut assembly = ManagedAssembly::load(&fixture_assembly()).expect("fixture assembly"); + let target = MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject", + None, + ) + .expect("target"); + let method = assembly + .exact_method_matches(&target) + .expect("method lookup") + .pop() + .expect("method match"); + let row = method_row(&assembly.metadata, method.method_index).expect("method row"); + let body_offset = assembly.offset_for_rva(row.rva).expect("method RVA"); + + assembly.bytes[body_offset..body_offset + 12] + .copy_from_slice(&[0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + + let error = assembly + .decode_method_body(method.method_index) + .expect_err("invalid fat method header should fail closed"); + + assert!(matches!( + error, + ManagedError::Metadata { message, .. } + if message.contains("invalid fat method header size") + )); + } + + #[test] + fn sort_and_dedup_method_references_collapses_duplicates() { + let mut references = vec![ + ManagedMethodReference { + metadata_token: 2, + qualified_name: "Game.Space::Queue".to_string(), + signature: "void Queue()".to_string(), + source_table: "MemberRef".to_string(), + external: false, + }, + ManagedMethodReference { + metadata_token: 2, + qualified_name: "Game.Space::Queue".to_string(), + signature: "void Queue()".to_string(), + source_table: "MemberRef".to_string(), + external: false, + }, + ManagedMethodReference { + metadata_token: 3, + qualified_name: "Game.Space::Start".to_string(), + signature: "void Start()".to_string(), + source_table: "MemberRef".to_string(), + external: false, + }, + ]; + sort_and_dedup_method_references(&mut references); + assert_eq!(references.len(), 2); + assert_eq!(references[0].qualified_name, "Game.Space::Queue"); + assert_eq!(references[1].qualified_name, "Game.Space::Start"); + } + + #[test] + fn method_filters_and_missing_targets_report_clean_errors() { + let methods = find_methods( + &[fixture_assembly()], + &MethodQuery { + match_pattern: Some(regex("startproject|queuevehicle")), + type_pattern: Some(regex("spacecraftconstructionwindow")), + signature_pattern: Some(regex(r"StartProject\(int units, string tag\)")), + limit: Some(4), + }, + ) + .expect("filtered methods"); + + assert_eq!(methods.len(), 1); + assert_eq!( + methods[0].qualified_name, + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject" + ); + + let error = inspect_method_body( + &[fixture_assembly()], + &MethodTargetQuery::parse( + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow::DefinitelyMissing", + None, + ) + .expect("target"), + ) + .expect_err("missing method should fail"); + assert!(matches!( + error, + ManagedError::Query { message } + if message.contains("no managed method matched") + )); + } +} diff --git a/crates/managed/src/flow_opcode_table.rs b/crates/managed/src/flow_opcode_table.rs new file mode 100644 index 0000000..2818ed9 --- /dev/null +++ b/crates/managed/src/flow_opcode_table.rs @@ -0,0 +1,233 @@ +// Auto-generated from System.Reflection.Emit.OpCodes on the development machine. +#[allow(clippy::too_many_lines)] +const fn opcode_info(value: i16) -> Option { + match value { + -482 => Some(OpcodeInfo { name: "readonly.", operand: OperandKind::InlineNone }), + -483 => Some(OpcodeInfo { name: "refanytype", operand: OperandKind::InlineNone }), + -484 => Some(OpcodeInfo { name: "sizeof", operand: OperandKind::InlineType }), + -486 => Some(OpcodeInfo { name: "rethrow", operand: OperandKind::InlineNone }), + -488 => Some(OpcodeInfo { name: "initblk", operand: OperandKind::InlineNone }), + -489 => Some(OpcodeInfo { name: "cpblk", operand: OperandKind::InlineNone }), + -490 => Some(OpcodeInfo { name: "constrained.", operand: OperandKind::InlineType }), + -491 => Some(OpcodeInfo { name: "initobj", operand: OperandKind::InlineType }), + -492 => Some(OpcodeInfo { name: "tail.", operand: OperandKind::InlineNone }), + -493 => Some(OpcodeInfo { name: "volatile.", operand: OperandKind::InlineNone }), + -494 => Some(OpcodeInfo { name: "unaligned.", operand: OperandKind::ShortInlineI }), + -495 => Some(OpcodeInfo { name: "endfilter", operand: OperandKind::InlineNone }), + -497 => Some(OpcodeInfo { name: "localloc", operand: OperandKind::InlineNone }), + -498 => Some(OpcodeInfo { name: "stloc", operand: OperandKind::InlineVar }), + -499 => Some(OpcodeInfo { name: "ldloca", operand: OperandKind::InlineVar }), + -500 => Some(OpcodeInfo { name: "ldloc", operand: OperandKind::InlineVar }), + -501 => Some(OpcodeInfo { name: "starg", operand: OperandKind::InlineVar }), + -502 => Some(OpcodeInfo { name: "ldarga", operand: OperandKind::InlineVar }), + -503 => Some(OpcodeInfo { name: "ldarg", operand: OperandKind::InlineVar }), + -505 => Some(OpcodeInfo { name: "ldvirtftn", operand: OperandKind::InlineMethod }), + -506 => Some(OpcodeInfo { name: "ldftn", operand: OperandKind::InlineMethod }), + -507 => Some(OpcodeInfo { name: "clt.un", operand: OperandKind::InlineNone }), + -508 => Some(OpcodeInfo { name: "clt", operand: OperandKind::InlineNone }), + -509 => Some(OpcodeInfo { name: "cgt.un", operand: OperandKind::InlineNone }), + -510 => Some(OpcodeInfo { name: "cgt", operand: OperandKind::InlineNone }), + -511 => Some(OpcodeInfo { name: "ceq", operand: OperandKind::InlineNone }), + -512 => Some(OpcodeInfo { name: "arglist", operand: OperandKind::InlineNone }), + 0 => Some(OpcodeInfo { name: "nop", operand: OperandKind::InlineNone }), + 1 => Some(OpcodeInfo { name: "break", operand: OperandKind::InlineNone }), + 10 => Some(OpcodeInfo { name: "stloc.0", operand: OperandKind::InlineNone }), + 100 => Some(OpcodeInfo { name: "shr.un", operand: OperandKind::InlineNone }), + 101 => Some(OpcodeInfo { name: "neg", operand: OperandKind::InlineNone }), + 102 => Some(OpcodeInfo { name: "not", operand: OperandKind::InlineNone }), + 103 => Some(OpcodeInfo { name: "conv.i1", operand: OperandKind::InlineNone }), + 104 => Some(OpcodeInfo { name: "conv.i2", operand: OperandKind::InlineNone }), + 105 => Some(OpcodeInfo { name: "conv.i4", operand: OperandKind::InlineNone }), + 106 => Some(OpcodeInfo { name: "conv.i8", operand: OperandKind::InlineNone }), + 107 => Some(OpcodeInfo { name: "conv.r4", operand: OperandKind::InlineNone }), + 108 => Some(OpcodeInfo { name: "conv.r8", operand: OperandKind::InlineNone }), + 109 => Some(OpcodeInfo { name: "conv.u4", operand: OperandKind::InlineNone }), + 11 => Some(OpcodeInfo { name: "stloc.1", operand: OperandKind::InlineNone }), + 110 => Some(OpcodeInfo { name: "conv.u8", operand: OperandKind::InlineNone }), + 111 => Some(OpcodeInfo { name: "callvirt", operand: OperandKind::InlineMethod }), + 112 => Some(OpcodeInfo { name: "cpobj", operand: OperandKind::InlineType }), + 113 => Some(OpcodeInfo { name: "ldobj", operand: OperandKind::InlineType }), + 114 => Some(OpcodeInfo { name: "ldstr", operand: OperandKind::InlineString }), + 115 => Some(OpcodeInfo { name: "newobj", operand: OperandKind::InlineMethod }), + 116 => Some(OpcodeInfo { name: "castclass", operand: OperandKind::InlineType }), + 117 => Some(OpcodeInfo { name: "isinst", operand: OperandKind::InlineType }), + 118 => Some(OpcodeInfo { name: "conv.r.un", operand: OperandKind::InlineNone }), + 12 => Some(OpcodeInfo { name: "stloc.2", operand: OperandKind::InlineNone }), + 121 => Some(OpcodeInfo { name: "unbox", operand: OperandKind::InlineType }), + 122 => Some(OpcodeInfo { name: "throw", operand: OperandKind::InlineNone }), + 123 => Some(OpcodeInfo { name: "ldfld", operand: OperandKind::InlineField }), + 124 => Some(OpcodeInfo { name: "ldflda", operand: OperandKind::InlineField }), + 125 => Some(OpcodeInfo { name: "stfld", operand: OperandKind::InlineField }), + 126 => Some(OpcodeInfo { name: "ldsfld", operand: OperandKind::InlineField }), + 127 => Some(OpcodeInfo { name: "ldsflda", operand: OperandKind::InlineField }), + 128 => Some(OpcodeInfo { name: "stsfld", operand: OperandKind::InlineField }), + 129 => Some(OpcodeInfo { name: "stobj", operand: OperandKind::InlineType }), + 13 => Some(OpcodeInfo { name: "stloc.3", operand: OperandKind::InlineNone }), + 130 => Some(OpcodeInfo { name: "conv.ovf.i1.un", operand: OperandKind::InlineNone }), + 131 => Some(OpcodeInfo { name: "conv.ovf.i2.un", operand: OperandKind::InlineNone }), + 132 => Some(OpcodeInfo { name: "conv.ovf.i4.un", operand: OperandKind::InlineNone }), + 133 => Some(OpcodeInfo { name: "conv.ovf.i8.un", operand: OperandKind::InlineNone }), + 134 => Some(OpcodeInfo { name: "conv.ovf.u1.un", operand: OperandKind::InlineNone }), + 135 => Some(OpcodeInfo { name: "conv.ovf.u2.un", operand: OperandKind::InlineNone }), + 136 => Some(OpcodeInfo { name: "conv.ovf.u4.un", operand: OperandKind::InlineNone }), + 137 => Some(OpcodeInfo { name: "conv.ovf.u8.un", operand: OperandKind::InlineNone }), + 138 => Some(OpcodeInfo { name: "conv.ovf.i.un", operand: OperandKind::InlineNone }), + 139 => Some(OpcodeInfo { name: "conv.ovf.u.un", operand: OperandKind::InlineNone }), + 14 => Some(OpcodeInfo { name: "ldarg.s", operand: OperandKind::ShortInlineVar }), + 140 => Some(OpcodeInfo { name: "box", operand: OperandKind::InlineType }), + 141 => Some(OpcodeInfo { name: "newarr", operand: OperandKind::InlineType }), + 142 => Some(OpcodeInfo { name: "ldlen", operand: OperandKind::InlineNone }), + 143 => Some(OpcodeInfo { name: "ldelema", operand: OperandKind::InlineType }), + 144 => Some(OpcodeInfo { name: "ldelem.i1", operand: OperandKind::InlineNone }), + 145 => Some(OpcodeInfo { name: "ldelem.u1", operand: OperandKind::InlineNone }), + 146 => Some(OpcodeInfo { name: "ldelem.i2", operand: OperandKind::InlineNone }), + 147 => Some(OpcodeInfo { name: "ldelem.u2", operand: OperandKind::InlineNone }), + 148 => Some(OpcodeInfo { name: "ldelem.i4", operand: OperandKind::InlineNone }), + 149 => Some(OpcodeInfo { name: "ldelem.u4", operand: OperandKind::InlineNone }), + 15 => Some(OpcodeInfo { name: "ldarga.s", operand: OperandKind::ShortInlineVar }), + 150 => Some(OpcodeInfo { name: "ldelem.i8", operand: OperandKind::InlineNone }), + 151 => Some(OpcodeInfo { name: "ldelem.i", operand: OperandKind::InlineNone }), + 152 => Some(OpcodeInfo { name: "ldelem.r4", operand: OperandKind::InlineNone }), + 153 => Some(OpcodeInfo { name: "ldelem.r8", operand: OperandKind::InlineNone }), + 154 => Some(OpcodeInfo { name: "ldelem.ref", operand: OperandKind::InlineNone }), + 155 => Some(OpcodeInfo { name: "stelem.i", operand: OperandKind::InlineNone }), + 156 => Some(OpcodeInfo { name: "stelem.i1", operand: OperandKind::InlineNone }), + 157 => Some(OpcodeInfo { name: "stelem.i2", operand: OperandKind::InlineNone }), + 158 => Some(OpcodeInfo { name: "stelem.i4", operand: OperandKind::InlineNone }), + 159 => Some(OpcodeInfo { name: "stelem.i8", operand: OperandKind::InlineNone }), + 16 => Some(OpcodeInfo { name: "starg.s", operand: OperandKind::ShortInlineVar }), + 160 => Some(OpcodeInfo { name: "stelem.r4", operand: OperandKind::InlineNone }), + 161 => Some(OpcodeInfo { name: "stelem.r8", operand: OperandKind::InlineNone }), + 162 => Some(OpcodeInfo { name: "stelem.ref", operand: OperandKind::InlineNone }), + 163 => Some(OpcodeInfo { name: "ldelem", operand: OperandKind::InlineType }), + 164 => Some(OpcodeInfo { name: "stelem", operand: OperandKind::InlineType }), + 165 => Some(OpcodeInfo { name: "unbox.any", operand: OperandKind::InlineType }), + 17 => Some(OpcodeInfo { name: "ldloc.s", operand: OperandKind::ShortInlineVar }), + 179 => Some(OpcodeInfo { name: "conv.ovf.i1", operand: OperandKind::InlineNone }), + 18 => Some(OpcodeInfo { name: "ldloca.s", operand: OperandKind::ShortInlineVar }), + 180 => Some(OpcodeInfo { name: "conv.ovf.u1", operand: OperandKind::InlineNone }), + 181 => Some(OpcodeInfo { name: "conv.ovf.i2", operand: OperandKind::InlineNone }), + 182 => Some(OpcodeInfo { name: "conv.ovf.u2", operand: OperandKind::InlineNone }), + 183 => Some(OpcodeInfo { name: "conv.ovf.i4", operand: OperandKind::InlineNone }), + 184 => Some(OpcodeInfo { name: "conv.ovf.u4", operand: OperandKind::InlineNone }), + 185 => Some(OpcodeInfo { name: "conv.ovf.i8", operand: OperandKind::InlineNone }), + 186 => Some(OpcodeInfo { name: "conv.ovf.u8", operand: OperandKind::InlineNone }), + 19 => Some(OpcodeInfo { name: "stloc.s", operand: OperandKind::ShortInlineVar }), + 194 => Some(OpcodeInfo { name: "refanyval", operand: OperandKind::InlineType }), + 195 => Some(OpcodeInfo { name: "ckfinite", operand: OperandKind::InlineNone }), + 198 => Some(OpcodeInfo { name: "mkrefany", operand: OperandKind::InlineType }), + 2 => Some(OpcodeInfo { name: "ldarg.0", operand: OperandKind::InlineNone }), + 20 => Some(OpcodeInfo { name: "ldnull", operand: OperandKind::InlineNone }), + 208 => Some(OpcodeInfo { name: "ldtoken", operand: OperandKind::InlineTok }), + 209 => Some(OpcodeInfo { name: "conv.u2", operand: OperandKind::InlineNone }), + 21 => Some(OpcodeInfo { name: "ldc.i4.m1", operand: OperandKind::InlineNone }), + 210 => Some(OpcodeInfo { name: "conv.u1", operand: OperandKind::InlineNone }), + 211 => Some(OpcodeInfo { name: "conv.i", operand: OperandKind::InlineNone }), + 212 => Some(OpcodeInfo { name: "conv.ovf.i", operand: OperandKind::InlineNone }), + 213 => Some(OpcodeInfo { name: "conv.ovf.u", operand: OperandKind::InlineNone }), + 214 => Some(OpcodeInfo { name: "add.ovf", operand: OperandKind::InlineNone }), + 215 => Some(OpcodeInfo { name: "add.ovf.un", operand: OperandKind::InlineNone }), + 216 => Some(OpcodeInfo { name: "mul.ovf", operand: OperandKind::InlineNone }), + 217 => Some(OpcodeInfo { name: "mul.ovf.un", operand: OperandKind::InlineNone }), + 218 => Some(OpcodeInfo { name: "sub.ovf", operand: OperandKind::InlineNone }), + 219 => Some(OpcodeInfo { name: "sub.ovf.un", operand: OperandKind::InlineNone }), + 22 => Some(OpcodeInfo { name: "ldc.i4.0", operand: OperandKind::InlineNone }), + 220 => Some(OpcodeInfo { name: "endfinally", operand: OperandKind::InlineNone }), + 221 => Some(OpcodeInfo { name: "leave", operand: OperandKind::InlineBrTarget }), + 222 => Some(OpcodeInfo { name: "leave.s", operand: OperandKind::ShortInlineBrTarget }), + 223 => Some(OpcodeInfo { name: "stind.i", operand: OperandKind::InlineNone }), + 224 => Some(OpcodeInfo { name: "conv.u", operand: OperandKind::InlineNone }), + 23 => Some(OpcodeInfo { name: "ldc.i4.1", operand: OperandKind::InlineNone }), + 24 => Some(OpcodeInfo { name: "ldc.i4.2", operand: OperandKind::InlineNone }), + 248 => Some(OpcodeInfo { name: "prefix7", operand: OperandKind::InlineNone }), + 249 => Some(OpcodeInfo { name: "prefix6", operand: OperandKind::InlineNone }), + 25 => Some(OpcodeInfo { name: "ldc.i4.3", operand: OperandKind::InlineNone }), + 250 => Some(OpcodeInfo { name: "prefix5", operand: OperandKind::InlineNone }), + 251 => Some(OpcodeInfo { name: "prefix4", operand: OperandKind::InlineNone }), + 252 => Some(OpcodeInfo { name: "prefix3", operand: OperandKind::InlineNone }), + 253 => Some(OpcodeInfo { name: "prefix2", operand: OperandKind::InlineNone }), + 254 => Some(OpcodeInfo { name: "prefix1", operand: OperandKind::InlineNone }), + 255 => Some(OpcodeInfo { name: "prefixref", operand: OperandKind::InlineNone }), + 26 => Some(OpcodeInfo { name: "ldc.i4.4", operand: OperandKind::InlineNone }), + 27 => Some(OpcodeInfo { name: "ldc.i4.5", operand: OperandKind::InlineNone }), + 28 => Some(OpcodeInfo { name: "ldc.i4.6", operand: OperandKind::InlineNone }), + 29 => Some(OpcodeInfo { name: "ldc.i4.7", operand: OperandKind::InlineNone }), + 3 => Some(OpcodeInfo { name: "ldarg.1", operand: OperandKind::InlineNone }), + 30 => Some(OpcodeInfo { name: "ldc.i4.8", operand: OperandKind::InlineNone }), + 31 => Some(OpcodeInfo { name: "ldc.i4.s", operand: OperandKind::ShortInlineI }), + 32 => Some(OpcodeInfo { name: "ldc.i4", operand: OperandKind::InlineI }), + 33 => Some(OpcodeInfo { name: "ldc.i8", operand: OperandKind::InlineI8 }), + 34 => Some(OpcodeInfo { name: "ldc.r4", operand: OperandKind::ShortInlineR }), + 35 => Some(OpcodeInfo { name: "ldc.r8", operand: OperandKind::InlineR }), + 37 => Some(OpcodeInfo { name: "dup", operand: OperandKind::InlineNone }), + 38 => Some(OpcodeInfo { name: "pop", operand: OperandKind::InlineNone }), + 39 => Some(OpcodeInfo { name: "jmp", operand: OperandKind::InlineMethod }), + 4 => Some(OpcodeInfo { name: "ldarg.2", operand: OperandKind::InlineNone }), + 40 => Some(OpcodeInfo { name: "call", operand: OperandKind::InlineMethod }), + 41 => Some(OpcodeInfo { name: "calli", operand: OperandKind::InlineSig }), + 42 => Some(OpcodeInfo { name: "ret", operand: OperandKind::InlineNone }), + 43 => Some(OpcodeInfo { name: "br.s", operand: OperandKind::ShortInlineBrTarget }), + 44 => Some(OpcodeInfo { name: "brfalse.s", operand: OperandKind::ShortInlineBrTarget }), + 45 => Some(OpcodeInfo { name: "brtrue.s", operand: OperandKind::ShortInlineBrTarget }), + 46 => Some(OpcodeInfo { name: "beq.s", operand: OperandKind::ShortInlineBrTarget }), + 47 => Some(OpcodeInfo { name: "bge.s", operand: OperandKind::ShortInlineBrTarget }), + 48 => Some(OpcodeInfo { name: "bgt.s", operand: OperandKind::ShortInlineBrTarget }), + 49 => Some(OpcodeInfo { name: "ble.s", operand: OperandKind::ShortInlineBrTarget }), + 5 => Some(OpcodeInfo { name: "ldarg.3", operand: OperandKind::InlineNone }), + 50 => Some(OpcodeInfo { name: "blt.s", operand: OperandKind::ShortInlineBrTarget }), + 51 => Some(OpcodeInfo { name: "bne.un.s", operand: OperandKind::ShortInlineBrTarget }), + 52 => Some(OpcodeInfo { name: "bge.un.s", operand: OperandKind::ShortInlineBrTarget }), + 53 => Some(OpcodeInfo { name: "bgt.un.s", operand: OperandKind::ShortInlineBrTarget }), + 54 => Some(OpcodeInfo { name: "ble.un.s", operand: OperandKind::ShortInlineBrTarget }), + 55 => Some(OpcodeInfo { name: "blt.un.s", operand: OperandKind::ShortInlineBrTarget }), + 56 => Some(OpcodeInfo { name: "br", operand: OperandKind::InlineBrTarget }), + 57 => Some(OpcodeInfo { name: "brfalse", operand: OperandKind::InlineBrTarget }), + 58 => Some(OpcodeInfo { name: "brtrue", operand: OperandKind::InlineBrTarget }), + 59 => Some(OpcodeInfo { name: "beq", operand: OperandKind::InlineBrTarget }), + 6 => Some(OpcodeInfo { name: "ldloc.0", operand: OperandKind::InlineNone }), + 60 => Some(OpcodeInfo { name: "bge", operand: OperandKind::InlineBrTarget }), + 61 => Some(OpcodeInfo { name: "bgt", operand: OperandKind::InlineBrTarget }), + 62 => Some(OpcodeInfo { name: "ble", operand: OperandKind::InlineBrTarget }), + 63 => Some(OpcodeInfo { name: "blt", operand: OperandKind::InlineBrTarget }), + 64 => Some(OpcodeInfo { name: "bne.un", operand: OperandKind::InlineBrTarget }), + 65 => Some(OpcodeInfo { name: "bge.un", operand: OperandKind::InlineBrTarget }), + 66 => Some(OpcodeInfo { name: "bgt.un", operand: OperandKind::InlineBrTarget }), + 67 => Some(OpcodeInfo { name: "ble.un", operand: OperandKind::InlineBrTarget }), + 68 => Some(OpcodeInfo { name: "blt.un", operand: OperandKind::InlineBrTarget }), + 69 => Some(OpcodeInfo { name: "switch", operand: OperandKind::InlineSwitch }), + 7 => Some(OpcodeInfo { name: "ldloc.1", operand: OperandKind::InlineNone }), + 70 => Some(OpcodeInfo { name: "ldind.i1", operand: OperandKind::InlineNone }), + 71 => Some(OpcodeInfo { name: "ldind.u1", operand: OperandKind::InlineNone }), + 72 => Some(OpcodeInfo { name: "ldind.i2", operand: OperandKind::InlineNone }), + 73 => Some(OpcodeInfo { name: "ldind.u2", operand: OperandKind::InlineNone }), + 74 => Some(OpcodeInfo { name: "ldind.i4", operand: OperandKind::InlineNone }), + 75 => Some(OpcodeInfo { name: "ldind.u4", operand: OperandKind::InlineNone }), + 76 => Some(OpcodeInfo { name: "ldind.i8", operand: OperandKind::InlineNone }), + 77 => Some(OpcodeInfo { name: "ldind.i", operand: OperandKind::InlineNone }), + 78 => Some(OpcodeInfo { name: "ldind.r4", operand: OperandKind::InlineNone }), + 79 => Some(OpcodeInfo { name: "ldind.r8", operand: OperandKind::InlineNone }), + 8 => Some(OpcodeInfo { name: "ldloc.2", operand: OperandKind::InlineNone }), + 80 => Some(OpcodeInfo { name: "ldind.ref", operand: OperandKind::InlineNone }), + 81 => Some(OpcodeInfo { name: "stind.ref", operand: OperandKind::InlineNone }), + 82 => Some(OpcodeInfo { name: "stind.i1", operand: OperandKind::InlineNone }), + 83 => Some(OpcodeInfo { name: "stind.i2", operand: OperandKind::InlineNone }), + 84 => Some(OpcodeInfo { name: "stind.i4", operand: OperandKind::InlineNone }), + 85 => Some(OpcodeInfo { name: "stind.i8", operand: OperandKind::InlineNone }), + 86 => Some(OpcodeInfo { name: "stind.r4", operand: OperandKind::InlineNone }), + 87 => Some(OpcodeInfo { name: "stind.r8", operand: OperandKind::InlineNone }), + 88 => Some(OpcodeInfo { name: "add", operand: OperandKind::InlineNone }), + 89 => Some(OpcodeInfo { name: "sub", operand: OperandKind::InlineNone }), + 9 => Some(OpcodeInfo { name: "ldloc.3", operand: OperandKind::InlineNone }), + 90 => Some(OpcodeInfo { name: "mul", operand: OperandKind::InlineNone }), + 91 => Some(OpcodeInfo { name: "div", operand: OperandKind::InlineNone }), + 92 => Some(OpcodeInfo { name: "div.un", operand: OperandKind::InlineNone }), + 93 => Some(OpcodeInfo { name: "rem", operand: OperandKind::InlineNone }), + 94 => Some(OpcodeInfo { name: "rem.un", operand: OperandKind::InlineNone }), + 95 => Some(OpcodeInfo { name: "and", operand: OperandKind::InlineNone }), + 96 => Some(OpcodeInfo { name: "or", operand: OperandKind::InlineNone }), + 97 => Some(OpcodeInfo { name: "xor", operand: OperandKind::InlineNone }), + 98 => Some(OpcodeInfo { name: "shl", operand: OperandKind::InlineNone }), + 99 => Some(OpcodeInfo { name: "shr", operand: OperandKind::InlineNone }), + _ => None, + } +} diff --git a/crates/managed/src/lib.rs b/crates/managed/src/lib.rs new file mode 100644 index 0000000..28697ee --- /dev/null +++ b/crates/managed/src/lib.rs @@ -0,0 +1,2500 @@ +//! Shared managed assembly metadata helpers. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use clrmeta::reader::Reader; +use clrmeta::{ + AssemblyInfo, AssemblyRefInfo, CodedIndex, CodedIndexKind, FieldSig, Metadata, MethodSig, + PropertySig, ResolvedType, TableId, TypeSig, +}; +use goblin::pe::PE; +use goblin::pe::section_table::SectionTable; +use regex_lite::Regex; +use serde::Serialize; +use thiserror::Error; + +mod flow; +mod reference_diagnose; + +pub use flow::*; +pub use reference_diagnose::*; + +/// Errors produced while inspecting managed assemblies. +#[derive(Debug, Error)] +pub enum ManagedError { + /// The assembly path could not be read. + #[error("failed to read {path}: {message}")] + Read { + /// Path that failed. + path: PathBuf, + /// Error detail. + message: String, + }, + /// The file is not a PE image. + #[error("{path} is not a PE image")] + NotPe { + /// Path that failed. + path: PathBuf, + }, + /// The PE image does not contain CLR metadata. + #[error("{path} is not a managed .NET assembly")] + NotManaged { + /// Path that failed. + path: PathBuf, + }, + /// CLR metadata could not be parsed. + #[error("failed to parse CLR metadata from {path}: {message}")] + Metadata { + /// Path that failed. + path: PathBuf, + /// Error detail. + message: String, + }, + /// The requested managed query could not be resolved uniquely. + #[error("{message}")] + Query { + /// Human-readable query failure detail. + message: String, + }, +} + +/// Filters for type queries. +#[derive(Debug, Clone, Default)] +pub struct TypeQuery { + /// Optional regex applied to the full type name. + pub match_pattern: Option, + /// Optional regex applied to the namespace. + pub namespace_pattern: Option, + /// Optional type-kind filter. + pub kind: Option, + /// Restrict results to public or nested-public types. + pub public_only: bool, + /// Optional regex applied to the resolved base type full name. + pub base_pattern: Option, + /// Optional regex applied to any resolved interface full name. + pub interface_pattern: Option, + /// Maximum number of rows to return. + pub limit: Option, +} + +/// Filters for member queries. +#[derive(Debug, Clone, Default)] +pub struct MemberQuery { + /// Type full names to inspect. + pub type_names: Vec, + /// Optional member-kind filter. + pub kind: Option, + /// Optional regex applied to member names. + pub match_pattern: Option, + /// Binding-style visibility filter. + pub binding: BindingFilter, + /// Whether to include compiler special-name methods in method output. + pub include_special: bool, + /// Whether to hide compiler-generated backing fields and closure artifacts. + pub user_code_only: bool, + /// Maximum number of rows to return. + pub limit: Option, +} + +/// Binding-style visibility and scope filters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub struct BindingFilter { + /// Include public members. + pub include_public: bool, + /// Include non-public members. + pub include_non_public: bool, + /// Include instance members. + pub include_instance: bool, + /// Include static members. + pub include_static: bool, +} + +impl Default for BindingFilter { + fn default() -> Self { + Self { + include_public: true, + include_non_public: false, + include_instance: true, + include_static: true, + } + } +} + +/// Query settings for reference inspection. +#[derive(Debug, Clone, Default)] +pub struct ReferenceQuery { + /// Additional directories to search while resolving references. + pub resolve_dirs: Vec, +} + +/// Summary of one managed assembly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssemblyDescriptor { + /// Assembly file path. + pub path: PathBuf, + /// Simple assembly name. + pub assembly_name: String, + /// Assembly version in dotted form. + pub assembly_version: Option, + /// CLR metadata runtime version string. + pub runtime_version: String, + /// Whether the assembly contains IL-only code. + pub is_il_only: bool, + /// Whether the assembly is marked as a library. + pub is_library: bool, + /// Whether the assembly is strong-name signed. + pub is_strong_name_signed: bool, + /// Assembly public key token, when available. + pub public_key_token: Option, +} + +/// Type inspection output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TypeDescriptor { + /// Assembly file path. + pub assembly_path: PathBuf, + /// Simple assembly name. + pub assembly_name: String, + /// Full type name. + pub full_name: String, + /// Namespace, if any. + pub namespace: Option, + /// Simple type name. + pub name: String, + /// Type kind label. + pub kind: String, + /// Visibility label. + pub visibility: String, + /// Whether the type is public or nested public. + pub is_public: bool, + /// Whether the type is abstract. + pub is_abstract: bool, + /// Whether the type is sealed. + pub is_sealed: bool, + /// Resolved base type full name, if any. + pub base_type: Option, + /// Resolved interface full names. + pub interfaces: Vec, +} + +/// Parameter metadata for method or property signatures. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParameterDescriptor { + /// Parameter name, if present in metadata. + pub name: Option, + /// Rendered parameter type. + pub parameter_type: String, +} + +/// Member inspection output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MemberDescriptor { + /// Method output. + Method { + /// Assembly file path. + assembly_path: PathBuf, + /// Simple assembly name. + assembly_name: String, + /// Declaring type full name. + type_name: String, + /// Method name. + name: String, + /// Visibility label. + visibility: String, + /// Whether the method is static. + is_static: bool, + /// Whether the method is virtual. + is_virtual: bool, + /// Whether the method is abstract. + is_abstract: bool, + /// Return type. + return_type: String, + /// Parameter list. + parameters: Vec, + /// Human-readable signature. + signature: String, + }, + /// Field output. + Field { + /// Assembly file path. + assembly_path: PathBuf, + /// Simple assembly name. + assembly_name: String, + /// Declaring type full name. + type_name: String, + /// Field name. + name: String, + /// Visibility label. + visibility: String, + /// Whether the field is static. + is_static: bool, + /// Whether the field is a literal constant. + is_literal: bool, + /// Whether the field is init-only. + is_init_only: bool, + /// Field type. + field_type: String, + /// Human-readable signature. + signature: String, + }, + /// Property output. + Property { + /// Assembly file path. + assembly_path: PathBuf, + /// Simple assembly name. + assembly_name: String, + /// Declaring type full name. + type_name: String, + /// Property name. + name: String, + /// Effective visibility label. + visibility: String, + /// Whether the property is static. + is_static: bool, + /// Property type. + property_type: String, + /// Indexer parameters, if any. + parameters: Vec, + /// Getter visibility, if present. + getter_visibility: Option, + /// Setter visibility, if present. + setter_visibility: Option, + /// Human-readable signature. + signature: String, + }, +} + +/// Visibility scope used by managed API diffing. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiVisibilityScope { + /// Public and nested-public types plus public methods. + #[default] + Public, + /// Public and assembly/protected API, excluding private members. + Internal, + /// Every metadata-visible type and method. + All, +} + +impl ApiVisibilityScope { + /// Returns true when the type visibility is included in this scope. + #[must_use] + pub fn includes_type(self, visibility: &str) -> bool { + match self { + Self::Public => matches!(visibility, "public" | "nested_public"), + Self::Internal => !matches!(visibility, "nested_private"), + Self::All => true, + } + } + + /// Returns true when the member visibility is included in this scope. + #[must_use] + pub fn includes_member(self, visibility: &str) -> bool { + match self { + Self::Public => visibility == "public", + Self::Internal => visibility != "private", + Self::All => true, + } + } +} + +/// Query settings for managed API diffing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ApiDiffQuery { + /// Visibility scope to compare. + pub visibility: ApiVisibilityScope, + /// Include special-name methods such as property accessors. + pub include_special: bool, + /// Include `MissingMethodException` risk rows in the report. + pub include_missing_method_risks: bool, +} + +impl Default for ApiDiffQuery { + fn default() -> Self { + Self { + visibility: ApiVisibilityScope::Public, + include_special: false, + include_missing_method_risks: true, + } + } +} + +/// Normalized managed type row used by API diffing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApiTypeRow { + /// Full type name. + pub full_name: String, + /// Namespace, if any. + pub namespace: Option, + /// Simple type name. + pub name: String, + /// Type kind label. + pub kind: String, + /// Visibility label. + pub visibility: String, + /// Whether the type is public or nested public. + pub is_public: bool, +} + +/// Normalized managed method row used by API diffing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApiMethodRow { + /// Declaring type full name. + pub type_name: String, + /// Method name. + pub name: String, + /// Visibility label. + pub visibility: String, + /// Whether the method is static. + pub is_static: bool, + /// Whether the method is virtual. + pub is_virtual: bool, + /// Whether the method is abstract. + pub is_abstract: bool, + /// Return type. + pub return_type: String, + /// Parameter list. + pub parameters: Vec, + /// Human-readable method signature. + pub signature: String, +} + +/// Normalized API surface for one managed assembly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssemblyApiSnapshot { + /// Assembly summary. + pub assembly: AssemblyDescriptor, + /// Normalized type rows. + pub types: Vec, + /// Normalized method rows. + pub methods: Vec, +} + +/// Summary counts for a managed API diff report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApiDiffSummary { + /// Removed type count. + pub removed_types: usize, + /// Added type count. + pub added_types: usize, + /// Removed method count. + pub removed_methods: usize, + /// Added method count. + pub added_methods: usize, + /// Changed method-name group count. + pub signature_changed_methods: usize, + /// `MissingMethodException` risk row count. + pub missing_method_risks: usize, +} + +/// Added or removed type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApiTypeChange { + /// Full type name. + pub type_name: String, + /// Type kind label. + pub kind: String, + /// Visibility label. + pub visibility: String, +} + +/// Added or removed method, or a changed method-name group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApiMethodChange { + /// Declaring type full name. + pub type_name: String, + /// Method name. + pub method_name: String, + /// Old signatures in this change. + pub old_signatures: Vec, + /// New signatures in this change. + pub new_signatures: Vec, +} + +/// Likely `MissingMethodException` compatibility risk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MissingMethodRisk { + /// Assembly that previously exposed the method. + pub old_assembly: String, + /// Declaring type full name. + pub type_name: String, + /// Method name. + pub method_name: String, + /// Old rendered method signature. + pub old_signature: String, + /// Risk reason. + pub reason: String, +} + +/// Managed API diff report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApiDiffReport { + /// Old assembly summary. + pub old_assembly: AssemblyDescriptor, + /// New assembly summary. + pub new_assembly: AssemblyDescriptor, + /// Compared visibility scope. + pub visibility: ApiVisibilityScope, + /// Summary counts. + pub summary: ApiDiffSummary, + /// Removed types. + pub removed_types: Vec, + /// Added types. + pub added_types: Vec, + /// Removed methods. + pub removed_methods: Vec, + /// Added methods. + pub added_methods: Vec, + /// Changed method-name groups. + pub signature_changed_methods: Vec, + /// Likely `MissingMethodException` risks. + pub missing_method_risks: Vec, +} + +/// Resolved assembly reference output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssemblyReferenceDescriptor { + /// Referenced assembly name. + pub name: String, + /// Referenced version string. + pub version: String, + /// Referenced culture string, if present. + pub culture: Option, + /// Public key token hex string, if present. + pub public_key_token: Option, + /// Whether the reference resolved to a file path. + pub resolved: bool, + /// Resolved file path, if found. + pub resolved_path: Option, + /// Resolution source label. + pub resolution_source: Option, + /// Whether the reference looks like a framework assembly. + pub is_framework_reference: bool, +} + +/// Report for one assembly and its references. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssemblyReferenceReport { + /// Assembly summary. + pub assembly: AssemblyDescriptor, + /// Reference rows. + pub references: Vec, +} + +#[derive(Debug)] +struct ManagedAssembly { + path: PathBuf, + bytes: Vec, + sections: Vec, + metadata: Metadata, + descriptor: AssemblyDescriptor, + nested_parent_by_child: HashMap, +} + +#[derive(Debug, Clone, Copy)] +struct PeSectionSpan { + virtual_address: usize, + read_size: usize, + raw_offset: usize, +} + +/// Lists managed types for one or more assembly paths. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when an assembly cannot be read or parsed. +pub fn list_types( + paths: &[PathBuf], + query: &TypeQuery, +) -> Result, ManagedError> { + let mut rows = Vec::new(); + for path in paths { + let assembly = ManagedAssembly::load(path)?; + let Some(last_type_index) = u32::try_from(assembly.metadata.type_defs.len()).ok() else { + return Ok(rows); + }; + for index in 1..=last_type_index { + let descriptor = assembly.type_descriptor(index); + if type_matches(&descriptor, query) { + rows.push(descriptor); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + return Ok(rows); + } + } + } + } + Ok(rows) +} + +/// Lists managed members for one or more assemblies and type names. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when an assembly cannot be read or parsed. +/// +/// # Panics +/// +/// This function does not panic. +pub fn list_members( + assembly_paths: &[PathBuf], + query: &MemberQuery, +) -> Result, ManagedError> { + let mut rows = Vec::new(); + for path in assembly_paths { + let assembly = ManagedAssembly::load(path)?; + for type_name in &query.type_names { + if let Some(index) = assembly.find_type_index(type_name) { + let mut members = assembly.member_descriptors(index, query); + rows.append(&mut members); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + if let Some(limit) = query.limit { + rows.truncate(limit); + } + return Ok(rows); + } + } + } + } + Ok(rows) +} + +/// Inspects assembly references for one or more assemblies. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when an assembly cannot be read or parsed. +pub fn inspect_references( + assembly_paths: &[PathBuf], + query: &ReferenceQuery, +) -> Result, ManagedError> { + let mut reports = Vec::new(); + for path in assembly_paths { + let assembly = ManagedAssembly::load(path)?; + reports.push(assembly.reference_report(query)); + } + Ok(reports) +} + +/// Builds a normalized managed API snapshot for one assembly. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when the assembly cannot be read or parsed. +pub fn snapshot_assembly_api( + path: &Path, + query: &ApiDiffQuery, +) -> Result { + let assembly = ManagedAssembly::load(path)?; + let mut types = Vec::new(); + let mut methods = Vec::new(); + let Some(last_type_index) = u32::try_from(assembly.metadata.type_defs.len()).ok() else { + return Ok(AssemblyApiSnapshot { + assembly: assembly.descriptor, + types, + methods, + }); + }; + + for index in 1..=last_type_index { + let type_row = ApiTypeRow::from(assembly.type_descriptor(index)); + if query.visibility.includes_type(&type_row.visibility) { + methods.extend(assembly.api_method_rows(index, *query)); + types.push(type_row); + } + } + + Ok(AssemblyApiSnapshot { + assembly: assembly.descriptor, + types, + methods, + }) +} + +/// Diffs two managed assemblies after building API snapshots. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when either assembly cannot be read or parsed. +pub fn diff_assembly_api( + old_path: &Path, + new_path: &Path, + query: &ApiDiffQuery, +) -> Result { + let old = snapshot_assembly_api(old_path, query)?; + let new = snapshot_assembly_api(new_path, query)?; + Ok(diff_api_snapshots(old, new, query)) +} + +/// Diffs two normalized managed API snapshots. +#[must_use] +pub fn diff_api_snapshots( + old: AssemblyApiSnapshot, + new: AssemblyApiSnapshot, + query: &ApiDiffQuery, +) -> ApiDiffReport { + let old_types = filtered_type_map(&old, query.visibility); + let new_types = filtered_type_map(&new, query.visibility); + let old_methods = filtered_method_groups(&old, query.visibility, &old_types); + let new_methods = filtered_method_groups(&new, query.visibility, &new_types); + + let mut removed_types = old_types + .iter() + .filter(|(key, _)| !new_types.contains_key(*key)) + .map(|(_, row)| ApiTypeChange::from(row)) + .collect::>(); + let mut added_types = new_types + .iter() + .filter(|(key, _)| !old_types.contains_key(*key)) + .map(|(_, row)| ApiTypeChange::from(row)) + .collect::>(); + + let mut removed_methods = Vec::new(); + let mut added_methods = Vec::new(); + let mut signature_changed_methods = Vec::new(); + + for (group, rows) in &old_methods { + if !new_methods.contains_key(group) { + removed_methods.extend(rows.iter().map(method_change_removed)); + } + } + for (group, rows) in &new_methods { + if !old_methods.contains_key(group) { + added_methods.extend(rows.iter().map(method_change_added)); + } + } + + for (group, old_rows) in old_methods + .iter() + .filter(|(group, _)| new_methods.contains_key(*group)) + { + let new_rows = &new_methods[group]; + let old_signatures = method_signature_map(old_rows); + let new_signatures = method_signature_map(new_rows); + if old_signatures.keys().eq(new_signatures.keys()) { + continue; + } + + signature_changed_methods.push(ApiMethodChange { + type_name: group.0.clone(), + method_name: group.1.clone(), + old_signatures: old_signatures + .values() + .map(|row| row.signature.clone()) + .collect(), + new_signatures: new_signatures + .values() + .map(|row| row.signature.clone()) + .collect(), + }); + + for key in old_signatures + .keys() + .filter(|key| !new_signatures.contains_key(*key)) + { + if let Some(row) = old_signatures.get(key) { + removed_methods.push(method_change_removed(row)); + } + } + for key in new_signatures + .keys() + .filter(|key| !old_signatures.contains_key(*key)) + { + if let Some(row) = new_signatures.get(key) { + added_methods.push(method_change_added(row)); + } + } + } + + sort_type_changes(&mut removed_types); + sort_type_changes(&mut added_types); + sort_method_changes(&mut removed_methods); + sort_method_changes(&mut added_methods); + sort_method_changes(&mut signature_changed_methods); + removed_methods.dedup(); + added_methods.dedup(); + + let missing_method_risks = if query.include_missing_method_risks { + missing_method_risks(&old.assembly, &removed_methods) + } else { + Vec::new() + }; + + let summary = ApiDiffSummary { + removed_types: removed_types.len(), + added_types: added_types.len(), + removed_methods: removed_methods.len(), + added_methods: added_methods.len(), + signature_changed_methods: signature_changed_methods.len(), + missing_method_risks: missing_method_risks.len(), + }; + + ApiDiffReport { + old_assembly: old.assembly, + new_assembly: new.assembly, + visibility: query.visibility, + summary, + removed_types, + added_types, + removed_methods, + added_methods, + signature_changed_methods, + missing_method_risks, + } +} + +impl ManagedAssembly { + fn load(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| ManagedError::Read { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let pe = PE::parse(&bytes).map_err(|_| ManagedError::NotPe { + path: path.to_path_buf(), + })?; + let clr_data = pe.clr_data.ok_or_else(|| ManagedError::NotManaged { + path: path.to_path_buf(), + })?; + let metadata_bytes = metadata_bytes(&bytes, &pe, clr_data.cor20_header.metadata) + .ok_or_else(|| ManagedError::Metadata { + path: path.to_path_buf(), + message: "failed to map CLR metadata RVA into file bytes".to_string(), + })?; + let metadata = Metadata::parse(metadata_bytes).map_err(|error| ManagedError::Metadata { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let assembly_info = metadata.assembly(); + let assembly_name = assembly_info + .as_ref() + .map_or_else(|| file_stem(path), |info| info.name.clone()); + let assembly_version = assembly_info.as_ref().map(AssemblyInfo::version_string); + let public_key_token = assembly_info + .as_ref() + .and_then(AssemblyInfo::public_key_token_string); + let descriptor = AssemblyDescriptor { + path: path.to_path_buf(), + assembly_name, + assembly_version, + runtime_version: metadata.version().to_string(), + is_il_only: clr_data.cor20_header.is_il_only(), + is_library: clr_data.cor20_header.is_il_library(), + is_strong_name_signed: clr_data.cor20_header.is_strong_name_signed(), + public_key_token, + }; + let file_alignment = pe + .header + .optional_header + .map(|header| header.windows_fields.file_alignment) + .ok_or_else(|| ManagedError::Metadata { + path: path.to_path_buf(), + message: "missing PE optional header".to_string(), + })?; + let sections = pe + .sections + .iter() + .map(|section| PeSectionSpan { + virtual_address: section.virtual_address as usize, + read_size: section_read_size(section, file_alignment), + raw_offset: section.pointer_to_raw_data as usize, + }) + .collect::>(); + let nested_parent_by_child = metadata + .nested_classes + .iter() + .map(|row| (row.nested_class, row.enclosing_class)) + .collect::>(); + + Ok(Self { + path: path.to_path_buf(), + bytes, + sections, + metadata, + descriptor, + nested_parent_by_child, + }) + } + + fn bytes_at_rva(&self, rva: u32, size: usize) -> Option<&[u8]> { + let offset = self.offset_for_rva(rva)?; + self.bytes.get(offset..offset.checked_add(size)?) + } + + fn offset_for_rva(&self, rva: u32) -> Option { + let rva = rva as usize; + self.sections.iter().find_map(|section| { + let end = section.virtual_address.checked_add(section.read_size)?; + if rva < section.virtual_address || rva >= end { + return None; + } + section + .raw_offset + .checked_add(rva - section.virtual_address) + }) + } + + fn type_descriptor(&self, index: u32) -> TypeDescriptor { + let row = self + .metadata + .get_type_def(index) + .expect("type index should be in range"); + let name = self.string_or_empty(row.type_name); + let namespace = self.string_option(row.type_namespace); + let full_name = self.full_type_name(index); + let base_type = self + .metadata + .get_base_type(index) + .map(|base| self.resolved_type_name(&base)); + let interfaces = self + .metadata + .get_interfaces(index) + .into_iter() + .map(|item| self.resolved_type_name(&item)) + .collect::>(); + let visibility = type_visibility_label(row.flags).to_string(); + let kind = self.type_kind(index).to_string(); + + TypeDescriptor { + assembly_path: self.descriptor.path.clone(), + assembly_name: self.descriptor.assembly_name.clone(), + full_name, + namespace, + name, + kind, + visibility, + is_public: is_public_type(row.flags), + is_abstract: (row.flags & 0x0000_0080) != 0, + is_sealed: (row.flags & 0x0000_0100) != 0, + base_type, + interfaces, + } + } + + fn find_type_index(&self, full_name: &str) -> Option { + let last_type_index = u32::try_from(self.metadata.type_defs.len()).ok()?; + (1..=last_type_index).find(|index| self.full_type_name(*index) == full_name) + } + + fn member_descriptors(&self, type_index: u32, query: &MemberQuery) -> Vec { + let type_name = self.full_type_name(type_index); + let mut rows = Vec::new(); + let include_methods = member_kind_enabled(query.kind.as_deref(), "method"); + let include_fields = member_kind_enabled(query.kind.as_deref(), "field"); + let include_properties = member_kind_enabled(query.kind.as_deref(), "property"); + + if include_methods { + for item in self.method_descriptors(type_index, &type_name, query) { + if member_matches(&item, query) { + rows.push(item); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + return rows; + } + } + } + } + + if include_fields { + for item in self.field_descriptors(type_index, &type_name, query) { + if member_matches(&item, query) { + rows.push(item); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + return rows; + } + } + } + } + + if include_properties { + for item in self.property_descriptors(type_index, &type_name, query) { + if member_matches(&item, query) { + rows.push(item); + if query.limit.is_some_and(|limit| rows.len() >= limit) { + return rows; + } + } + } + } + + rows + } + + fn api_method_rows(&self, type_index: u32, query: ApiDiffQuery) -> Vec { + let type_name = self.full_type_name(type_index); + self.method_descriptors( + type_index, + &type_name, + &MemberQuery { + kind: Some("method".to_string()), + binding: BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: true, + }, + include_special: query.include_special, + ..MemberQuery::default() + }, + ) + .into_iter() + .filter_map(|member| match member { + MemberDescriptor::Method { + type_name, + name, + visibility, + is_static, + is_virtual, + is_abstract, + return_type, + parameters, + signature, + .. + } if query.visibility.includes_member(&visibility) => Some(ApiMethodRow { + type_name, + name, + visibility, + is_static, + is_virtual, + is_abstract, + return_type, + parameters, + signature, + }), + _ => None, + }) + .collect() + } + + fn method_descriptors( + &self, + type_index: u32, + type_name: &str, + query: &MemberQuery, + ) -> Vec { + self.metadata + .get_type_methods(type_index) + .into_iter() + .filter_map(|(method_index, row)| { + if !query.include_special && (row.flags & 0x0800) != 0 { + return None; + } + if !query.binding.matches_method(row.flags) { + return None; + } + + let name = self.string_or_empty(row.name); + let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; + let signature = MethodSig::parse_blob(signature_bytes).ok()?; + let return_type = self.format_type_sig(&signature.return_type); + let parameters = self.method_parameters(method_index, &signature); + let signature_text = format_method_signature(&name, &return_type, ¶meters); + + Some(MemberDescriptor::Method { + assembly_path: self.descriptor.path.clone(), + assembly_name: self.descriptor.assembly_name.clone(), + type_name: type_name.to_string(), + name, + visibility: member_visibility_label(row.flags).to_string(), + is_static: (row.flags & 0x0010) != 0, + is_virtual: (row.flags & 0x0040) != 0, + is_abstract: (row.flags & 0x0400) != 0, + return_type, + parameters, + signature: signature_text, + }) + }) + .collect() + } + + fn field_descriptors( + &self, + type_index: u32, + type_name: &str, + query: &MemberQuery, + ) -> Vec { + self.metadata + .get_type_fields(type_index) + .into_iter() + .filter_map(|(_, row)| { + if !query.binding.matches_field(row.flags) { + return None; + } + + let name = self.string_or_empty(row.name); + let signature_bytes = self.metadata.blobs.get(row.signature).ok()?; + let signature = FieldSig::parse_blob(signature_bytes).ok()?; + let field_type = self.format_type_sig(&signature.field_type); + let visibility = member_visibility_label(row.flags).to_string(); + let signature_text = format_field_signature( + &field_type, + &name, + &visibility, + (row.flags & 0x0010) != 0, + (row.flags & 0x0040) != 0, + (row.flags & 0x0020) != 0, + ); + + Some(MemberDescriptor::Field { + assembly_path: self.descriptor.path.clone(), + assembly_name: self.descriptor.assembly_name.clone(), + type_name: type_name.to_string(), + name, + visibility, + is_static: (row.flags & 0x0010) != 0, + is_literal: (row.flags & 0x0040) != 0, + is_init_only: (row.flags & 0x0020) != 0, + field_type, + signature: signature_text, + }) + }) + .collect() + } + + fn property_descriptors( + &self, + type_index: u32, + type_name: &str, + query: &MemberQuery, + ) -> Vec { + self.get_type_properties(type_index) + .into_iter() + .filter_map(|(property_index, row)| { + let accessor_rows = self.property_accessors(property_index); + if !query.binding.matches_property(&accessor_rows) { + return None; + } + + let name = self.string_or_empty(row.name); + let signature_bytes = self.metadata.blobs.get(row.property_type).ok()?; + let signature = PropertySig::parse_blob(signature_bytes).ok()?; + let property_type = self.format_type_sig(&signature.property_type); + let parameters = signature + .params + .iter() + .map(|param| ParameterDescriptor { + name: None, + parameter_type: self.format_type_sig(param), + }) + .collect::>(); + let getter_visibility = accessor_rows.iter().find_map(|item| { + item.semantics + .contains("getter") + .then(|| item.visibility.clone()) + }); + let setter_visibility = accessor_rows.iter().find_map(|item| { + item.semantics + .contains("setter") + .then(|| item.visibility.clone()) + }); + let is_static = accessor_rows.iter().any(|item| item.is_static); + let visibility = getter_visibility + .clone() + .or_else(|| setter_visibility.clone()) + .unwrap_or_else(|| "private".to_string()); + let signature_text = format_property_signature( + &property_type, + &name, + ¶meters, + getter_visibility.as_deref(), + setter_visibility.as_deref(), + is_static, + ); + + Some(MemberDescriptor::Property { + assembly_path: self.descriptor.path.clone(), + assembly_name: self.descriptor.assembly_name.clone(), + type_name: type_name.to_string(), + name, + visibility, + is_static, + property_type, + parameters, + getter_visibility, + setter_visibility, + signature: signature_text, + }) + }) + .collect() + } + + fn reference_report(&self, query: &ReferenceQuery) -> AssemblyReferenceReport { + let resolve_dirs = self + .path + .parent() + .map(Path::to_path_buf) + .into_iter() + .chain(query.resolve_dirs.iter().cloned()) + .collect::>(); + let references = self + .metadata + .assembly_refs() + .into_iter() + .map(|item| resolve_reference(&item, &resolve_dirs)) + .collect::>(); + + AssemblyReferenceReport { + assembly: self.descriptor.clone(), + references, + } + } + + fn full_type_name(&self, index: u32) -> String { + let row = self + .metadata + .get_type_def(index) + .expect("type index should be in range"); + let name = self.string_or_empty(row.type_name); + if let Some(parent) = self.nested_parent_by_child.get(&index) { + return format!("{}+{}", self.full_type_name(*parent), name); + } + let namespace = self.string_option(row.type_namespace); + if let Some(namespace) = namespace + && !namespace.is_empty() + { + return format!("{namespace}.{name}"); + } + name + } + + fn type_kind(&self, index: u32) -> &'static str { + let row = self + .metadata + .get_type_def(index) + .expect("type index should be in range"); + if (row.flags & 0x20) != 0 { + return "interface"; + } + match self + .metadata + .get_base_type(index) + .map(|item| self.resolved_type_name(&item)) + { + Some(base) if base == "System.Enum" => "enum", + Some(base) if base == "System.MulticastDelegate" => "delegate", + Some(base) if base == "System.ValueType" => "struct", + _ => "class", + } + } + + fn resolved_type_name(&self, resolved: &ResolvedType) -> String { + match resolved { + ResolvedType::TypeSpec { signature, .. } => { + let signature_bytes = self.metadata.blobs.get(*signature).ok(); + signature_bytes + .and_then(parse_type_sig_blob) + .map_or_else(|| resolved.full_name(), |sig| self.format_type_sig(&sig)) + } + _ => resolved.full_name(), + } + } + + fn format_type_sig(&self, signature: &TypeSig) -> String { + match signature { + TypeSig::Primitive(element) => element.name().to_string(), + TypeSig::Class(token) | TypeSig::ValueType(token) => self + .metadata + .resolve_type(&CodedIndex::decode(CodedIndexKind::TypeDefOrRef, *token)) + .map_or_else( + || format!(""), + |item| self.resolved_type_name(&item), + ), + TypeSig::SzArray(inner) => format!("{}[]", self.format_type_sig(inner)), + TypeSig::Array { + element_type, rank, .. + } => { + format!( + "{}[{}]", + self.format_type_sig(element_type), + ",".repeat(rank.saturating_sub(1) as usize) + ) + } + TypeSig::Ptr(inner) => format!("{}*", self.format_type_sig(inner)), + TypeSig::ByRef(inner) => format!("{}&", self.format_type_sig(inner)), + TypeSig::GenericInst { + is_value_type: _, + type_ref, + type_args, + } => { + let base = self + .metadata + .resolve_type(&CodedIndex::decode(CodedIndexKind::TypeDefOrRef, *type_ref)) + .map_or_else( + || format!(""), + |item| self.resolved_type_name(&item), + ); + let args = type_args + .iter() + .map(|item| self.format_type_sig(item)) + .collect::>() + .join(", "); + format!("{base}<{args}>") + } + TypeSig::Var(index) => format!("!{index}"), + TypeSig::MVar(index) => format!("!!{index}"), + TypeSig::FnPtr(method) => format!("fnptr {}", self.format_method_sig(method, "invoke")), + TypeSig::Modified { + required, + modifier, + inner, + } => { + let label = if *required { "modreq" } else { "modopt" }; + format!( + "{label}() {}", + self.format_type_sig(inner) + ) + } + TypeSig::Pinned(inner) => format!("pinned {}", self.format_type_sig(inner)), + _ => "".to_string(), + } + } + + fn format_method_sig(&self, signature: &MethodSig, name: &str) -> String { + let return_type = self.format_type_sig(&signature.return_type); + let parameters = signature + .params + .iter() + .map(|item| ParameterDescriptor { + name: None, + parameter_type: self.format_type_sig(item), + }) + .collect::>(); + format_method_signature(name, &return_type, ¶meters) + } + + fn method_parameters( + &self, + method_index: u32, + signature: &MethodSig, + ) -> Vec { + let param_rows = self.get_method_params(method_index); + signature + .params + .iter() + .enumerate() + .map(|(index, item)| { + let name = param_rows + .iter() + .find(|(_, row)| usize::from(row.sequence) == index + 1) + .map(|(_, row)| self.string_or_empty(row.name)); + ParameterDescriptor { + name, + parameter_type: self.format_type_sig(item), + } + }) + .collect() + } + + fn get_method_params(&self, method_index: u32) -> Vec<(u32, &clrmeta::ParamRow)> { + let Some(row) = method_index + .checked_sub(1) + .and_then(|index| self.metadata.method_defs.get(index as usize)) + else { + return Vec::new(); + }; + let start = row.param_list; + let end = method_index + .checked_add(1) + .and_then(|next_index| self.metadata.method_defs.get((next_index - 1) as usize)) + .map_or_else( + || length_index(&self.metadata.params), + |item| item.param_list, + ); + ((start as usize)..(end as usize)) + .filter_map(|index| { + if index > 0 && index <= self.metadata.params.len() { + Some((u32::try_from(index).ok()?, &self.metadata.params[index - 1])) + } else { + None + } + }) + .collect() + } + + fn get_type_properties(&self, type_index: u32) -> Vec<(u32, &clrmeta::PropertyRow)> { + let Some(start) = self + .metadata + .property_maps + .iter() + .find(|row| row.parent == type_index) + .map(|row| row.property_list) + else { + return Vec::new(); + }; + let end = self + .metadata + .property_maps + .iter() + .filter(|row| row.parent > type_index) + .map(|row| row.property_list) + .min() + .unwrap_or_else(|| length_index(&self.metadata.properties)); + ((start as usize)..(end as usize)) + .filter_map(|index| { + if index > 0 && index <= self.metadata.properties.len() { + Some(( + u32::try_from(index).ok()?, + &self.metadata.properties[index - 1], + )) + } else { + None + } + }) + .collect() + } + + fn property_accessors(&self, property_index: u32) -> Vec { + self.metadata + .method_semantics + .iter() + .filter(|row| { + row.association.table == Some(TableId::Property) + && row.association.row == property_index + }) + .filter_map(|row| { + let method = row + .method + .checked_sub(1) + .and_then(|index| self.metadata.method_defs.get(index as usize))?; + Some(PropertyAccessor { + visibility: member_visibility_label(method.flags).to_string(), + is_static: (method.flags & 0x0010) != 0, + semantics: method_semantics_label(row.semantics), + }) + }) + .collect() + } + + fn string_or_empty(&self, index: u32) -> String { + self.metadata + .strings + .get(index) + .map_or_else(|_| String::new(), ToString::to_string) + } + + fn string_option(&self, index: u32) -> Option { + if index == 0 { + return None; + } + self.metadata + .strings + .get(index) + .ok() + .map(ToString::to_string) + } +} + +#[derive(Debug, Clone)] +struct PropertyAccessor { + visibility: String, + is_static: bool, + semantics: String, +} + +fn resolve_reference( + reference: &AssemblyRefInfo, + resolve_dirs: &[PathBuf], +) -> AssemblyReferenceDescriptor { + let resolved = resolve_dirs.iter().find_map(|directory| { + let dll_path = directory.join(format!("{}.dll", reference.name)); + if dll_path.is_file() { + return Some((dll_path, "resolve_dir".to_string())); + } + let exe_path = directory.join(format!("{}.exe", reference.name)); + exe_path + .is_file() + .then(|| (exe_path, "resolve_dir".to_string())) + }); + + let (resolved_path, resolution_source, resolved_flag) = match resolved { + Some((path, source)) => (Some(path), Some(source), true), + None => (None, None, false), + }; + + AssemblyReferenceDescriptor { + name: reference.name.clone(), + version: reference.version_string(), + culture: reference.culture.clone(), + public_key_token: reference + .public_key_token + .as_ref() + .map(|value| bytes_to_hex(value)), + resolved: resolved_flag, + resolved_path, + resolution_source, + is_framework_reference: is_framework_reference(&reference.name), + } +} + +fn type_matches(descriptor: &TypeDescriptor, query: &TypeQuery) -> bool { + if query.public_only && !descriptor.is_public { + return false; + } + if let Some(kind) = &query.kind + && descriptor.kind != *kind + { + return false; + } + if let Some(pattern) = &query.match_pattern + && !pattern.is_match(&descriptor.full_name) + { + return false; + } + if let Some(pattern) = &query.namespace_pattern { + let namespace = descriptor.namespace.as_deref().unwrap_or_default(); + if !pattern.is_match(namespace) { + return false; + } + } + if let Some(pattern) = &query.base_pattern { + let base = descriptor.base_type.as_deref().unwrap_or_default(); + if !pattern.is_match(base) { + return false; + } + } + if let Some(pattern) = &query.interface_pattern + && !descriptor + .interfaces + .iter() + .any(|item| pattern.is_match(item)) + { + return false; + } + true +} + +fn member_matches(descriptor: &MemberDescriptor, query: &MemberQuery) -> bool { + let name = match descriptor { + MemberDescriptor::Method { name, .. } + | MemberDescriptor::Field { name, .. } + | MemberDescriptor::Property { name, .. } => name, + }; + + if query.user_code_only && is_compiler_generated_member_name(name) { + return false; + } + + if let Some(pattern) = &query.match_pattern { + if !pattern.is_match(name) { + return false; + } + } + true +} + +impl From for ApiTypeRow { + fn from(value: TypeDescriptor) -> Self { + Self { + full_name: value.full_name, + namespace: value.namespace, + name: value.name, + kind: value.kind, + visibility: value.visibility, + is_public: value.is_public, + } + } +} + +impl From<&ApiTypeRow> for ApiTypeChange { + fn from(value: &ApiTypeRow) -> Self { + Self { + type_name: value.full_name.clone(), + kind: value.kind.clone(), + visibility: value.visibility.clone(), + } + } +} + +fn filtered_type_map( + snapshot: &AssemblyApiSnapshot, + visibility: ApiVisibilityScope, +) -> BTreeMap { + snapshot + .types + .iter() + .filter(|row| visibility.includes_type(&row.visibility)) + .map(|row| (row.full_name.clone(), row.clone())) + .collect() +} + +fn filtered_method_groups( + snapshot: &AssemblyApiSnapshot, + visibility: ApiVisibilityScope, + types: &BTreeMap, +) -> BTreeMap<(String, String), Vec> { + let mut groups: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for row in &snapshot.methods { + if types.contains_key(&row.type_name) && visibility.includes_member(&row.visibility) { + groups + .entry((row.type_name.clone(), row.name.clone())) + .or_default() + .push(row.clone()); + } + } + for rows in groups.values_mut() { + rows.sort_by(|left, right| method_sort_key(left).cmp(&method_sort_key(right))); + } + groups +} + +fn method_signature_map(rows: &[ApiMethodRow]) -> BTreeMap { + rows.iter() + .map(|row| (method_fingerprint(row), row.clone())) + .collect() +} + +fn method_fingerprint(row: &ApiMethodRow) -> String { + let parameters = row + .parameters + .iter() + .map(|parameter| parameter.parameter_type.as_str()) + .collect::>() + .join("\u{1f}"); + format!( + "static={} return={} params={}", + row.is_static, row.return_type, parameters + ) +} + +fn method_change_removed(row: &ApiMethodRow) -> ApiMethodChange { + ApiMethodChange { + type_name: row.type_name.clone(), + method_name: row.name.clone(), + old_signatures: vec![row.signature.clone()], + new_signatures: Vec::new(), + } +} + +fn method_change_added(row: &ApiMethodRow) -> ApiMethodChange { + ApiMethodChange { + type_name: row.type_name.clone(), + method_name: row.name.clone(), + old_signatures: Vec::new(), + new_signatures: vec![row.signature.clone()], + } +} + +fn missing_method_risks( + old_assembly: &AssemblyDescriptor, + removed_methods: &[ApiMethodChange], +) -> Vec { + let mut rows = removed_methods + .iter() + .flat_map(|change| { + change + .old_signatures + .iter() + .map(|signature| MissingMethodRisk { + old_assembly: old_assembly.assembly_name.clone(), + type_name: change.type_name.clone(), + method_name: change.method_name.clone(), + old_signature: signature.clone(), + reason: "removed_or_changed_public_signature".to_string(), + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + ( + left.type_name.as_str(), + left.method_name.as_str(), + left.old_signature.as_str(), + ) + .cmp(&( + right.type_name.as_str(), + right.method_name.as_str(), + right.old_signature.as_str(), + )) + }); + rows.dedup(); + rows +} + +fn sort_type_changes(rows: &mut [ApiTypeChange]) { + rows.sort_by(|left, right| left.type_name.cmp(&right.type_name)); +} + +fn sort_method_changes(rows: &mut [ApiMethodChange]) { + rows.sort_by(|left, right| { + ( + left.type_name.as_str(), + left.method_name.as_str(), + left.old_signatures.first().map_or("", String::as_str), + left.new_signatures.first().map_or("", String::as_str), + ) + .cmp(&( + right.type_name.as_str(), + right.method_name.as_str(), + right.old_signatures.first().map_or("", String::as_str), + right.new_signatures.first().map_or("", String::as_str), + )) + }); +} + +fn method_sort_key(row: &ApiMethodRow) -> (&str, &str, bool, String) { + ( + row.type_name.as_str(), + row.name.as_str(), + row.is_static, + row.parameters + .iter() + .map(|parameter| parameter.parameter_type.as_str()) + .collect::>() + .join("\u{1f}"), + ) +} + +fn is_compiler_generated_member_name(name: &str) -> bool { + name.starts_with('<') + || name.starts_with("<>") + || name.starts_with("CS$<") + || name.contains(">k__BackingField") +} + +fn member_kind_enabled(kind: Option<&str>, label: &str) -> bool { + match kind { + None | Some("all") => true, + Some(value) => value == label, + } +} + +const fn is_public_type(flags: u32) -> bool { + matches!(flags & 0x0000_0007, 0x0000_0001 | 0x0000_0002) +} + +const fn type_visibility_label(flags: u32) -> &'static str { + match flags & 0x0000_0007 { + 0x0000_0001 => "public", + 0x0000_0002 => "nested_public", + 0x0000_0003 => "nested_private", + 0x0000_0004 => "nested_family", + 0x0000_0005 => "nested_assembly", + 0x0000_0006 => "nested_fam_and_assem", + 0x0000_0007 => "nested_fam_or_assem", + _ => "not_public", + } +} + +const fn member_visibility_label(flags: u16) -> &'static str { + match flags & 0x0007 { + 0x0001 => "private", + 0x0002 => "fam_and_assem", + 0x0003 => "assembly", + 0x0004 => "family", + 0x0005 => "fam_or_assem", + 0x0006 => "public", + _ => "compiler_controlled", + } +} + +fn method_semantics_label(flags: u16) -> String { + let mut parts = Vec::new(); + if (flags & 0x0001) != 0 { + parts.push("setter"); + } + if (flags & 0x0002) != 0 { + parts.push("getter"); + } + if (flags & 0x0004) != 0 { + parts.push("other"); + } + if (flags & 0x0008) != 0 { + parts.push("add_on"); + } + if (flags & 0x0010) != 0 { + parts.push("remove_on"); + } + if (flags & 0x0020) != 0 { + parts.push("fire"); + } + if parts.is_empty() { + "other".to_string() + } else { + parts.join("|") + } +} + +fn format_method_signature( + name: &str, + return_type: &str, + parameters: &[ParameterDescriptor], +) -> String { + let args = parameters + .iter() + .map(|item| { + item.name.as_ref().map_or_else( + || item.parameter_type.clone(), + |name| format!("{} {name}", item.parameter_type), + ) + }) + .collect::>() + .join(", "); + format!("{return_type} {name}({args})") +} + +fn format_field_signature( + field_type: &str, + name: &str, + visibility: &str, + is_static: bool, + is_literal: bool, + is_init_only: bool, +) -> String { + let mut prefixes = Vec::new(); + prefixes.push(visibility.to_string()); + if is_static { + prefixes.push("static".to_string()); + } + if is_literal { + prefixes.push("literal".to_string()); + } + if is_init_only { + prefixes.push("initonly".to_string()); + } + format!("{} {field_type} {name}", prefixes.join(" ")) +} + +fn format_property_signature( + property_type: &str, + name: &str, + parameters: &[ParameterDescriptor], + getter_visibility: Option<&str>, + setter_visibility: Option<&str>, + is_static: bool, +) -> String { + let property_name = if parameters.is_empty() { + name.to_string() + } else { + let args = parameters + .iter() + .map(|item| item.parameter_type.clone()) + .collect::>() + .join(", "); + format!("{name}[{args}]") + }; + let mut accessors = Vec::new(); + if let Some(visibility) = getter_visibility { + accessors.push(accessor_signature("get", visibility)); + } + if let Some(visibility) = setter_visibility { + accessors.push(accessor_signature("set", visibility)); + } + let static_prefix = if is_static { "static " } else { "" }; + format!( + "{static_prefix}{property_type} {property_name} {{ {} }}", + accessors.join(" ") + ) +} + +fn accessor_signature(name: &str, visibility: &str) -> String { + if visibility == "public" { + format!("{name};") + } else { + format!("{visibility} {name};") + } +} + +fn file_stem(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .map_or_else(|| path.display().to_string(), ToString::to_string) +} + +fn is_framework_reference(name: &str) -> bool { + name == "mscorlib" + || name == "netstandard" + || name.starts_with("System") + || name.starts_with("Microsoft.") +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut text = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(text, "{byte:02x}"); + } + text +} + +fn parse_type_sig_blob(bytes: &[u8]) -> Option { + let mut reader = Reader::new(bytes); + TypeSig::parse(&mut reader).ok() +} + +fn metadata_bytes<'a>( + bytes: &'a [u8], + pe: &PE<'_>, + directory: goblin::pe::data_directories::DataDirectory, +) -> Option<&'a [u8]> { + let file_alignment = pe + .header + .optional_header + .map(|header| header.windows_fields.file_alignment)?; + let offset = find_pe_offset( + directory.virtual_address as usize, + &pe.sections, + file_alignment, + )?; + let size = directory.size as usize; + bytes.get(offset..offset.checked_add(size)?) +} + +fn find_pe_offset(rva: usize, sections: &[SectionTable], file_alignment: u32) -> Option { + sections.iter().find_map(|section| { + let start = section.virtual_address as usize; + let read_size = section_read_size(section, file_alignment); + let end = start.checked_add(read_size)?; + if rva < start || rva >= end { + return None; + } + let raw = section.pointer_to_raw_data as usize; + raw.checked_add(rva - start) + }) +} + +fn section_read_size(section: &SectionTable, file_alignment: u32) -> usize { + let raw_size = section.size_of_raw_data as usize; + let virtual_size = section.virtual_size as usize; + if file_alignment < 0x200 { + return raw_size.max(virtual_size); + } + if virtual_size == 0 { + return raw_size; + } + raw_size.max(virtual_size) +} + +fn length_index(items: &[T]) -> u32 { + u32::try_from(items.len().saturating_add(1)).unwrap_or(u32::MAX) +} + +impl BindingFilter { + /// Returns true when the method matches the filter. + #[must_use] + pub fn matches_method(self, flags: u16) -> bool { + self.matches_common(flags) + } + + /// Returns true when the field matches the filter. + #[must_use] + pub fn matches_field(self, flags: u16) -> bool { + self.matches_common(flags) + } + + /// Returns true when any accessor of a property matches the filter. + #[must_use] + fn matches_property(self, accessors: &[PropertyAccessor]) -> bool { + accessors.iter().any(|item| { + self.matches_visibility(&item.visibility) && self.matches_scope(item.is_static) + }) + } + + fn matches_common(self, flags: u16) -> bool { + self.matches_visibility(member_visibility_label(flags)) + && self.matches_scope((flags & 0x0010) != 0) + } + + fn matches_visibility(self, visibility: &str) -> bool { + let is_public = visibility == "public"; + (is_public && self.include_public) || (!is_public && self.include_non_public) + } + + const fn matches_scope(self, is_static: bool) -> bool { + (is_static && self.include_static) || (!is_static && self.include_instance) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use regex_lite::Regex; + + use super::*; + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") + } + + fn managed_fixture_paths() -> Vec { + let root = workspace_root() + .join("fixtures") + .join("managed") + .join("bin"); + vec![root.join("GameAssembly.dll")] + } + + fn fixture_support_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("bin") + } + + fn diagnose_fixture_root() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("diagnose-bin") + .join("root") + .join("RootPlugin.dll") + } + + fn diagnose_server_a_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("diagnose-bin") + .join("server-a") + } + + fn diagnose_server_b_dir() -> PathBuf { + workspace_root() + .join("fixtures") + .join("managed") + .join("diagnose-bin") + .join("server-b") + } + + #[test] + fn binding_filter_matches_expected_visibility_and_scope() { + let filter = BindingFilter { + include_public: true, + include_non_public: false, + include_instance: true, + include_static: false, + }; + + assert!(filter.matches_method(0x0006)); + assert!(!filter.matches_method(0x0016)); + assert!(!filter.matches_method(0x0001)); + } + + #[test] + fn formatting_helpers_render_compact_signatures() { + let parameters = vec![ + ParameterDescriptor { + name: Some("count".to_string()), + parameter_type: "int".to_string(), + }, + ParameterDescriptor { + name: Some("tag".to_string()), + parameter_type: "string".to_string(), + }, + ]; + + assert_eq!( + format_method_signature("StartProject", "void", ¶meters), + "void StartProject(int count, string tag)" + ); + assert_eq!( + format_field_signature("int", "_buildTicks", "private", false, false, true), + "private initonly int _buildTicks" + ); + assert_eq!( + format_property_signature( + "string", + "ProjectName", + &[], + Some("public"), + Some("private"), + false + ), + "string ProjectName { get; private set; }" + ); + } + + #[test] + fn type_and_member_label_helpers_are_stable() { + assert_eq!(type_visibility_label(0x0000_0001), "public"); + assert_eq!(type_visibility_label(0x0000_0003), "nested_private"); + assert_eq!(member_visibility_label(0x0006), "public"); + assert_eq!(member_visibility_label(0x0001), "private"); + assert_eq!(method_semantics_label(0x0003), "setter|getter"); + assert!(is_framework_reference("System.Runtime")); + assert!(!is_framework_reference("FixtureSupport")); + } + + #[test] + fn list_types_filters_by_name_kind_namespace_and_limit() { + let paths = managed_fixture_paths(); + + let broad_query = TypeQuery { + match_pattern: Some(Regex::new("(?i)spacecraft").expect("regex")), + namespace_pattern: None, + kind: None, + public_only: false, + base_pattern: None, + interface_pattern: None, + limit: None, + }; + let broad_types = list_types(&paths, &broad_query).expect("managed types"); + assert!(broad_types.iter().any(|item| { + item.full_name == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow" + && item.kind == "class" + })); + assert!(broad_types.iter().any(|item| { + item.full_name == "Data.SpacecraftConstructData" && item.kind == "struct" + })); + + let filtered_query = TypeQuery { + match_pattern: Some(Regex::new("(?i)spacecraft").expect("regex")), + namespace_pattern: Some(Regex::new(r"(?i)^Game\.UI").expect("regex")), + kind: Some("class".to_string()), + public_only: true, + base_pattern: Some( + Regex::new("(?i)MercuryFixture.Support.LaunchVehicleBase").expect("regex"), + ), + interface_pattern: Some( + Regex::new("(?i)MercuryFixture.Support.ILaunchable").expect("regex"), + ), + limit: Some(1), + }; + let filtered = list_types(&paths, &filtered_query).expect("filtered managed types"); + assert_eq!(filtered.len(), 1); + assert_eq!( + filtered[0].full_name, + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow" + ); + } + + #[test] + fn list_members_covers_kind_binding_and_special_name_filters() { + let paths = managed_fixture_paths(); + let type_name = "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(); + + let all_members = list_members( + &paths, + &MemberQuery { + type_names: vec![type_name.clone()], + kind: None, + match_pattern: Some( + Regex::new("(?i)build|project|launch|queue|complete|projectname") + .expect("regex"), + ), + binding: BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: true, + }, + include_special: false, + user_code_only: false, + limit: None, + }, + ) + .expect("all members"); + assert!(all_members.iter().any(|item| matches!( + item, + MemberDescriptor::Method { name, .. } if name == "StartProject" + ))); + assert!(all_members.iter().any(|item| matches!( + item, + MemberDescriptor::Field { name, .. } if name == "_buildTicks" + ))); + assert!(all_members.iter().any(|item| matches!( + item, + MemberDescriptor::Property { name, .. } if name == "ProjectName" + ))); + assert!(!all_members.iter().any(|item| matches!( + item, + MemberDescriptor::Method { name, .. } if name == "get_ProjectName" + ))); + + let special_members = list_members( + &paths, + &MemberQuery { + type_names: vec![type_name], + kind: Some("method".to_string()), + match_pattern: Some(Regex::new("(?i)projectname").expect("regex")), + binding: BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: false, + }, + include_special: true, + user_code_only: false, + limit: None, + }, + ) + .expect("special members"); + assert!(special_members.iter().any(|item| matches!( + item, + MemberDescriptor::Method { name, .. } if name == "get_ProjectName" + ))); + + let filtered_members = list_members( + &paths, + &MemberQuery { + type_names: vec![ + "Game.UI.Windows.Windows.SpaceCraftConstructionWindow".to_string(), + ], + kind: Some("field".to_string()), + match_pattern: Some(Regex::new("(?i)projectname").expect("regex")), + binding: BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: true, + }, + include_special: false, + user_code_only: true, + limit: None, + }, + ) + .expect("filtered members"); + assert!(!filtered_members.iter().any(|item| matches!( + item, + MemberDescriptor::Field { name, .. } if name.contains("BackingField") + ))); + } + + #[test] + fn inspect_references_resolves_fixture_support_dependency() { + let paths = managed_fixture_paths(); + let reports = inspect_references( + &paths, + &ReferenceQuery { + resolve_dirs: vec![fixture_support_dir()], + }, + ) + .expect("reference reports"); + + assert_eq!(reports.len(), 1); + let fixture_support = reports[0] + .references + .iter() + .find(|item| item.name == "FixtureSupport") + .expect("FixtureSupport reference"); + assert!(fixture_support.resolved); + assert!( + fixture_support + .resolved_path + .as_ref() + .is_some_and(|path| path.ends_with("FixtureSupport.dll")) + ); + } + + fn api_type(full_name: &str, visibility: &str, is_public: bool) -> ApiTypeRow { + ApiTypeRow { + full_name: full_name.to_string(), + namespace: full_name + .rsplit_once('.') + .map(|(namespace, _)| namespace.to_string()), + name: full_name + .rsplit_once('.') + .map_or(full_name, |(_, name)| name) + .to_string(), + kind: "class".to_string(), + visibility: visibility.to_string(), + is_public, + } + } + + fn api_method( + type_name: &str, + name: &str, + visibility: &str, + is_static: bool, + return_type: &str, + parameters: &[&str], + ) -> ApiMethodRow { + let parameters = parameters + .iter() + .map(|parameter_type| ParameterDescriptor { + name: None, + parameter_type: (*parameter_type).to_string(), + }) + .collect::>(); + ApiMethodRow { + type_name: type_name.to_string(), + name: name.to_string(), + visibility: visibility.to_string(), + is_static, + is_virtual: false, + is_abstract: false, + return_type: return_type.to_string(), + parameters: parameters.clone(), + signature: format_method_signature(name, return_type, ¶meters), + } + } + + fn snapshot(types: Vec, methods: Vec) -> AssemblyApiSnapshot { + AssemblyApiSnapshot { + assembly: AssemblyDescriptor { + path: PathBuf::from("Fixture.dll"), + assembly_name: "Fixture".to_string(), + assembly_version: Some("1.0.0.0".to_string()), + runtime_version: "v4.0.30319".to_string(), + is_il_only: true, + is_library: true, + is_strong_name_signed: false, + public_key_token: None, + }, + types, + methods, + } + } + + #[test] + fn diff_snapshots_reports_type_and_method_breaks() { + let old = snapshot( + vec![api_type("Game.Api.OldType", "public", true)], + vec![api_method( + "Game.Api.OldType", + "Launch", + "public", + false, + "void", + &["string"], + )], + ); + let new = snapshot( + vec![api_type("Game.Api.NewType", "public", true)], + Vec::new(), + ); + + let report = diff_api_snapshots( + old, + new, + &ApiDiffQuery { + visibility: ApiVisibilityScope::Public, + include_special: false, + include_missing_method_risks: true, + }, + ); + + assert_eq!(report.summary.removed_types, 1); + assert_eq!(report.summary.added_types, 1); + assert_eq!(report.summary.removed_methods, 1); + assert_eq!(report.summary.missing_method_risks, 1); + assert_eq!(report.removed_types[0].type_name, "Game.Api.OldType"); + assert_eq!(report.added_types[0].type_name, "Game.Api.NewType"); + assert_eq!( + report.missing_method_risks[0].reason, + "removed_or_changed_public_signature" + ); + } + + #[test] + fn diff_snapshots_reports_name_group_signature_changes_and_added_overloads() { + let old = snapshot( + vec![api_type("Game.Api.Rocket", "public", true)], + vec![api_method( + "Game.Api.Rocket", + "Launch", + "public", + false, + "void", + &["string"], + )], + ); + let new = snapshot( + vec![api_type("Game.Api.Rocket", "public", true)], + vec![ + api_method( + "Game.Api.Rocket", + "Launch", + "public", + false, + "void", + &["int"], + ), + api_method( + "Game.Api.Rocket", + "Launch", + "public", + false, + "void", + &["int", "bool"], + ), + ], + ); + + let report = diff_api_snapshots(old, new, &ApiDiffQuery::default()); + + assert_eq!(report.summary.signature_changed_methods, 1); + assert_eq!(report.summary.added_methods, 2); + assert_eq!(report.summary.removed_methods, 1); + assert_eq!( + report.signature_changed_methods[0].type_name, + "Game.Api.Rocket" + ); + assert_eq!(report.signature_changed_methods[0].method_name, "Launch"); + assert_eq!(report.missing_method_risks.len(), 1); + } + + #[test] + fn api_visibility_scope_filters_rows() { + let old = snapshot( + vec![ + api_type("Game.Api.PublicType", "public", true), + api_type("Game.Api.InternalType", "not_public", false), + api_type("Game.Api.PrivateType", "nested_private", false), + ], + vec![ + api_method( + "Game.Api.PublicType", + "Public", + "public", + false, + "void", + &[], + ), + api_method( + "Game.Api.InternalType", + "Internal", + "assembly", + false, + "void", + &[], + ), + api_method( + "Game.Api.PrivateType", + "Private", + "private", + false, + "void", + &[], + ), + ], + ); + let new = snapshot(Vec::new(), Vec::new()); + + let public = diff_api_snapshots( + old.clone(), + new.clone(), + &ApiDiffQuery { + visibility: ApiVisibilityScope::Public, + ..ApiDiffQuery::default() + }, + ); + let internal = diff_api_snapshots( + old.clone(), + new.clone(), + &ApiDiffQuery { + visibility: ApiVisibilityScope::Internal, + ..ApiDiffQuery::default() + }, + ); + let all = diff_api_snapshots( + old, + new, + &ApiDiffQuery { + visibility: ApiVisibilityScope::All, + ..ApiDiffQuery::default() + }, + ); + + assert_eq!(public.summary.removed_methods, 1); + assert_eq!(internal.summary.removed_methods, 2); + assert_eq!(all.summary.removed_methods, 3); + } + + #[test] + fn diagnose_dependencies_reports_closure_risks_and_winners() { + let report = diagnose_dependencies( + &[diagnose_fixture_root()], + &DiagnoseQuery { + resolve_dirs: vec![diagnose_server_a_dir(), diagnose_server_b_dir()], + test_only_patterns: Vec::new(), + use_default_test_patterns: true, + }, + ) + .expect("diagnosis report"); + + assert_eq!(report.summary.root_count, 1); + assert!(report.summary.error_count > 0); + assert!(report.references.iter().any(|entry| { + entry.reference_name == "MissingOnly" + && entry.resolution_status == ResolutionStatus::Missing + })); + assert!( + report + .conflicts + .iter() + .any(|entry| entry.reference_name == "RuntimeDependency") + ); + assert!(report.winners.iter().any(|entry| { + entry.reference_name == "0Harmony" + && entry.winner.assembly.assembly_version.as_deref() == Some("2.2.2.0") + })); + assert!( + report + .test_only + .iter() + .any(|entry| entry.assembly.assembly_name == "TestOnlySupport") + ); + for expected in [ + "missing_reference", + "version_mismatch", + "test_only_dependency", + "missing_method", + "missing_type", + ] { + assert!( + report.risks.iter().any(|entry| entry.kind == expected), + "expected risk kind {expected} in {:#?}", + report.risks + ); + } + } + + #[test] + fn diagnose_dependencies_allows_disabling_default_test_only_patterns() { + let report = diagnose_dependencies( + &[diagnose_fixture_root()], + &DiagnoseQuery { + resolve_dirs: vec![diagnose_server_a_dir(), diagnose_server_b_dir()], + test_only_patterns: Vec::new(), + use_default_test_patterns: false, + }, + ) + .expect("diagnosis report"); + + assert!( + !report + .test_only + .iter() + .any(|entry| entry.assembly.assembly_name == "TestOnlySupport") + ); + assert!( + !report + .risks + .iter() + .any(|entry| entry.kind == "test_only_dependency") + ); + } + + #[test] + fn diagnose_dependencies_accepts_explicit_test_only_regex_patterns() { + let report = diagnose_dependencies( + &[diagnose_fixture_root()], + &DiagnoseQuery { + resolve_dirs: vec![diagnose_server_a_dir(), diagnose_server_b_dir()], + test_only_patterns: vec!["TestOnly(Support|Fixture)".to_string()], + use_default_test_patterns: false, + }, + ) + .expect("diagnosis report"); + + assert!( + report + .test_only + .iter() + .any(|entry| entry.assembly.assembly_name == "TestOnlySupport") + ); + assert!( + report + .risks + .iter() + .any(|entry| entry.kind == "test_only_dependency") + ); + } +} diff --git a/crates/managed/src/reference_diagnose.rs b/crates/managed/src/reference_diagnose.rs new file mode 100644 index 0000000..4d76a38 --- /dev/null +++ b/crates/managed/src/reference_diagnose.rs @@ -0,0 +1,1117 @@ +//! Managed assembly dependency closure diagnosis. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; + +use clrmeta::{CodedIndex, FieldSig, MethodSig, TableId}; +use regex_lite::RegexBuilder; +use serde::Serialize; + +use super::*; + +const DEFAULT_TEST_ONLY_PATTERNS: &[&str] = &[ + "test", + "tests", + "testing", + "fixtures", + "mock", + "mocks", + ".test", + ".tests", + "nunit.framework", + "xunit", + "microsoft.net.test.sdk", + "moq", + "coverlet", +]; +const MAX_TYPE_REF_SCOPE_DEPTH: usize = 64; + +/// Query settings for dependency closure diagnosis. +#[derive(Debug, Clone)] +pub struct DiagnoseQuery { + /// Additional directories to search while resolving references. + pub resolve_dirs: Vec, + /// Extra case-insensitive regex patterns that mark an assembly path or name as test-only. + pub test_only_patterns: Vec, + /// Whether built-in test-only patterns should be applied. + pub use_default_test_patterns: bool, +} + +impl Default for DiagnoseQuery { + fn default() -> Self { + Self { + resolve_dirs: Vec::new(), + test_only_patterns: Vec::new(), + use_default_test_patterns: true, + } + } +} + +/// Full dependency diagnosis report. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct DependencyDiagnosisReport { + /// Root assemblies requested by the caller. + pub roots: Vec, + /// Global resolve directories in the order provided by the caller. + pub search_dirs: Vec, + /// Summary counters. + pub summary: DependencyDiagnosisSummary, + /// Assemblies reached in the dependency closure. + pub assemblies: Vec, + /// One row for each assembly reference observed while walking the closure. + pub references: Vec, + /// Unique managed candidates found while searching resolve directories. + pub candidates: Vec, + /// Same-name candidate conflicts. + pub conflicts: Vec, + /// Winning candidate rows for resolved references. + pub winners: Vec, + /// Candidate assemblies that match test-only rules and are reached by the closure. + pub test_only: Vec, + /// Notable references such as Harmony and `BepInEx`. + pub notable_refs: Vec, + /// Error, warning, and info risks. + pub risks: Vec, + /// Files that could not be inspected while scanning candidate directories. + pub scan_warnings: Vec, +} + +/// Dependency diagnosis summary. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct DependencyDiagnosisSummary { + /// Number of root assemblies. + pub root_count: usize, + /// Number of assemblies reached in the dependency closure. + pub assembly_count: usize, + /// Number of reference rows observed. + pub reference_count: usize, + /// Number of resolved reference rows. + pub resolved_count: usize, + /// Number of missing non-framework reference rows. + pub missing_count: usize, + /// Number of candidate conflicts. + pub conflict_count: usize, + /// Number of test-only assemblies reached. + pub test_only_count: usize, + /// Number of error risks. + pub error_count: usize, + /// Number of warning risks. + pub warning_count: usize, + /// Number of info risks. + pub info_count: usize, +} + +/// One candidate assembly found in a resolve directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssemblyCandidateDescriptor { + /// Candidate assembly identity and metadata. + pub assembly: AssemblyDescriptor, + /// Directory that contained this candidate. + pub search_dir: PathBuf, + /// Search directory index used for winner selection. + pub search_index: usize, + /// Candidate file name. + pub file_name: String, + /// Whether the file name exactly matched the requested assembly name plus extension. + pub exact_file_name: bool, +} + +/// One diagnosed reference edge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DependencyReferenceDiagnostic { + /// Referencing assembly name. + pub source_assembly: String, + /// Referencing assembly path. + pub source_path: PathBuf, + /// Referenced assembly name. + pub reference_name: String, + /// Requested version. + pub requested_version: String, + /// Requested public key token, when present. + pub requested_public_key_token: Option, + /// Whether this looks like a framework assembly. + pub is_framework_reference: bool, + /// Resolution status. + pub resolution_status: ResolutionStatus, + /// Winning candidate, when resolved. + pub winner: Option, + /// Number of candidates found. + pub candidate_count: usize, +} + +/// Reference resolution status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResolutionStatus { + /// A candidate won resolution. + Resolved, + /// No candidate was found. + Missing, + /// The reference is a framework/system reference and was not searched as a local dependency. + FrameworkSkipped, +} + +/// Same-name candidate conflict. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DependencyConflict { + /// Referenced assembly name. + pub reference_name: String, + /// Conflict reason. + pub reason: String, + /// Conflicting candidates. + pub candidates: Vec, +} + +/// Winning candidate for a reference. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DependencyWinner { + /// Referenced assembly name. + pub reference_name: String, + /// Requested version. + pub requested_version: String, + /// Requested public key token, when present. + pub requested_public_key_token: Option, + /// Winning candidate. + pub winner: AssemblyCandidateDescriptor, + /// Why this candidate won. + pub reason: CandidateWinReason, +} + +/// Winner selection reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateWinReason { + /// The candidate was the only available candidate. + OnlyCandidate, + /// The candidate won by search directory and file-name order. + SearchOrder, +} + +/// One dependency risk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DependencyRisk { + /// Severity: error, warning, or info. + pub severity: String, + /// Stable risk kind. + pub kind: String, + /// Source assembly name, when applicable. + pub source_assembly: Option, + /// Referenced assembly name, when applicable. + pub reference_name: Option, + /// Related path, when applicable. + pub path: Option, + /// Human-readable detail. + pub message: String, +} + +#[derive(Debug, Default)] +struct DiagnosisState { + report: DependencyDiagnosisReport, + loaded: HashMap, + queued_or_seen: HashSet, + candidate_paths: HashSet, + warning_keys: HashSet, + conflict_keys: HashSet, + winner_keys: HashSet, + test_only_paths: HashSet, + risk_keys: HashSet, +} + +/// Diagnoses dependency closure resolution for one or more managed assemblies. +/// +/// # Errors +/// +/// Returns [`ManagedError`] when an explicit root assembly cannot be read or parsed. +pub fn diagnose_dependencies( + paths: &[PathBuf], + query: &DiagnoseQuery, +) -> Result { + let mut state = DiagnosisState { + report: DependencyDiagnosisReport { + search_dirs: query.resolve_dirs.clone(), + ..DependencyDiagnosisReport::default() + }, + ..DiagnosisState::default() + }; + let mut queue = VecDeque::new(); + + for path in paths { + let assembly = ManagedAssembly::load(path)?; + let key = stable_existing_key(&assembly.path); + state.report.roots.push(assembly.descriptor.clone()); + state.report.assemblies.push(assembly.descriptor.clone()); + state.queued_or_seen.insert(key.clone()); + queue.push_back(key.clone()); + state.loaded.insert(key, assembly); + } + + while let Some(source_key) = queue.pop_front() { + process_queued_assembly(&source_key, query, &mut queue, &mut state); + } + + finalize_summary(&mut state.report); + Ok(state.report) +} + +fn process_queued_assembly( + source_key: &PathBuf, + query: &DiagnoseQuery, + queue: &mut VecDeque, + state: &mut DiagnosisState, +) { + let Some(reference_rows) = state + .loaded + .get(source_key) + .map(|source| source.metadata.assembly_refs()) + else { + return; + }; + let mut winner_by_reference = HashMap::new(); + + for reference in reference_rows { + if let Some((reference_name, winner_key)) = + diagnose_reference(source_key, &reference, query, queue, state) + { + winner_by_reference.insert(reference_name, winner_key); + } + } + + diagnose_member_and_type_risks(source_key, &winner_by_reference, state); +} + +fn diagnose_reference( + source_key: &PathBuf, + reference: &clrmeta::AssemblyRefInfo, + query: &DiagnoseQuery, + queue: &mut VecDeque, + state: &mut DiagnosisState, +) -> Option<(String, PathBuf)> { + let (source_descriptor, source_directory) = state.loaded.get(source_key).map(|source| { + ( + source.descriptor.clone(), + source.path.parent().map(Path::to_path_buf), + ) + })?; + let is_framework = is_framework_reference(&reference.name); + let candidates = if is_framework { + Vec::new() + } else { + find_candidates(source_directory.as_deref(), &reference.name, query, state) + }; + let winner = candidates.first().cloned(); + let resolution_status = reference_status(is_framework, winner.is_some()); + let diagnostic = reference_diagnostic( + &source_descriptor, + reference, + resolution_status, + winner.clone(), + candidates.len(), + ); + + if is_notable_reference(&reference.name) { + state.report.notable_refs.push(diagnostic.clone()); + } + state.report.references.push(diagnostic); + + match resolution_status { + ResolutionStatus::FrameworkSkipped => { + add_framework_skipped_risk(&source_descriptor, reference, state); + None + } + ResolutionStatus::Missing => { + add_missing_reference_risk(&source_descriptor, reference, state); + None + } + ResolutionStatus::Resolved => { + let winner = winner?; + Some(record_resolved_reference( + &source_descriptor, + reference, + &candidates, + &winner, + query, + queue, + state, + )) + } + } +} + +const fn reference_status(is_framework: bool, has_winner: bool) -> ResolutionStatus { + if is_framework { + ResolutionStatus::FrameworkSkipped + } else if has_winner { + ResolutionStatus::Resolved + } else { + ResolutionStatus::Missing + } +} + +fn reference_diagnostic( + source: &AssemblyDescriptor, + reference: &clrmeta::AssemblyRefInfo, + resolution_status: ResolutionStatus, + winner: Option, + candidate_count: usize, +) -> DependencyReferenceDiagnostic { + DependencyReferenceDiagnostic { + source_assembly: source.assembly_name.clone(), + source_path: source.path.clone(), + reference_name: reference.name.clone(), + requested_version: reference.version_string(), + requested_public_key_token: reference + .public_key_token + .as_ref() + .map(|value| bytes_to_hex(value)), + is_framework_reference: is_framework_reference(&reference.name), + resolution_status, + winner, + candidate_count, + } +} + +fn add_framework_skipped_risk( + source: &AssemblyDescriptor, + reference: &clrmeta::AssemblyRefInfo, + state: &mut DiagnosisState, +) { + let reference_name = &reference.name; + state.add_risk( + "info", + "framework_skipped", + Some(source.assembly_name.clone()), + Some(reference.name.clone()), + None, + format!("framework reference {reference_name} is not resolved from local directories"), + ); +} + +fn add_missing_reference_risk( + source: &AssemblyDescriptor, + reference: &clrmeta::AssemblyRefInfo, + state: &mut DiagnosisState, +) { + let source_name = &source.assembly_name; + let reference_name = &reference.name; + state.add_risk( + "error", + "missing_reference", + Some(source.assembly_name.clone()), + Some(reference.name.clone()), + None, + format!("{source_name} references {reference_name}, but no local candidate was found"), + ); +} + +fn record_resolved_reference( + source: &AssemblyDescriptor, + reference: &clrmeta::AssemblyRefInfo, + candidates: &[AssemblyCandidateDescriptor], + winner: &AssemblyCandidateDescriptor, + query: &DiagnoseQuery, + queue: &mut VecDeque, + state: &mut DiagnosisState, +) -> (String, PathBuf) { + let reason = if candidates.len() == 1 { + CandidateWinReason::OnlyCandidate + } else { + CandidateWinReason::SearchOrder + }; + state.add_winner(reference, winner, reason); + diagnose_candidate_set(source, reference, candidates, winner, state); + diagnose_test_only(source, &reference.name, winner, query, state); + + let winner_key = stable_existing_key(&winner.assembly.path); + if state.queued_or_seen.insert(winner_key.clone()) { + if let Ok(assembly) = ManagedAssembly::load(&winner.assembly.path) { + state.report.assemblies.push(assembly.descriptor.clone()); + state.loaded.insert(winner_key.clone(), assembly); + queue.push_back(winner_key.clone()); + } + } + + (normalize_name(&reference.name), winner_key) +} + +fn find_candidates( + source_directory: Option<&Path>, + reference_name: &str, + query: &DiagnoseQuery, + state: &mut DiagnosisState, +) -> Vec { + let mut candidates = Vec::new(); + let dirs = source_directory + .map(Path::to_path_buf) + .into_iter() + .chain(query.resolve_dirs.iter().cloned()) + .collect::>(); + + for (search_index, directory) in dirs.iter().enumerate() { + let Ok(entries) = fs::read_dir(directory) else { + state.add_scan_warning(format!( + "failed to read resolve directory {}", + directory.display() + )); + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !is_managed_candidate_path(&path) { + continue; + } + match ManagedAssembly::load(&path) { + Ok(assembly) => { + if !assembly + .descriptor + .assembly_name + .eq_ignore_ascii_case(reference_name) + { + continue; + } + let candidate = AssemblyCandidateDescriptor { + assembly: assembly.descriptor, + search_dir: directory.clone(), + search_index, + file_name: path + .file_name() + .map_or_else(String::new, |value| value.to_string_lossy().to_string()), + exact_file_name: exact_file_name(reference_name, &path), + }; + let key = stable_existing_key(&candidate.assembly.path); + if state.candidate_paths.insert(key) { + state.report.candidates.push(candidate.clone()); + } + candidates.push(candidate); + } + Err(error) => { + state + .add_scan_warning(format!("skipped candidate {}: {error}", path.display())); + } + } + } + } + + candidates.sort_by(candidate_order); + candidates +} + +fn candidate_order( + left: &AssemblyCandidateDescriptor, + right: &AssemblyCandidateDescriptor, +) -> std::cmp::Ordering { + ( + left.search_index, + file_rank(left), + stable_display_key(&left.assembly.path), + ) + .cmp(&( + right.search_index, + file_rank(right), + stable_display_key(&right.assembly.path), + )) +} + +fn file_rank(candidate: &AssemblyCandidateDescriptor) -> u8 { + let extension = candidate + .assembly + .path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + match ( + candidate.exact_file_name, + extension.eq_ignore_ascii_case("dll"), + ) { + (true, true) => 0, + (true, false) => 1, + (false, true) => 2, + (false, false) => 3, + } +} + +fn diagnose_candidate_set( + source: &AssemblyDescriptor, + reference: &clrmeta::AssemblyRefInfo, + candidates: &[AssemblyCandidateDescriptor], + winner: &AssemblyCandidateDescriptor, + state: &mut DiagnosisState, +) { + let source_name = &source.assembly_name; + let reference_name = &reference.name; + let requested_version = reference.version_string(); + let winner_path = winner.assembly.path.display(); + let winner_version = winner + .assembly + .assembly_version + .as_deref() + .unwrap_or(""); + + if winner.assembly.assembly_version.as_deref() != Some(requested_version.as_str()) { + state.add_risk( + "error", + "version_mismatch", + Some(source.assembly_name.clone()), + Some(reference.name.clone()), + Some(winner.assembly.path.clone()), + format!("{source_name} requested {reference_name} {requested_version}, but {winner_path} wins with version {winner_version}"), + ); + } + + let requested_token = reference + .public_key_token + .as_ref() + .map(|value| bytes_to_hex(value)); + if requested_token.is_some() && winner.assembly.public_key_token != requested_token { + state.add_risk( + "error", + "public_key_token_mismatch", + Some(source.assembly_name.clone()), + Some(reference.name.clone()), + Some(winner.assembly.path.clone()), + format!( + "{reference_name} requested public key token {:?}, but winner has {:?}", + requested_token, winner.assembly.public_key_token + ), + ); + } + + if candidates.len() < 2 { + return; + } + + let versions = candidates + .iter() + .map(|candidate| candidate.assembly.assembly_version.clone()) + .collect::>(); + let tokens = candidates + .iter() + .map(|candidate| candidate.assembly.public_key_token.clone()) + .collect::>(); + let conflict_reason = match (versions.len() > 1, tokens.len() > 1) { + (true, true) => Some("version_and_public_key_token"), + (true, false) => Some("version"), + (false, true) => Some("public_key_token"), + (false, false) => None, + }; + if let Some(reason) = conflict_reason { + state.add_conflict(&reference.name, reason, candidates.to_vec()); + state.add_risk( + "error", + "candidate_conflict", + Some(source.assembly_name.clone()), + Some(reference.name.clone()), + Some(winner.assembly.path.clone()), + format!("{reference_name} has multiple candidate identities ({reason})"), + ); + } else { + state.add_risk( + "warning", + "duplicate_candidate", + Some(source.assembly_name.clone()), + Some(reference.name.clone()), + Some(winner.assembly.path.clone()), + format!("{reference_name} has multiple identical candidate identities"), + ); + } +} + +fn diagnose_test_only( + source: &AssemblyDescriptor, + reference_name: &str, + winner: &AssemblyCandidateDescriptor, + query: &DiagnoseQuery, + state: &mut DiagnosisState, +) { + if !is_test_only_candidate(winner, query) { + return; + } + let key = stable_existing_key(&winner.assembly.path); + if state.test_only_paths.insert(key) { + state.report.test_only.push(winner.clone()); + } + state.add_risk( + "error", + "test_only_dependency", + Some(source.assembly_name.clone()), + Some(reference_name.to_string()), + Some(winner.assembly.path.clone()), + format!( + "{reference_name} resolves to test-only candidate {}", + winner.assembly.path.display() + ), + ); +} + +fn diagnose_member_and_type_risks( + source_key: &PathBuf, + winner_by_reference: &HashMap, + state: &mut DiagnosisState, +) { + let Some(source_name) = state + .loaded + .get(source_key) + .map(|source| source.descriptor.assembly_name.clone()) + else { + return; + }; + + diagnose_type_risks(source_key, &source_name, winner_by_reference, state); + diagnose_member_risks(source_key, &source_name, winner_by_reference, state); +} + +fn diagnose_type_risks( + source_key: &PathBuf, + source_name: &str, + winner_by_reference: &HashMap, + state: &mut DiagnosisState, +) { + let Some(type_refs) = state + .loaded + .get(source_key) + .map(|source| source.metadata.type_refs.clone()) + else { + return; + }; + + for (index, row) in type_refs.iter().enumerate() { + let type_ref_index = u32::try_from(index + 1).unwrap_or(0); + let Some((type_name, reference_name)) = state + .loaded + .get(source_key) + .and_then(|source| type_ref_info(source, type_ref_index, row)) + else { + continue; + }; + let Some(winner_key) = winner_by_reference.get(&normalize_name(&reference_name)) else { + continue; + }; + let Some((winner_path, missing_type)) = state.loaded.get(winner_key).map(|winner| { + ( + winner.descriptor.path.clone(), + winner.find_type_index(&type_name).is_none(), + ) + }) else { + continue; + }; + if missing_type { + state.add_risk( + "error", + "missing_type", + Some(source_name.to_string()), + Some(reference_name), + Some(winner_path), + format!( + "{source_name} references type {type_name}, but the winning assembly lacks it" + ), + ); + } + } +} + +fn diagnose_member_risks( + source_key: &PathBuf, + source_name: &str, + winner_by_reference: &HashMap, + state: &mut DiagnosisState, +) { + let Some(member_refs) = state + .loaded + .get(source_key) + .map(|source| source.metadata.member_refs.clone()) + else { + return; + }; + + for row in member_refs { + let Some((type_name, reference_name, member_name, member_kind)) = + state.loaded.get(source_key).and_then(|source| { + let (type_name, reference_name) = member_ref_parent_type(source, row.class)?; + let member_name = source.string_or_empty(row.name); + let signature_bytes = source.metadata.blobs.get(row.signature).ok()?; + let member_kind = if let Ok(signature) = MethodSig::parse_blob(signature_bytes) { + MemberReferenceKind::Method { + parameter_count: signature.params.len(), + } + } else if FieldSig::parse_blob(signature_bytes).is_ok() { + MemberReferenceKind::Field + } else { + MemberReferenceKind::Unknown + }; + Some((type_name, reference_name, member_name, member_kind)) + }) + else { + continue; + }; + let Some(winner_key) = winner_by_reference.get(&normalize_name(&reference_name)) else { + continue; + }; + let Some(winner_path) = state + .loaded + .get(winner_key) + .map(|winner| winner.descriptor.path.clone()) + else { + continue; + }; + match member_kind { + MemberReferenceKind::Method { parameter_count } => { + let missing_method = state.loaded.get(winner_key).is_some_and(|winner| { + !winner_has_method(winner, &type_name, &member_name, parameter_count) + }); + if !missing_method { + continue; + } + state.add_risk( + "error", + "missing_method", + Some(source_name.to_string()), + Some(reference_name), + Some(winner_path), + format!("{source_name} references method {type_name}::{member_name}, but the winning assembly lacks a matching method"), + ); + } + MemberReferenceKind::Field => { + let missing_field = state + .loaded + .get(winner_key) + .is_some_and(|winner| !winner_has_field(winner, &type_name, &member_name)); + if !missing_field { + continue; + } + state.add_risk( + "error", + "missing_field", + Some(source_name.to_string()), + Some(reference_name), + Some(winner_path), + format!("{source_name} references field {type_name}::{member_name}, but the winning assembly lacks a matching field"), + ); + } + MemberReferenceKind::Unknown => { + state.add_risk( + "warning", + "unknown_member_signature", + Some(source_name.to_string()), + Some(reference_name), + Some(winner_path), + format!("could not parse member signature for {type_name}::{member_name}"), + ); + } + } + } +} + +enum MemberReferenceKind { + Method { parameter_count: usize }, + Field, + Unknown, +} + +fn type_ref_info( + assembly: &ManagedAssembly, + type_ref_index: u32, + row: &clrmeta::TypeRefRow, +) -> Option<(String, String)> { + let name = assembly.string_or_empty(row.type_name); + let namespace = assembly.string_option(row.type_namespace); + let type_name = + namespace.map_or_else(|| name.clone(), |namespace| format!("{namespace}.{name}")); + let reference_name = + type_ref_scope_assembly_name(assembly, type_ref_index, row.resolution_scope)?; + Some((type_name, reference_name)) +} + +fn type_ref_scope_assembly_name( + assembly: &ManagedAssembly, + type_ref_index: u32, + scope: CodedIndex, +) -> Option { + let mut visited = HashSet::new(); + visited.insert(type_ref_index); + type_ref_scope_assembly_name_inner(assembly, scope, &mut visited, 0) +} + +fn type_ref_scope_assembly_name_inner( + assembly: &ManagedAssembly, + scope: CodedIndex, + visited: &mut HashSet, + depth: usize, +) -> Option { + if depth >= MAX_TYPE_REF_SCOPE_DEPTH { + return None; + } + + match scope.table? { + TableId::AssemblyRef => assembly_ref_name(assembly, scope.row), + TableId::TypeRef => { + if !visited.insert(scope.row) { + return None; + } + let row = assembly + .metadata + .type_refs + .get(scope.row.checked_sub(1)? as usize)?; + type_ref_scope_assembly_name_inner(assembly, row.resolution_scope, visited, depth + 1) + } + _ => None, + } +} + +fn member_ref_parent_type( + assembly: &ManagedAssembly, + parent: CodedIndex, +) -> Option<(String, String)> { + match parent.table? { + TableId::TypeRef => { + let row = assembly + .metadata + .type_refs + .get(parent.row.checked_sub(1)? as usize)?; + type_ref_info(assembly, parent.row, row) + } + _ => None, + } +} + +fn assembly_ref_name(assembly: &ManagedAssembly, row_index: u32) -> Option { + let row = assembly + .metadata + .assembly_refs + .get(row_index.checked_sub(1)? as usize)?; + Some(assembly.string_or_empty(row.name)) +} + +fn winner_has_method( + winner: &ManagedAssembly, + type_name: &str, + method_name: &str, + parameter_count: usize, +) -> bool { + let Some(type_index) = winner.find_type_index(type_name) else { + return false; + }; + let query = all_member_query(); + winner + .method_descriptors(type_index, type_name, &query) + .into_iter() + .any(|descriptor| { + matches!( + descriptor, + MemberDescriptor::Method { + name, + parameters, + .. + } if name == method_name && parameters.len() == parameter_count + ) + }) +} + +fn winner_has_field(winner: &ManagedAssembly, type_name: &str, field_name: &str) -> bool { + let Some(type_index) = winner.find_type_index(type_name) else { + return false; + }; + let query = all_member_query(); + winner + .field_descriptors(type_index, type_name, &query) + .into_iter() + .any(|descriptor| { + matches!( + descriptor, + MemberDescriptor::Field { name, .. } if name == field_name + ) + }) +} + +const fn all_member_query() -> MemberQuery { + MemberQuery { + type_names: Vec::new(), + kind: None, + match_pattern: None, + binding: BindingFilter { + include_public: true, + include_non_public: true, + include_instance: true, + include_static: true, + }, + include_special: true, + user_code_only: false, + limit: None, + } +} + +fn is_managed_candidate_path(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("dll") || extension.eq_ignore_ascii_case("exe") + }) +} + +fn exact_file_name(reference_name: &str, path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + file_name.eq_ignore_ascii_case(&format!("{reference_name}.dll")) + || file_name.eq_ignore_ascii_case(&format!("{reference_name}.exe")) +} + +fn is_test_only_candidate(candidate: &AssemblyCandidateDescriptor, query: &DiagnoseQuery) -> bool { + let haystack = format!( + "{} {}", + candidate.assembly.assembly_name, + candidate.assembly.path.display() + ); + let lower_haystack = haystack.to_ascii_lowercase(); + let default_match = query + .use_default_test_patterns + .then_some(DEFAULT_TEST_ONLY_PATTERNS) + .into_iter() + .flatten() + .any(|pattern| lower_haystack.contains(&pattern.to_ascii_lowercase())); + + default_match + || query + .test_only_patterns + .iter() + .any(|pattern| explicit_test_pattern_matches(pattern, &haystack)) +} + +fn explicit_test_pattern_matches(pattern: &str, haystack: &str) -> bool { + RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + .is_ok_and(|regex| regex.is_match(haystack)) +} + +fn is_notable_reference(reference_name: &str) -> bool { + let normalized = reference_name.to_ascii_lowercase(); + normalized.contains("harmony") + || normalized.contains("0harmony") + || normalized.contains("bepinex") +} + +fn normalize_name(value: &str) -> String { + value.to_ascii_lowercase() +} + +fn stable_existing_key(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn stable_display_key(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase() +} + +fn finalize_summary(report: &mut DependencyDiagnosisReport) { + report.summary = DependencyDiagnosisSummary { + root_count: report.roots.len(), + assembly_count: report.assemblies.len(), + reference_count: report.references.len(), + resolved_count: report + .references + .iter() + .filter(|item| item.resolution_status == ResolutionStatus::Resolved) + .count(), + missing_count: report + .references + .iter() + .filter(|item| item.resolution_status == ResolutionStatus::Missing) + .count(), + conflict_count: report.conflicts.len(), + test_only_count: report.test_only.len(), + error_count: report + .risks + .iter() + .filter(|item| item.severity == "error") + .count(), + warning_count: report + .risks + .iter() + .filter(|item| item.severity == "warning") + .count(), + info_count: report + .risks + .iter() + .filter(|item| item.severity == "info") + .count(), + }; +} + +impl DiagnosisState { + fn add_scan_warning(&mut self, message: String) { + if self.warning_keys.insert(message.clone()) { + self.report.scan_warnings.push(message); + } + } + + fn add_conflict( + &mut self, + reference_name: &str, + reason: &str, + candidates: Vec, + ) { + let key = format!("{reference_name}:{reason}"); + if self.conflict_keys.insert(key) { + self.report.conflicts.push(DependencyConflict { + reference_name: reference_name.to_string(), + reason: reason.to_string(), + candidates, + }); + } + } + + fn add_winner( + &mut self, + reference: &clrmeta::AssemblyRefInfo, + winner: &AssemblyCandidateDescriptor, + reason: CandidateWinReason, + ) { + let key = format!( + "{}:{}:{}", + reference.name, + reference.version_string(), + stable_display_key(&winner.assembly.path) + ); + if self.winner_keys.insert(key) { + self.report.winners.push(DependencyWinner { + reference_name: reference.name.clone(), + requested_version: reference.version_string(), + requested_public_key_token: reference + .public_key_token + .as_ref() + .map(|value| bytes_to_hex(value)), + winner: winner.clone(), + reason, + }); + } + } + + fn add_risk( + &mut self, + severity: &str, + kind: &str, + source_assembly: Option, + reference_name: Option, + path: Option, + message: String, + ) { + let key = format!( + "{severity}:{kind}:{:?}:{:?}:{:?}:{message}", + source_assembly, + reference_name, + path.as_ref().map(|item| stable_display_key(item)) + ); + if self.risk_keys.insert(key) { + self.report.risks.push(DependencyRisk { + severity: severity.to_string(), + kind: kind.to_string(), + source_assembly, + reference_name, + path, + message, + }); + } + } +} diff --git a/crates/mhash/Cargo.toml b/crates/mhash/Cargo.toml new file mode 100644 index 0000000..a6bc780 --- /dev/null +++ b/crates/mhash/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "mercury-mhash" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "High-performance OpenHashTab-class digest and manifest tooling." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +blake2s_simd.workspace = true +blake3.workspace = true +common = { path = "../common", default-features = false } +crc.workspace = true +crc32fast.workspace = true +digest-traits.workspace = true +lexopt.workspace = true +md-5.workspace = true +md4.workspace = true +memmap2.workspace = true +rayon.workspace = true +ripemd.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +sha2.workspace = true +sha3.workspace = true +streebog.workspace = true +tiny-keccak.workspace = true +walkdir.workspace = true +xxhash-rust.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true + +[[bin]] +name = "mhash" +path = "src/main.rs" diff --git a/crates/mhash/src/lib.rs b/crates/mhash/src/lib.rs new file mode 100644 index 0000000..e3dff76 --- /dev/null +++ b/crates/mhash/src/lib.rs @@ -0,0 +1,3176 @@ +//! The `mhash` command computes high-performance file digests and verifies manifests. +#![allow( + clippy::multiple_crate_versions, + reason = "hash crates currently depend on different cpufeatures releases; the split is transitive and harmless" +)] + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, BufRead, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, map_result_count, parse_color_choice, + parse_input_format, print_json, print_quick_help_error, print_structured, should_read_stdin, +}; +use digest_traits::Digest as _; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use memmap2::Mmap; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +const DEFAULT_CHUNK_SIZE: usize = 1024 * 1024; +const MMAP_THRESHOLD_BYTES: usize = 8 * 1024 * 1024; +const PARALLEL_HASH_BLOCK_SIZE: usize = 8192; +const MAX_VARIABLE_DIGEST_BYTES: usize = 66; +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; +const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF"; +const CRC64_XZ: crc::Crc = crc::Crc::::new(&crc::CRC_64_XZ); + +const HELP: &str = "\ +Compute high-performance OpenHashTab-class digests and manifests. + +Usage: + mhash [OPTIONS] [PATH...] + mhash verify [OPTIONS] + mhash list-algorithms [--json] + mhash bench [OPTIONS] [PATH...] + +Options: + --algorithm Comma-separated algorithms; default: sha256 + --all Use every supported algorithm + --recursive Recurse into directory inputs + --ignore Skip paths matching a simple glob pattern + --manifest Write a JSONL manifest while hashing + --format Output format: text, sum, json, jsonl, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --jsonl Emit newline-delimited JSON records + --input-format Override stdin parsing mode: auto, lines, jsonl + --threads Worker threads; default: auto + --chunk-size Buffered read size; accepts K, M, or G suffix + --io Read mode: auto, mmap, read + --repeat Bench timed iterations; default: 1 + --warmup Bench warmup iterations; default: 0 + --bench-mode Bench timing mode: cpu, end-to-end; default: cpu + --uppercase Emit uppercase hex + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + mhash .\\README.md + mhash --algorithm sha256,blake3-256 --json .\\dist\\tool.zip | ConvertFrom-Json + fd -t f . .\\dist | mhash --input-format lines --algorithm sha256 --format jsonl + mhash verify .\\checksums.jsonl --json | ConvertFrom-Json +"; + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("mhash {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: Command, + algorithms: AlgorithmSelection, + recursive: bool, + ignores: Vec, + manifest: Option, + output: OutputFormat, + threads: Option, + chunk_size: usize, + io_mode: IoMode, + bench_repeat: usize, + bench_warmup: usize, + bench_mode: BenchMode, + uppercase: bool, + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Command { + Hash, + Verify, + ListAlgorithms, + Bench, +} + +#[derive(Debug, Clone)] +enum AlgorithmSelection { + Default, + All, + Explicit(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFormat { + Text, + Sum, + Json, + Jsonl, + Toon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum IoMode { + Auto, + Mmap, + Read, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum BenchMode { + Cpu, + EndToEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParallelHashStrength { + Bits128, + Bits256, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HashJob { + Single(Algorithm), + Xxh3, + Blake3 { bytes: usize }, + KangarooTwelve { bytes: usize }, +} + +enum StreamingHashState { + Crc32(crc32fast::Hasher), + Crc64Xz(crc::Digest<'static, u64>), + Xxh32(xxhash_rust::xxh32::Xxh32), + Xxh64(xxhash_rust::xxh64::Xxh64), + Xxh3(xxhash_rust::xxh3::Xxh3), + Md4(md4::Md4), + Md5(md5::Md5), + Ripemd160(ripemd::Ripemd160), + Blake2sp(Box), + Sha1(sha1::Sha1), + Sha224(sha2::Sha224), + Sha256(sha2::Sha256), + Sha384(sha2::Sha384), + Sha512(sha2::Sha512), + Sha3_224(sha3::Sha3_224), + Sha3_256(sha3::Sha3_256), + Sha3_384(sha3::Sha3_384), + Sha3_512(sha3::Sha3_512), + Blake3(Box, usize), + KangarooTwelve(tiny_keccak::KangarooTwelve<&'static [u8]>, usize), + ParallelHash(tiny_keccak::ParallelHash, usize), + Streebog256(streebog::Streebog256), + Streebog512(streebog::Streebog512), +} + +struct StreamingHashJob { + job: HashJob, + state: StreamingHashState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[repr(usize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum Algorithm { + Crc32, + Crc64Xz, + Xxh32, + Xxh64, + Xxh3_64, + Xxh3_128, + Md4, + Md5, + Ripemd160, + Blake2sp, + Sha1, + Sha224, + Sha256, + Sha384, + Sha512, + Sha3_224, + Sha3_256, + Sha3_384, + Sha3_512, + Blake3_256, + Blake3_512, + KangarooTwelve264, + KangarooTwelve256, + KangarooTwelve512, + ParallelHash128_264, + ParallelHash256_528, + Streebog256, + Streebog512, +} + +#[derive(Debug, Clone, Copy, Serialize)] +struct AlgorithmInfo { + name: &'static str, + aliases: &'static [&'static str], + bits: usize, + bytes: usize, + family: &'static str, + security: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct HashRecord { + path: PathBuf, + size: u64, + algorithm: &'static str, + digest: String, + ok: bool, + error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct VerifyRecord { + path: PathBuf, + algorithm: String, + expected: String, + actual: Option, + ok: bool, + error: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +struct BenchRecord { + path: Option, + algorithm: &'static str, + repeat: usize, + warmup: usize, + bench_mode: BenchMode, + bytes: u64, + elapsed_ms: f64, + throughput_mib_s: f64, +} + +#[derive(Debug, Clone, Deserialize)] +struct ManifestJsonRecord { + path: PathBuf, + algorithm: String, + #[serde(alias = "hex")] + digest: String, +} + +#[derive(Debug, Clone, Copy)] +struct DigestBytes { + bytes: [u8; MAX_VARIABLE_DIGEST_BYTES], + len: usize, +} + +impl DigestBytes { + const fn new(len: usize) -> Self { + Self { + bytes: [0; MAX_VARIABLE_DIGEST_BYTES], + len, + } + } + + fn as_slice(&self) -> &[u8] { + &self.bytes[..self.len] + } +} + +static ALGORITHMS: &[(Algorithm, AlgorithmInfo)] = &[ + ( + Algorithm::Crc32, + AlgorithmInfo { + name: "crc32", + aliases: &["crc-32"], + bits: 32, + bytes: 4, + family: "crc", + security: "non_crypto", + }, + ), + ( + Algorithm::Crc64Xz, + AlgorithmInfo { + name: "crc64-xz", + aliases: &["crc-64-xz", "crc64"], + bits: 64, + bytes: 8, + family: "crc", + security: "non_crypto", + }, + ), + ( + Algorithm::Xxh32, + AlgorithmInfo { + name: "xxh32", + aliases: &["xxhash32", "xxhash-32"], + bits: 32, + bytes: 4, + family: "xxhash", + security: "non_crypto", + }, + ), + ( + Algorithm::Xxh64, + AlgorithmInfo { + name: "xxh64", + aliases: &["xxhash64", "xxhash-64"], + bits: 64, + bytes: 8, + family: "xxhash", + security: "non_crypto", + }, + ), + ( + Algorithm::Xxh3_64, + AlgorithmInfo { + name: "xxh3-64", + aliases: &["xxh3", "xxhash3-64"], + bits: 64, + bytes: 8, + family: "xxhash", + security: "non_crypto", + }, + ), + ( + Algorithm::Xxh3_128, + AlgorithmInfo { + name: "xxh3-128", + aliases: &["xxhash3-128"], + bits: 128, + bytes: 16, + family: "xxhash", + security: "non_crypto", + }, + ), + ( + Algorithm::Md4, + AlgorithmInfo { + name: "md4", + aliases: &[], + bits: 128, + bytes: 16, + family: "md", + security: "broken_legacy", + }, + ), + ( + Algorithm::Md5, + AlgorithmInfo { + name: "md5", + aliases: &[], + bits: 128, + bytes: 16, + family: "md", + security: "broken_legacy", + }, + ), + ( + Algorithm::Ripemd160, + AlgorithmInfo { + name: "ripemd160", + aliases: &["ripemd-160"], + bits: 160, + bytes: 20, + family: "ripemd", + security: "legacy", + }, + ), + ( + Algorithm::Blake2sp, + AlgorithmInfo { + name: "blake2sp", + aliases: &["blake2-sp"], + bits: 256, + bytes: 32, + family: "blake2", + security: "crypto", + }, + ), + ( + Algorithm::Sha1, + AlgorithmInfo { + name: "sha1", + aliases: &["sha-1"], + bits: 160, + bytes: 20, + family: "sha1", + security: "broken_legacy", + }, + ), + ( + Algorithm::Sha224, + AlgorithmInfo { + name: "sha224", + aliases: &["sha-224"], + bits: 224, + bytes: 28, + family: "sha2", + security: "crypto", + }, + ), + ( + Algorithm::Sha256, + AlgorithmInfo { + name: "sha256", + aliases: &["sha-256"], + bits: 256, + bytes: 32, + family: "sha2", + security: "crypto", + }, + ), + ( + Algorithm::Sha384, + AlgorithmInfo { + name: "sha384", + aliases: &["sha-384"], + bits: 384, + bytes: 48, + family: "sha2", + security: "crypto", + }, + ), + ( + Algorithm::Sha512, + AlgorithmInfo { + name: "sha512", + aliases: &["sha-512"], + bits: 512, + bytes: 64, + family: "sha2", + security: "crypto", + }, + ), + ( + Algorithm::Sha3_224, + AlgorithmInfo { + name: "sha3-224", + aliases: &["sha3_224"], + bits: 224, + bytes: 28, + family: "sha3", + security: "crypto", + }, + ), + ( + Algorithm::Sha3_256, + AlgorithmInfo { + name: "sha3-256", + aliases: &["sha3_256"], + bits: 256, + bytes: 32, + family: "sha3", + security: "crypto", + }, + ), + ( + Algorithm::Sha3_384, + AlgorithmInfo { + name: "sha3-384", + aliases: &["sha3_384"], + bits: 384, + bytes: 48, + family: "sha3", + security: "crypto", + }, + ), + ( + Algorithm::Sha3_512, + AlgorithmInfo { + name: "sha3-512", + aliases: &["sha3_512"], + bits: 512, + bytes: 64, + family: "sha3", + security: "crypto", + }, + ), + ( + Algorithm::Blake3_256, + AlgorithmInfo { + name: "blake3-256", + aliases: &["blake3"], + bits: 256, + bytes: 32, + family: "blake3", + security: "crypto", + }, + ), + ( + Algorithm::Blake3_512, + AlgorithmInfo { + name: "blake3-512", + aliases: &[], + bits: 512, + bytes: 64, + family: "blake3", + security: "crypto_xof", + }, + ), + ( + Algorithm::KangarooTwelve264, + AlgorithmInfo { + name: "kangarootwelve-264", + aliases: &["k12-264"], + bits: 264, + bytes: 33, + family: "kangarootwelve", + security: "crypto_xof", + }, + ), + ( + Algorithm::KangarooTwelve256, + AlgorithmInfo { + name: "kangarootwelve-256", + aliases: &["k12-256", "kangarootwelve"], + bits: 256, + bytes: 32, + family: "kangarootwelve", + security: "crypto_xof", + }, + ), + ( + Algorithm::KangarooTwelve512, + AlgorithmInfo { + name: "kangarootwelve-512", + aliases: &["k12-512"], + bits: 512, + bytes: 64, + family: "kangarootwelve", + security: "crypto_xof", + }, + ), + ( + Algorithm::ParallelHash128_264, + AlgorithmInfo { + name: "parallelhash128-264", + aliases: &["parallelhash-128-264"], + bits: 264, + bytes: 33, + family: "parallelhash", + security: "crypto_xof", + }, + ), + ( + Algorithm::ParallelHash256_528, + AlgorithmInfo { + name: "parallelhash256-528", + aliases: &["parallelhash-256-528"], + bits: 528, + bytes: 66, + family: "parallelhash", + security: "crypto_xof", + }, + ), + ( + Algorithm::Streebog256, + AlgorithmInfo { + name: "streebog-256", + aliases: &["streebog256", "gost-256"], + bits: 256, + bytes: 32, + family: "streebog", + security: "gost_compat", + }, + ), + ( + Algorithm::Streebog512, + AlgorithmInfo { + name: "streebog-512", + aliases: &["streebog512", "gost-512"], + bits: 512, + bytes: 64, + family: "streebog", + security: "gost_compat", + }, + ), +]; + +#[allow( + clippy::too_many_lines, + reason = "the CLI parser keeps option interactions centralized and visible" +)] +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + command: Command::Hash, + algorithms: AlgorithmSelection::Default, + recursive: false, + ignores: Vec::new(), + manifest: None, + output: OutputFormat::Text, + threads: None, + chunk_size: DEFAULT_CHUNK_SIZE, + io_mode: IoMode::Auto, + bench_repeat: 1, + bench_warmup: 0, + bench_mode: BenchMode::Cpu, + uppercase: false, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => { + cli.common.set_render_mode(RenderMode::Json); + cli.output = OutputFormat::Json; + } + Long("toon") => { + cli.common.set_render_mode(RenderMode::Toon); + cli.output = OutputFormat::Toon; + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("algorithm" | "alg") => { + let value = parser_value_string(&mut parser, "--algorithm")?; + cli.algorithms = AlgorithmSelection::Explicit(parse_algorithm_list(&value)?); + } + Long("all") => cli.algorithms = AlgorithmSelection::All, + Long("recursive") => cli.recursive = true, + Long("ignore") => cli + .ignores + .push(parser_value_string(&mut parser, "--ignore")?), + Long("manifest") => { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + cli.manifest = Some(PathBuf::from(value)); + } + Long("jsonl") => cli.output = OutputFormat::Jsonl, + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.output = parse_output_format(&value)?; + } + Long("threads") => { + let value = parser_value_string(&mut parser, "--threads")?; + cli.threads = Some(parse_positive_usize("--threads", &value)?); + } + Long("chunk-size") => { + let value = parser_value_string(&mut parser, "--chunk-size")?; + cli.chunk_size = parse_size("--chunk-size", &value)?; + } + Long("io") => cli.io_mode = parse_io_mode(&parser_value_string(&mut parser, "--io")?)?, + Long("repeat") => { + let value = parser_value_string(&mut parser, "--repeat")?; + cli.bench_repeat = parse_positive_usize("--repeat", &value)?; + } + Long("warmup") => { + let value = parser_value_string(&mut parser, "--warmup")?; + cli.bench_warmup = parse_nonnegative_usize("--warmup", &value)?; + } + Long("bench-mode") => { + let value = parser_value_string(&mut parser, "--bench-mode")?; + cli.bench_mode = parse_bench_mode(&value)?; + } + Long("uppercase") => cli.uppercase = true, + ArgValue(value) if is_subcommand(&value, "verify") && cli.paths.is_empty() => { + cli.command = Command::Verify; + } + ArgValue(value) if is_subcommand(&value, "list-algorithms") && cli.paths.is_empty() => { + cli.command = Command::ListAlgorithms; + } + ArgValue(value) if is_subcommand(&value, "bench") && cli.paths.is_empty() => { + cli.command = Command::Bench; + } + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + normalize_trailing_flag_paths(&mut cli)?; + Ok((ParseOutcome::Run, cli)) +} + +fn is_subcommand(value: &OsString, expected: &str) -> bool { + value.to_string_lossy().eq_ignore_ascii_case(expected) +} + +fn normalize_trailing_flag_paths(cli: &mut Cli) -> Result<(), CliError> { + let mut paths = Vec::with_capacity(cli.paths.len()); + let original_paths = std::mem::take(&mut cli.paths); + for path in original_paths { + let Some(text) = path.to_str() else { + paths.push(path); + continue; + }; + match text { + "--json" => { + cli.common.set_render_mode(RenderMode::Json); + cli.output = OutputFormat::Json; + } + "--toon" => { + cli.common.set_render_mode(RenderMode::Toon); + cli.output = OutputFormat::Toon; + } + "--jsonl" => cli.output = OutputFormat::Jsonl, + "--uppercase" => cli.uppercase = true, + "--quiet" => cli.common.quiet = true, + "--recursive" => cli.recursive = true, + "--all" => cli.algorithms = AlgorithmSelection::All, + flag if flag.starts_with('-') => { + return Err(CliError::usage(format!( + "option '{flag}' must appear before positional paths or use a value form supported by --help" + ))); + } + _ => paths.push(path), + } + } + cli.paths = paths; + Ok(()) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +/// Executes the `mhash` command with parsed CLI arguments. +/// +/// # Errors +/// +/// Returns [`CliError`] for invalid invocation, unreadable files, rendering failures, or manifest IO failures. +fn run(cli: &Cli) -> Result { + match cli.command { + Command::Hash => run_hash(cli), + Command::Verify => run_verify(cli), + Command::ListAlgorithms => run_list_algorithms(cli), + Command::Bench => run_bench(cli), + } +} + +fn run_hash(cli: &Cli) -> Result { + let algorithms = selected_algorithms(&cli.algorithms); + let paths = collect_input_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let records = with_thread_pool(cli.threads, || hash_paths(cli, &paths, &algorithms))?; + if let Some(manifest) = &cli.manifest { + write_manifest(manifest, &records)?; + } + render_hash_records(cli, &records)?; + Ok(map_result_count( + records.iter().filter(|record| record.ok).count(), + )) +} + +fn run_verify(cli: &Cli) -> Result { + let manifest = cli + .paths + .first() + .ok_or_else(|| CliError::usage("provide a manifest path to verify"))?; + let entries = read_manifest_entries(manifest, cli)?; + let records = with_thread_pool(cli.threads, || Ok(verify_entries(cli, &entries)))?; + render_verify_records(cli, &records)?; + if !records.is_empty() && records.iter().all(|record| record.ok) { + Ok(ExitCode::Success) + } else { + Ok(ExitCode::NoResults) + } +} + +fn run_list_algorithms(cli: &Cli) -> Result { + let infos: Vec<_> = ALGORITHMS.iter().map(|(_, info)| *info).collect(); + match effective_output(cli) { + OutputFormat::Json => print_json(&infos)?, + OutputFormat::Toon => print_structured(&infos, RenderMode::Toon)?, + OutputFormat::Jsonl => render_jsonl(&infos)?, + OutputFormat::Text | OutputFormat::Sum => { + for info in infos { + println!("{}\t{} bits\t{}", info.name, info.bits, info.security); + } + } + } + Ok(ExitCode::Success) +} + +fn run_bench(cli: &Cli) -> Result { + let algorithms = selected_algorithms(&cli.algorithms); + let paths = collect_input_paths(cli)?; + let records = with_thread_pool(cli.threads, || { + if paths.is_empty() { + Ok(bench_synthetic(&algorithms, cli)) + } else { + bench_paths(cli, &paths, &algorithms) + } + })?; + render_bench_records(cli, &records)?; + Ok(map_result_count(records.len())) +} + +fn with_thread_pool( + threads: Option, + operation: impl FnOnce() -> Result + Send, +) -> Result +where + T: Send, +{ + if let Some(count) = threads { + rayon::ThreadPoolBuilder::new() + .num_threads(count) + .build() + .map_err(|error| CliError::runtime(format!("failed to build thread pool: {error}")))? + .install(operation) + } else { + operation() + } +} + +fn selected_algorithms(selection: &AlgorithmSelection) -> Vec { + match selection { + AlgorithmSelection::Default => vec![Algorithm::Sha256], + AlgorithmSelection::All => ALGORITHMS.iter().map(|(algorithm, _)| *algorithm).collect(), + AlgorithmSelection::Explicit(algorithms) => algorithms.clone(), + } +} + +fn collect_input_paths(cli: &Cli) -> Result, CliError> { + let mut roots = Vec::new(); + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if let Some(paths) = + common::read_existing_stdin_paths(&buffer, cli.common.input_format, "mhash")? + { + roots.extend(paths); + } + } + roots.extend(cli.paths.iter().cloned()); + + let mut files = Vec::new(); + for root in roots { + if root.is_file() { + if !is_ignored(&root, &cli.ignores) { + files.push(root); + } + } else if root.is_dir() { + if cli.recursive { + collect_recursive_files(&root, &cli.ignores, &mut files)?; + } else { + return Err(CliError::usage(format!( + "{} is a directory; pass --recursive to hash files below it", + root.display() + ))); + } + } else { + return Err(CliError::runtime(format!( + "path does not exist: {}", + root.display() + ))); + } + } + files.sort_unstable(); + files.dedup(); + Ok(files) +} + +fn collect_recursive_files( + root: &Path, + ignores: &[String], + output: &mut Vec, +) -> Result<(), CliError> { + for entry in WalkDir::new(root).follow_links(false) { + let entry = entry.map_err(|error| { + CliError::runtime(format!("failed to walk {}: {error}", root.display())) + })?; + let path = entry.path(); + if path.is_file() && !is_ignored(path, ignores) { + output.push(path.to_path_buf()); + } + } + Ok(()) +} + +fn is_ignored(path: &Path, patterns: &[String]) -> bool { + let normalized = path.to_string_lossy().replace('\\', "/"); + patterns + .iter() + .any(|pattern| wildcard_match(&normalized, &pattern.replace('\\', "/"))) +} + +fn wildcard_match(value: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + let Some(first_star) = pattern.find('*') else { + return value.contains(pattern); + }; + let prefix = &pattern[..first_star]; + let suffix = &pattern[first_star + 1..]; + value.contains(prefix) && value.ends_with(suffix) +} + +fn hash_paths( + cli: &Cli, + paths: &[PathBuf], + algorithms: &[Algorithm], +) -> Result, CliError> { + let use_path_parallelism = paths.len() > 1; + let per_path: Vec, CliError>> = if use_path_parallelism { + paths + .par_iter() + .map(|path| hash_path(cli, path, algorithms, false, false)) + .collect() + } else { + paths + .iter() + .map(|path| hash_path(cli, path, algorithms, true, true)) + .collect() + }; + let mut records = Vec::new(); + for result in per_path { + records.extend(result?); + } + Ok(records) +} + +fn hash_path( + cli: &Cli, + path: &Path, + algorithms: &[Algorithm], + parallel_jobs: bool, + allow_internal_parallelism: bool, +) -> Result, CliError> { + if should_stream_algorithm_set(path, cli.io_mode, algorithms, parallel_jobs)? { + let (digests, size) = + hash_streaming_algorithm_set(algorithms, path, cli.chunk_size, cli.uppercase)?; + let mut records: Vec<_> = digests + .into_iter() + .map(|(algorithm, digest)| build_hash_record(path, size, algorithm, digest)) + .collect(); + sort_hash_records_for_path(&mut records); + return Ok(records); + } + + let input = read_file_input(path, cli.io_mode, cli.chunk_size)?; + let data = input.as_bytes(); + let size = u64::try_from(data.len()) + .map_err(|error| CliError::runtime(format!("file too large to report size: {error}")))?; + let mut records: Vec<_> = hash_algorithm_set( + algorithms, + data, + cli.uppercase, + parallel_jobs, + allow_internal_parallelism, + ) + .into_iter() + .map(|(algorithm, digest)| build_hash_record(path, size, algorithm, digest)) + .collect(); + sort_hash_records_for_path(&mut records); + Ok(records) +} + +fn build_hash_record(path: &Path, size: u64, algorithm: Algorithm, digest: String) -> HashRecord { + HashRecord { + path: path.to_path_buf(), + size, + algorithm: algorithm.info().name, + digest, + ok: true, + error: None, + } +} + +fn sort_hash_records_for_path(records: &mut [HashRecord]) { + records.sort_unstable_by_key(|record| record.algorithm); +} + +fn should_stream_algorithm_set( + path: &Path, + mode: IoMode, + algorithms: &[Algorithm], + parallel_jobs: bool, +) -> Result { + if algorithms.is_empty() || mode == IoMode::Mmap { + return Ok(false); + } + if mode == IoMode::Read { + return Ok(!parallel_jobs || algorithms_are_streaming_friendly(algorithms)); + } + if algorithms_are_streaming_friendly(algorithms) { + return Ok(true); + } + let metadata = path.metadata().map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + Ok(metadata.len() < u64::try_from(MMAP_THRESHOLD_BYTES).expect("threshold fits u64")) +} + +const fn algorithms_are_streaming_friendly(algorithms: &[Algorithm]) -> bool { + if algorithms.is_empty() || algorithms.len() > 4 { + return false; + } + let mut index = 0; + while index < algorithms.len() { + if !algorithm_is_streaming_friendly(algorithms[index]) { + return false; + } + index += 1; + } + true +} + +const fn algorithm_is_streaming_friendly(algorithm: Algorithm) -> bool { + !matches!( + algorithm, + Algorithm::Blake2sp | Algorithm::Blake3_256 | Algorithm::Blake3_512 + ) +} + +fn verify_entries(cli: &Cli, entries: &[ManifestJsonRecord]) -> Vec { + let mut records = vec![None; entries.len()]; + let mut groups: BTreeMap> = BTreeMap::new(); + for (index, entry) in entries.iter().enumerate() { + match parse_algorithm(&entry.algorithm) { + Ok(algorithm) => { + groups + .entry(entry.path.clone()) + .or_default() + .push((index, algorithm)); + } + Err(error) => { + records[index] = Some(VerifyRecord { + path: entry.path.clone(), + algorithm: entry.algorithm.clone(), + expected: normalize_expected_digest(&entry.digest, cli.uppercase), + actual: None, + ok: false, + error: Some(error.to_string()), + }); + } + } + } + + let groups: Vec<_> = groups.into_iter().collect(); + let parallel_groups = groups.len() > 1; + let computed: Vec> = if parallel_groups { + groups + .par_iter() + .map(|(path, group)| verify_entry_group(cli, entries, path, group, false)) + .collect() + } else { + groups + .iter() + .map(|(path, group)| verify_entry_group(cli, entries, path, group, true)) + .collect() + }; + for (index, record) in computed.into_iter().flatten() { + records[index] = Some(record); + } + records + .into_iter() + .map(|record| record.expect("every verify entry is filled")) + .collect() +} + +fn verify_entry_group( + cli: &Cli, + entries: &[ManifestJsonRecord], + path: &Path, + group: &[(usize, Algorithm)], + allow_internal_parallelism: bool, +) -> Vec<(usize, VerifyRecord)> { + let mut algorithms: Vec<_> = group.iter().map(|(_, algorithm)| *algorithm).collect(); + algorithms.sort_unstable(); + algorithms.dedup(); + match hash_path( + cli, + path, + &algorithms, + allow_internal_parallelism, + allow_internal_parallelism, + ) { + Ok(hash_records) => { + let digests: BTreeMap<_, _> = hash_records + .into_iter() + .map(|record| (record.algorithm, record.digest)) + .collect(); + group + .iter() + .map(|(index, algorithm)| { + let entry = &entries[*index]; + let expected = normalize_expected_digest(&entry.digest, cli.uppercase); + let actual = digests.get(algorithm.info().name).cloned(); + let ok = actual + .as_deref() + .is_some_and(|digest| digest.eq_ignore_ascii_case(&expected)); + let error = actual + .is_none() + .then(|| format!("{} was not computed", algorithm.info().name)); + ( + *index, + VerifyRecord { + path: entry.path.clone(), + algorithm: algorithm.info().name.to_string(), + expected, + actual, + ok, + error, + }, + ) + }) + .collect() + } + Err(error) => group + .iter() + .map(|(index, algorithm)| { + let entry = &entries[*index]; + ( + *index, + VerifyRecord { + path: entry.path.clone(), + algorithm: algorithm.info().name.to_string(), + expected: normalize_expected_digest(&entry.digest, cli.uppercase), + actual: None, + ok: false, + error: Some(error.to_string()), + }, + ) + }) + .collect(), + } +} + +fn bench_synthetic(algorithms: &[Algorithm], cli: &Cli) -> Vec { + let data = vec![0xA5; 16 * 1024 * 1024]; + bench_data_records(None, algorithms, &data, cli) +} + +fn bench_paths( + cli: &Cli, + paths: &[PathBuf], + algorithms: &[Algorithm], +) -> Result, CliError> { + let mut records = Vec::new(); + for path in paths { + match cli.bench_mode { + BenchMode::Cpu => { + let input = read_file_input(path, cli.io_mode, cli.chunk_size)?; + let data = input.as_bytes(); + records.extend(bench_data_records( + Some(path.as_path()), + algorithms, + data, + cli, + )); + } + BenchMode::EndToEnd => { + records.extend(bench_path_end_to_end_records(path, algorithms, cli)?); + } + } + } + Ok(records) +} + +fn bench_data_records( + path: Option<&Path>, + algorithms: &[Algorithm], + data: &[u8], + cli: &Cli, +) -> Vec { + if bench_uses_parallel_algorithms(cli.threads) { + algorithms + .par_iter() + .map(|algorithm| bench_data(path, *algorithm, data, cli)) + .collect() + } else { + algorithms + .iter() + .map(|algorithm| bench_data(path, *algorithm, data, cli)) + .collect() + } +} + +const fn bench_uses_parallel_algorithms(threads: Option) -> bool { + matches!(threads, Some(count) if count > 1) +} + +fn bench_path_end_to_end_records( + path: &Path, + algorithms: &[Algorithm], + cli: &Cli, +) -> Result, CliError> { + if bench_uses_parallel_algorithms(cli.threads) { + algorithms + .par_iter() + .map(|algorithm| bench_path_end_to_end(path, *algorithm, cli)) + .collect() + } else { + algorithms + .iter() + .map(|algorithm| bench_path_end_to_end(path, *algorithm, cli)) + .collect() + } +} + +fn bench_data(path: Option<&Path>, algorithm: Algorithm, data: &[u8], cli: &Cli) -> BenchRecord { + let bytes = u64::try_from(data.len()).unwrap_or(u64::MAX); + for _ in 0..cli.bench_warmup { + std::hint::black_box(hash_algorithm(algorithm, data, cli.uppercase)); + } + let mut elapsed = Duration::ZERO; + for _ in 0..cli.bench_repeat { + let start = Instant::now(); + let digest = hash_algorithm(algorithm, data, cli.uppercase); + std::hint::black_box(digest); + elapsed += start.elapsed(); + } + bench_record(path.map(Path::to_path_buf), algorithm, bytes, elapsed, cli) +} + +fn bench_path_end_to_end( + path: &Path, + algorithm: Algorithm, + cli: &Cli, +) -> Result { + let mut bytes = 0; + for _ in 0..cli.bench_warmup { + let input = read_file_input(path, cli.io_mode, cli.chunk_size)?; + let data = input.as_bytes(); + bytes = u64::try_from(data.len()).unwrap_or(u64::MAX); + std::hint::black_box(hash_algorithm(algorithm, data, cli.uppercase)); + } + let mut elapsed = Duration::ZERO; + for _ in 0..cli.bench_repeat { + let start = Instant::now(); + let input = read_file_input(path, cli.io_mode, cli.chunk_size)?; + let data = input.as_bytes(); + bytes = u64::try_from(data.len()).unwrap_or(u64::MAX); + let digest = hash_algorithm(algorithm, data, cli.uppercase); + std::hint::black_box(digest); + elapsed += start.elapsed(); + } + Ok(bench_record( + Some(path.to_path_buf()), + algorithm, + bytes, + elapsed, + cli, + )) +} + +fn bench_record( + path: Option, + algorithm: Algorithm, + bytes: u64, + elapsed: Duration, + cli: &Cli, +) -> BenchRecord { + #[allow( + clippy::cast_precision_loss, + reason = "benchmark averages and throughput are approximate human-facing telemetry" + )] + let elapsed_secs = (elapsed.as_secs_f64() / cli.bench_repeat as f64).max(f64::EPSILON); + #[allow( + clippy::cast_precision_loss, + reason = "benchmark averages and throughput are approximate human-facing telemetry" + )] + let mib = bytes as f64 / (1024.0 * 1024.0); + BenchRecord { + path, + algorithm: algorithm.info().name, + repeat: cli.bench_repeat, + warmup: cli.bench_warmup, + bench_mode: cli.bench_mode, + bytes, + elapsed_ms: elapsed_secs * 1000.0, + throughput_mib_s: mib / elapsed_secs, + } +} + +struct FileInput { + bytes: FileBytes, +} + +enum FileBytes { + Owned(Vec), + Mapped(Mmap), +} + +impl FileInput { + fn as_bytes(&self) -> &[u8] { + match &self.bytes { + FileBytes::Owned(bytes) => bytes, + FileBytes::Mapped(map) => map, + } + } +} + +fn read_file_input(path: &Path, mode: IoMode, chunk_size: usize) -> Result { + let file = File::open(path).map_err(|error| { + CliError::runtime(format!("failed to open {}: {error}", path.display())) + })?; + let metadata = file.metadata().map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + let should_mmap = match mode { + IoMode::Mmap => true, + IoMode::Read => false, + IoMode::Auto => { + metadata.len() >= u64::try_from(MMAP_THRESHOLD_BYTES).expect("threshold fits u64") + } + }; + if should_mmap { + match fast_io::map_file(&file) { + Ok(mapped) => { + return Ok(FileInput { + bytes: FileBytes::Mapped(mapped), + }); + } + Err(error) if mode == IoMode::Mmap => { + return Err(CliError::runtime(format!( + "failed to memory-map {}: {error}", + path.display() + ))); + } + Err(_) => {} + } + } + let capacity = usize::try_from(metadata.len()).unwrap_or_default(); + let bytes = read_buffered(file, path, chunk_size, capacity)?; + Ok(FileInput { + bytes: FileBytes::Owned(bytes), + }) +} + +fn read_buffered( + mut file: File, + path: &Path, + chunk_size: usize, + capacity: usize, +) -> Result, CliError> { + let mut output = Vec::with_capacity(capacity); + let mut buffer = vec![0; read_buffer_size(chunk_size, capacity)]; + loop { + let read = file.read(&mut buffer).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + if read == 0 { + break; + } + output.extend_from_slice(&buffer[..read]); + } + Ok(output) +} + +fn hash_streaming_algorithm_set( + algorithms: &[Algorithm], + path: &Path, + chunk_size: usize, + uppercase: bool, +) -> Result<(Vec<(Algorithm, String)>, u64), CliError> { + let mut file = File::open(path).map_err(|error| { + CliError::runtime(format!("failed to open {}: {error}", path.display())) + })?; + let metadata = file.metadata().map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + let capacity = usize::try_from(metadata.len()).unwrap_or_default(); + let mut jobs: Vec<_> = build_hash_jobs(algorithms) + .into_iter() + .map(|job| StreamingHashJob::new(job, capacity)) + .collect(); + let size = feed_reader(&mut file, path, chunk_size, capacity, |chunk| { + for job in &mut jobs { + job.update(chunk); + } + })?; + let mut records: Vec<_> = jobs + .into_iter() + .flat_map(|job| job.finalize(algorithms, uppercase)) + .collect(); + records.sort_unstable_by_key(|(algorithm, _)| *algorithm); + Ok((records, size)) +} + +#[allow( + clippy::too_many_lines, + reason = "the enum keeps streaming fanout monomorphic and avoids trait-object dispatch in the hot loop" +)] +impl StreamingHashJob { + fn new(job: HashJob, capacity: usize) -> Self { + let state = match job { + HashJob::Single(algorithm) => StreamingHashState::new_single(algorithm, capacity), + HashJob::Xxh3 => StreamingHashState::Xxh3(xxhash_rust::xxh3::Xxh3::new()), + HashJob::Blake3 { bytes } => { + StreamingHashState::Blake3(Box::new(blake3::Hasher::new()), bytes) + } + HashJob::KangarooTwelve { bytes } => StreamingHashState::KangarooTwelve( + tiny_keccak::KangarooTwelve::new(&[] as &'static [u8]), + bytes, + ), + }; + Self { job, state } + } + + fn update(&mut self, chunk: &[u8]) { + self.state.update(chunk); + } + + fn finalize(self, algorithms: &[Algorithm], uppercase: bool) -> Vec<(Algorithm, String)> { + match self.job { + HashJob::Single(algorithm) => { + vec![(algorithm, self.state.finalize_single(algorithm, uppercase))] + } + HashJob::Xxh3 => self.state.finalize_xxh3(algorithms, uppercase), + HashJob::Blake3 { .. } => { + let digest = self.state.finalize_xof(); + encode_shared_xof_outputs(algorithms, digest.as_slice(), uppercase, is_blake3) + } + HashJob::KangarooTwelve { .. } => { + let digest = self.state.finalize_xof(); + encode_shared_xof_outputs( + algorithms, + digest.as_slice(), + uppercase, + is_kangaroo_twelve, + ) + } + } + } +} + +#[allow( + clippy::too_many_lines, + reason = "the explicit variants keep every streaming backend visible and type-specialized" +)] +impl StreamingHashState { + fn new_single(algorithm: Algorithm, _capacity: usize) -> Self { + match algorithm { + Algorithm::Crc32 => Self::Crc32(crc32fast::Hasher::new()), + Algorithm::Crc64Xz => Self::Crc64Xz(CRC64_XZ.digest()), + Algorithm::Xxh32 => Self::Xxh32(xxhash_rust::xxh32::Xxh32::new(0)), + Algorithm::Xxh64 => Self::Xxh64(xxhash_rust::xxh64::Xxh64::new(0)), + Algorithm::Xxh3_64 | Algorithm::Xxh3_128 => Self::Xxh3(xxhash_rust::xxh3::Xxh3::new()), + Algorithm::Md4 => Self::Md4(md4::Md4::new()), + Algorithm::Md5 => Self::Md5(md5::Md5::new()), + Algorithm::Ripemd160 => Self::Ripemd160(ripemd::Ripemd160::new()), + Algorithm::Blake2sp => Self::Blake2sp(Box::new(blake2s_simd::blake2sp::State::new())), + Algorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), + Algorithm::Sha224 => Self::Sha224(sha2::Sha224::new()), + Algorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), + Algorithm::Sha384 => Self::Sha384(sha2::Sha384::new()), + Algorithm::Sha512 => Self::Sha512(sha2::Sha512::new()), + Algorithm::Sha3_224 => Self::Sha3_224(sha3::Sha3_224::new()), + Algorithm::Sha3_256 => Self::Sha3_256(sha3::Sha3_256::new()), + Algorithm::Sha3_384 => Self::Sha3_384(sha3::Sha3_384::new()), + Algorithm::Sha3_512 => Self::Sha3_512(sha3::Sha3_512::new()), + Algorithm::Blake3_256 => Self::Blake3(Box::new(blake3::Hasher::new()), 32), + Algorithm::Blake3_512 => Self::Blake3(Box::new(blake3::Hasher::new()), 64), + Algorithm::KangarooTwelve264 => { + Self::KangarooTwelve(tiny_keccak::KangarooTwelve::new(&[] as &'static [u8]), 33) + } + Algorithm::KangarooTwelve256 => { + Self::KangarooTwelve(tiny_keccak::KangarooTwelve::new(&[] as &'static [u8]), 32) + } + Algorithm::KangarooTwelve512 => { + Self::KangarooTwelve(tiny_keccak::KangarooTwelve::new(&[] as &'static [u8]), 64) + } + Algorithm::ParallelHash128_264 => Self::ParallelHash( + tiny_keccak::ParallelHash::v128(&[], PARALLEL_HASH_BLOCK_SIZE), + 33, + ), + Algorithm::ParallelHash256_528 => Self::ParallelHash( + tiny_keccak::ParallelHash::v256(&[], PARALLEL_HASH_BLOCK_SIZE), + 66, + ), + Algorithm::Streebog256 => Self::Streebog256(streebog::Streebog256::new()), + Algorithm::Streebog512 => Self::Streebog512(streebog::Streebog512::new()), + } + } + + fn update(&mut self, chunk: &[u8]) { + use tiny_keccak::Hasher as _; + + match self { + Self::Crc32(hasher) => hasher.update(chunk), + Self::Crc64Xz(hasher) => hasher.update(chunk), + Self::Xxh32(hasher) => hasher.update(chunk), + Self::Xxh64(hasher) => hasher.update(chunk), + Self::Xxh3(hasher) => hasher.update(chunk), + Self::Md4(hasher) => hasher.update(chunk), + Self::Md5(hasher) => hasher.update(chunk), + Self::Ripemd160(hasher) => hasher.update(chunk), + Self::Blake2sp(hasher) => { + hasher.update(chunk); + } + Self::Sha1(hasher) => hasher.update(chunk), + Self::Sha224(hasher) => hasher.update(chunk), + Self::Sha256(hasher) => hasher.update(chunk), + Self::Sha384(hasher) => hasher.update(chunk), + Self::Sha512(hasher) => hasher.update(chunk), + Self::Sha3_224(hasher) => hasher.update(chunk), + Self::Sha3_256(hasher) => hasher.update(chunk), + Self::Sha3_384(hasher) => hasher.update(chunk), + Self::Sha3_512(hasher) => hasher.update(chunk), + Self::Blake3(hasher, _) => { + hasher.update(chunk); + } + Self::KangarooTwelve(hasher, _) => hasher.update(chunk), + Self::ParallelHash(hasher, _) => hasher.update(chunk), + Self::Streebog256(hasher) => hasher.update(chunk), + Self::Streebog512(hasher) => hasher.update(chunk), + } + } + + fn finalize_single(self, algorithm: Algorithm, uppercase: bool) -> String { + match (algorithm, self) { + (Algorithm::Crc32, Self::Crc32(hasher)) => { + encode_fixed_u64(u64::from(hasher.finalize()), 8, uppercase) + } + (Algorithm::Crc64Xz, Self::Crc64Xz(hasher)) => { + encode_fixed_u64(hasher.finalize(), 16, uppercase) + } + (Algorithm::Xxh32, Self::Xxh32(hasher)) => { + encode_fixed_u64(u64::from(hasher.digest()), 8, uppercase) + } + (Algorithm::Xxh64, Self::Xxh64(hasher)) => { + encode_fixed_u64(hasher.digest(), 16, uppercase) + } + (Algorithm::Xxh3_64, Self::Xxh3(hasher)) => { + encode_fixed_u64(hasher.digest(), 16, uppercase) + } + (Algorithm::Xxh3_128, Self::Xxh3(hasher)) => { + encode_fixed_u128(hasher.digest128(), uppercase) + } + (Algorithm::Md4, Self::Md4(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Md5, Self::Md5(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Ripemd160, Self::Ripemd160(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + (Algorithm::Blake2sp, Self::Blake2sp(hasher)) => { + let digest = hasher.finalize(); + encode_hex(digest.as_bytes(), uppercase) + } + (Algorithm::Sha1, Self::Sha1(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Sha224, Self::Sha224(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Sha256, Self::Sha256(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Sha384, Self::Sha384(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Sha512, Self::Sha512(hasher)) => encode_hex(&hasher.finalize(), uppercase), + (Algorithm::Sha3_224, Self::Sha3_224(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + (Algorithm::Sha3_256, Self::Sha3_256(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + (Algorithm::Sha3_384, Self::Sha3_384(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + (Algorithm::Sha3_512, Self::Sha3_512(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + (Algorithm::Blake3_256 | Algorithm::Blake3_512, Self::Blake3(hasher, output_len)) => { + finalize_blake3_hasher(&hasher, output_len, uppercase) + } + ( + Algorithm::KangarooTwelve264 + | Algorithm::KangarooTwelve256 + | Algorithm::KangarooTwelve512, + Self::KangarooTwelve(hasher, output_len), + ) => finalize_k12_hasher(hasher, output_len, uppercase), + ( + Algorithm::ParallelHash128_264 | Algorithm::ParallelHash256_528, + Self::ParallelHash(hasher, output_len), + ) => finalize_parallel_hash_hasher(hasher, output_len, uppercase), + (Algorithm::Streebog256, Self::Streebog256(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + (Algorithm::Streebog512, Self::Streebog512(hasher)) => { + encode_hex(&hasher.finalize(), uppercase) + } + _ => unreachable!("streaming hash state must match its algorithm"), + } + } + + fn finalize_xof(self) -> DigestBytes { + match self { + Self::Blake3(hasher, output_len) => finalize_blake3_bytes(&hasher, output_len), + Self::KangarooTwelve(hasher, output_len) => finalize_k12_bytes(hasher, output_len), + _ => unreachable!("only shared XOF states use finalize_xof"), + } + } + + fn finalize_xxh3(self, algorithms: &[Algorithm], uppercase: bool) -> Vec<(Algorithm, String)> { + match self { + Self::Xxh3(hasher) => encode_xxh3_outputs(algorithms, &hasher, uppercase), + _ => unreachable!("only shared xxh3 states use finalize_xxh3"), + } + } +} + +fn finalize_blake3_hasher(hasher: &blake3::Hasher, output_len: usize, uppercase: bool) -> String { + encode_hex( + finalize_blake3_bytes(hasher, output_len).as_slice(), + uppercase, + ) +} + +fn finalize_blake3_bytes(hasher: &blake3::Hasher, output_len: usize) -> DigestBytes { + let mut reader = hasher.finalize_xof(); + let mut output = DigestBytes::new(output_len); + reader.fill(&mut output.bytes[..output_len]); + output +} + +fn finalize_k12_hasher( + hasher: tiny_keccak::KangarooTwelve<&'static [u8]>, + output_len: usize, + uppercase: bool, +) -> String { + encode_hex(finalize_k12_bytes(hasher, output_len).as_slice(), uppercase) +} + +fn finalize_k12_bytes( + hasher: tiny_keccak::KangarooTwelve<&'static [u8]>, + output_len: usize, +) -> DigestBytes { + use tiny_keccak::Hasher as _; + + let mut output = DigestBytes::new(output_len); + hasher.finalize(&mut output.bytes[..output_len]); + output +} + +fn finalize_parallel_hash_hasher( + hasher: tiny_keccak::ParallelHash, + output_len: usize, + uppercase: bool, +) -> String { + use tiny_keccak::Hasher as _; + + let mut output = DigestBytes::new(output_len); + hasher.finalize(&mut output.bytes[..output_len]); + encode_hex(output.as_slice(), uppercase) +} + +fn feed_reader( + file: &mut File, + path: &Path, + chunk_size: usize, + expected_len: usize, + mut consume: impl FnMut(&[u8]), +) -> Result { + let mut size = 0_u64; + let mut buffer = vec![0; read_buffer_size(chunk_size, expected_len)]; + loop { + let read = file.read(&mut buffer).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + if read == 0 { + break; + } + size = size + .checked_add( + u64::try_from(read) + .map_err(|error| CliError::runtime(format!("read size overflow: {error}")))?, + ) + .ok_or_else(|| CliError::runtime("file too large to report size"))?; + consume(&buffer[..read]); + } + Ok(size) +} + +fn read_buffer_size(chunk_size: usize, expected_len: usize) -> usize { + let requested = chunk_size.max(4096); + if expected_len == 0 { + 1 + } else { + requested.min(expected_len) + } +} + +mod fast_io { + //! Unsafe IO acceleration points. + //! + //! Safety contract: memory maps are read-only, tied to a live file handle during creation, + //! and exposed as immutable byte slices. Callers fall back to buffered reads when mapping is + //! unavailable or explicitly disabled. + + use std::fs::File; + use std::io; + + use memmap2::{Mmap, MmapOptions}; + + #[allow( + clippy::redundant_pub_crate, + reason = "parent module owns the mmap fallback policy while unsafe code stays isolated here" + )] + pub(super) fn map_file(file: &File) -> io::Result { + #[allow(unsafe_code)] + unsafe { + MmapOptions::new().map(file) + } + } +} + +fn hash_algorithm_set( + algorithms: &[Algorithm], + data: &[u8], + uppercase: bool, + parallel_jobs: bool, + allow_internal_parallelism: bool, +) -> Vec<(Algorithm, String)> { + let jobs = build_hash_jobs(algorithms); + let use_parallel_jobs = parallel_jobs && jobs.len() > 1; + let per_job: Vec> = if use_parallel_jobs { + jobs.par_iter() + .map(|job| { + run_hash_job( + *job, + algorithms, + data, + uppercase, + allow_hash_job_internal_parallelism( + *job, + data.len(), + allow_internal_parallelism, + use_parallel_jobs, + ), + ) + }) + .collect() + } else { + jobs.iter() + .map(|job| { + run_hash_job( + *job, + algorithms, + data, + uppercase, + allow_hash_job_internal_parallelism( + *job, + data.len(), + allow_internal_parallelism, + use_parallel_jobs, + ), + ) + }) + .collect() + }; + let mut records: Vec<_> = per_job.into_iter().flatten().collect(); + records.sort_unstable_by_key(|(algorithm, _)| *algorithm); + records +} + +const fn allow_hash_job_internal_parallelism( + job: HashJob, + data_len: usize, + allow_internal_parallelism: bool, + use_parallel_jobs: bool, +) -> bool { + if !allow_internal_parallelism { + return false; + } + match job { + HashJob::Blake3 { .. } | HashJob::Single(Algorithm::Blake3_256 | Algorithm::Blake3_512) => { + allow_blake3_internal_parallelism(data_len, use_parallel_jobs) + } + _ => !use_parallel_jobs, + } +} + +const fn allow_blake3_internal_parallelism(data_len: usize, _use_parallel_jobs: bool) -> bool { + data_len >= MMAP_THRESHOLD_BYTES +} + +fn build_hash_jobs(algorithms: &[Algorithm]) -> Vec { + let mut jobs = Vec::with_capacity(algorithms.len()); + let share_xxh3 = has_both_xxh3_outputs(algorithms); + if share_xxh3 { + jobs.push(HashJob::Xxh3); + } + if let Some(bytes) = max_blake3_output_len(algorithms) { + jobs.push(HashJob::Blake3 { bytes }); + } + if let Some(bytes) = max_kangaroo_twelve_output_len(algorithms) { + jobs.push(HashJob::KangarooTwelve { bytes }); + } + jobs.extend( + algorithms + .iter() + .copied() + .filter(|algorithm| { + (!share_xxh3 || !is_xxh3(*algorithm)) + && !is_blake3(*algorithm) + && !is_kangaroo_twelve(*algorithm) + }) + .map(HashJob::Single), + ); + jobs +} + +fn run_hash_job( + job: HashJob, + algorithms: &[Algorithm], + data: &[u8], + uppercase: bool, + allow_internal_parallelism: bool, +) -> Vec<(Algorithm, String)> { + match job { + HashJob::Single(algorithm) => { + vec![( + algorithm, + hash_algorithm_with_options(algorithm, data, uppercase, allow_internal_parallelism), + )] + } + HashJob::Xxh3 => { + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); + hasher.update(data); + encode_xxh3_outputs(algorithms, &hasher, uppercase) + } + HashJob::Blake3 { bytes } => { + let digest = hash_blake3_bytes(data, bytes, allow_internal_parallelism); + encode_shared_xof_outputs(algorithms, digest.as_slice(), uppercase, is_blake3) + } + HashJob::KangarooTwelve { bytes } => { + let digest = hash_k12_bytes(data, bytes); + encode_shared_xof_outputs(algorithms, digest.as_slice(), uppercase, is_kangaroo_twelve) + } + } +} + +fn encode_xxh3_outputs( + algorithms: &[Algorithm], + hasher: &xxhash_rust::xxh3::Xxh3, + uppercase: bool, +) -> Vec<(Algorithm, String)> { + algorithms + .iter() + .copied() + .filter(|algorithm| is_xxh3(*algorithm)) + .map(|algorithm| { + let digest = match algorithm { + Algorithm::Xxh3_64 => encode_fixed_u64(hasher.digest(), 16, uppercase), + Algorithm::Xxh3_128 => encode_fixed_u128(hasher.digest128(), uppercase), + _ => unreachable!("only xxh3 algorithms pass the filter"), + }; + (algorithm, digest) + }) + .collect() +} + +fn encode_shared_xof_outputs( + algorithms: &[Algorithm], + digest: &[u8], + uppercase: bool, + predicate: fn(Algorithm) -> bool, +) -> Vec<(Algorithm, String)> { + let output_count = algorithms + .iter() + .copied() + .filter(|algorithm| predicate(*algorithm)) + .count(); + if output_count > 1 { + let encoded = encode_hex(digest, uppercase); + return algorithms + .iter() + .copied() + .filter(|algorithm| predicate(*algorithm)) + .map(|algorithm| { + let hex_len = algorithm.info().bytes * 2; + (algorithm, encoded[..hex_len].to_string()) + }) + .collect(); + } + + algorithms + .iter() + .copied() + .filter(|algorithm| predicate(*algorithm)) + .map(|algorithm| { + let bytes = algorithm.info().bytes; + (algorithm, encode_hex(&digest[..bytes], uppercase)) + }) + .collect() +} + +fn max_blake3_output_len(algorithms: &[Algorithm]) -> Option { + max_xof_output_len(algorithms, is_blake3) +} + +fn max_kangaroo_twelve_output_len(algorithms: &[Algorithm]) -> Option { + max_xof_output_len(algorithms, is_kangaroo_twelve) +} + +fn max_xof_output_len(algorithms: &[Algorithm], predicate: fn(Algorithm) -> bool) -> Option { + algorithms + .iter() + .copied() + .filter(|algorithm| predicate(*algorithm)) + .map(|algorithm| algorithm.info().bytes) + .max() +} + +const fn is_xxh3(algorithm: Algorithm) -> bool { + matches!(algorithm, Algorithm::Xxh3_64 | Algorithm::Xxh3_128) +} + +const fn is_blake3(algorithm: Algorithm) -> bool { + matches!(algorithm, Algorithm::Blake3_256 | Algorithm::Blake3_512) +} + +const fn is_kangaroo_twelve(algorithm: Algorithm) -> bool { + matches!( + algorithm, + Algorithm::KangarooTwelve264 | Algorithm::KangarooTwelve256 | Algorithm::KangarooTwelve512 + ) +} + +const fn has_both_xxh3_outputs(algorithms: &[Algorithm]) -> bool { + let mut has_64 = false; + let mut has_128 = false; + let mut index = 0; + while index < algorithms.len() { + match algorithms[index] { + Algorithm::Xxh3_64 => has_64 = true, + Algorithm::Xxh3_128 => has_128 = true, + _ => {} + } + index += 1; + } + has_64 && has_128 +} + +fn hash_algorithm(algorithm: Algorithm, data: &[u8], uppercase: bool) -> String { + hash_algorithm_with_options(algorithm, data, uppercase, true) +} + +fn hash_algorithm_with_options( + algorithm: Algorithm, + data: &[u8], + uppercase: bool, + allow_internal_parallelism: bool, +) -> String { + match algorithm { + Algorithm::Crc32 => encode_fixed_u64(u64::from(hash_crc32(data)), 8, uppercase), + Algorithm::Crc64Xz => encode_fixed_u64(hash_crc64_xz(data), 16, uppercase), + Algorithm::Xxh32 => { + encode_fixed_u64(u64::from(xxhash_rust::xxh32::xxh32(data, 0)), 8, uppercase) + } + Algorithm::Xxh64 => encode_fixed_u64(xxhash_rust::xxh64::xxh64(data, 0), 16, uppercase), + Algorithm::Xxh3_64 => encode_fixed_u64(xxhash_rust::xxh3::xxh3_64(data), 16, uppercase), + Algorithm::Xxh3_128 => encode_fixed_u128(xxhash_rust::xxh3::xxh3_128(data), uppercase), + Algorithm::Md4 => fixed_digest::(data, uppercase), + Algorithm::Md5 => fixed_digest::(data, uppercase), + Algorithm::Ripemd160 => fixed_digest::(data, uppercase), + Algorithm::Blake2sp => { + let digest = blake2s_simd::blake2sp::blake2sp(data); + encode_hex(digest.as_bytes(), uppercase) + } + Algorithm::Sha1 => fixed_digest::(data, uppercase), + Algorithm::Sha224 => fixed_digest::(data, uppercase), + Algorithm::Sha256 => fixed_digest::(data, uppercase), + Algorithm::Sha384 => fixed_digest::(data, uppercase), + Algorithm::Sha512 => fixed_digest::(data, uppercase), + Algorithm::Sha3_224 => fixed_digest::(data, uppercase), + Algorithm::Sha3_256 => fixed_digest::(data, uppercase), + Algorithm::Sha3_384 => fixed_digest::(data, uppercase), + Algorithm::Sha3_512 => fixed_digest::(data, uppercase), + Algorithm::Blake3_256 => hash_blake3(data, 32, uppercase, allow_internal_parallelism), + Algorithm::Blake3_512 => hash_blake3(data, 64, uppercase, allow_internal_parallelism), + Algorithm::KangarooTwelve264 => hash_k12(data, 33, uppercase), + Algorithm::KangarooTwelve256 => hash_k12(data, 32, uppercase), + Algorithm::KangarooTwelve512 => hash_k12(data, 64, uppercase), + Algorithm::ParallelHash128_264 => { + hash_parallel_hash(data, 33, ParallelHashStrength::Bits128, uppercase) + } + Algorithm::ParallelHash256_528 => { + hash_parallel_hash(data, 66, ParallelHashStrength::Bits256, uppercase) + } + Algorithm::Streebog256 => fixed_digest::(data, uppercase), + Algorithm::Streebog512 => fixed_digest::(data, uppercase), + } +} + +fn fixed_digest(data: &[u8], uppercase: bool) -> String +where + D: digest_traits::Digest, +{ + encode_hex(&D::digest(data), uppercase) +} + +fn hash_crc32(data: &[u8]) -> u32 { + let mut hasher = crc32fast::Hasher::new(); + hasher.update(data); + hasher.finalize() +} + +#[allow( + clippy::missing_const_for_fn, + reason = "crc digest state update is runtime work despite a const-capable constructor" +)] +fn hash_crc64_xz(data: &[u8]) -> u64 { + let mut digest = CRC64_XZ.digest(); + digest.update(data); + digest.finalize() +} + +fn hash_blake3( + data: &[u8], + output_len: usize, + uppercase: bool, + allow_internal_parallelism: bool, +) -> String { + encode_hex( + hash_blake3_bytes(data, output_len, allow_internal_parallelism).as_slice(), + uppercase, + ) +} + +fn hash_blake3_bytes( + data: &[u8], + output_len: usize, + allow_internal_parallelism: bool, +) -> DigestBytes { + let mut hasher = blake3::Hasher::new(); + if allow_internal_parallelism && data.len() >= MMAP_THRESHOLD_BYTES { + hasher.update_rayon(data); + } else { + hasher.update(data); + } + let mut reader = hasher.finalize_xof(); + let mut output = DigestBytes::new(output_len); + reader.fill(&mut output.bytes[..output_len]); + output +} + +fn hash_k12(data: &[u8], output_len: usize, uppercase: bool) -> String { + encode_hex(hash_k12_bytes(data, output_len).as_slice(), uppercase) +} + +fn hash_k12_bytes(data: &[u8], output_len: usize) -> DigestBytes { + use tiny_keccak::Hasher as _; + let mut hasher = tiny_keccak::KangarooTwelve::new(&[] as &[u8]); + hasher.update(data); + let mut output = DigestBytes::new(output_len); + hasher.finalize(&mut output.bytes[..output_len]); + output +} + +fn hash_parallel_hash( + data: &[u8], + output_len: usize, + strength: ParallelHashStrength, + uppercase: bool, +) -> String { + encode_hex( + hash_parallel_hash_bytes(data, output_len, strength).as_slice(), + uppercase, + ) +} + +fn hash_parallel_hash_bytes( + data: &[u8], + output_len: usize, + strength: ParallelHashStrength, +) -> DigestBytes { + use tiny_keccak::Hasher as _; + let mut hasher = match strength { + ParallelHashStrength::Bits128 => { + tiny_keccak::ParallelHash::v128(&[], PARALLEL_HASH_BLOCK_SIZE) + } + ParallelHashStrength::Bits256 => { + tiny_keccak::ParallelHash::v256(&[], PARALLEL_HASH_BLOCK_SIZE) + } + }; + hasher.update(data); + let mut output = DigestBytes::new(output_len); + hasher.finalize(&mut output.bytes[..output_len]); + output +} + +fn encode_hex(bytes: &[u8], uppercase: bool) -> String { + let table = if uppercase { HEX_UPPER } else { HEX_LOWER }; + let mut output = Vec::with_capacity(bytes.len() * 2); + for &byte in bytes { + output.push(table[usize::from(byte >> 4)]); + output.push(table[usize::from(byte & 0x0f)]); + } + String::from_utf8(output) + .unwrap_or_else(|error| unreachable!("hex lookup table is ASCII: {error}")) +} + +fn encode_fixed_u64(value: u64, width: usize, uppercase: bool) -> String { + let bytes = value.to_be_bytes(); + encode_hex(&bytes[bytes.len() - (width / 2)..], uppercase) +} + +fn encode_fixed_u128(value: u128, uppercase: bool) -> String { + encode_hex(&value.to_be_bytes(), uppercase) +} + +impl Algorithm { + fn info(self) -> AlgorithmInfo { + let (algorithm, info) = ALGORITHMS[self as usize]; + debug_assert_eq!(algorithm, self); + info + } +} + +fn parse_algorithm_list(value: &str) -> Result, CliError> { + let mut algorithms = Vec::new(); + for item in value.split(',') { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + algorithms.push(parse_algorithm(trimmed)?); + } + if algorithms.is_empty() { + return Err(CliError::usage("--algorithm requires at least one name")); + } + algorithms.sort_unstable(); + algorithms.dedup(); + Ok(algorithms) +} + +fn parse_algorithm(value: &str) -> Result { + let normalized = normalize_algorithm_name(value); + for (algorithm, info) in ALGORITHMS { + if normalize_algorithm_name(info.name) == normalized + || info + .aliases + .iter() + .any(|alias| normalize_algorithm_name(alias) == normalized) + { + return Ok(*algorithm); + } + } + let suggestion = suggest_algorithm(&normalized); + Err(CliError::usage(format!( + "unsupported algorithm '{value}'{}", + suggestion + .map(|item| format!("; did you mean {item}?")) + .unwrap_or_default() + ))) +} + +fn normalize_algorithm_name(value: &str) -> String { + value + .trim() + .to_ascii_lowercase() + .replace('_', "-") + .replace(' ', "") +} + +fn suggest_algorithm(value: &str) -> Option<&'static str> { + ALGORITHMS + .iter() + .map(|(_, info)| info.name) + .min_by_key(|name| levenshtein_distance(value, &normalize_algorithm_name(name))) +} + +fn levenshtein_distance(left: &str, right: &str) -> usize { + let mut costs: Vec = (0..=right.len()).collect(); + for (left_index, left_char) in left.chars().enumerate() { + let mut previous = left_index; + costs[0] = left_index + 1; + for (right_index, right_char) in right.chars().enumerate() { + let old = costs[right_index + 1]; + let replacement = previous + usize::from(left_char != right_char); + costs[right_index + 1] = (costs[right_index] + 1).min(old + 1).min(replacement); + previous = old; + } + } + costs[right.len()] +} + +fn parse_output_format(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "text" => Ok(OutputFormat::Text), + "sum" => Ok(OutputFormat::Sum), + "json" => Ok(OutputFormat::Json), + "jsonl" => Ok(OutputFormat::Jsonl), + "toon" => Ok(OutputFormat::Toon), + other => Err(CliError::usage(format!( + "invalid --format value '{other}'; expected text, sum, json, jsonl, or toon" + ))), + } +} + +fn parse_io_mode(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(IoMode::Auto), + "mmap" => Ok(IoMode::Mmap), + "read" => Ok(IoMode::Read), + other => Err(CliError::usage(format!( + "invalid --io value '{other}'; expected auto, mmap, or read" + ))), + } +} + +fn parse_bench_mode(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "cpu" => Ok(BenchMode::Cpu), + "end-to-end" | "end_to_end" | "e2e" => Ok(BenchMode::EndToEnd), + other => Err(CliError::usage(format!( + "invalid --bench-mode value '{other}'; expected cpu or end-to-end" + ))), + } +} + +fn parse_positive_usize(flag: &str, value: &str) -> Result { + let parsed = value + .parse::() + .map_err(|error| CliError::usage(format!("{flag} expects a positive integer: {error}")))?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than zero"))); + } + Ok(parsed) +} + +fn parse_nonnegative_usize(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("{flag} expects a non-negative integer: {error}"))) +} + +fn parse_size(flag: &str, value: &str) -> Result { + let trimmed = value.trim(); + let (number, multiplier) = match trimmed.chars().last() { + Some('k' | 'K') => (&trimmed[..trimmed.len() - 1], 1024usize), + Some('m' | 'M') => (&trimmed[..trimmed.len() - 1], 1024usize * 1024), + Some('g' | 'G') => (&trimmed[..trimmed.len() - 1], 1024usize * 1024 * 1024), + _ => (trimmed, 1), + }; + let parsed = parse_positive_usize(flag, number)?; + parsed + .checked_mul(multiplier) + .ok_or_else(|| CliError::usage(format!("{flag} is too large"))) +} + +fn render_hash_records(cli: &Cli, records: &[HashRecord]) -> Result<(), CliError> { + match effective_output(cli) { + OutputFormat::Json => print_json(&records), + OutputFormat::Toon => print_structured(&records, RenderMode::Toon), + OutputFormat::Jsonl => render_jsonl(records), + OutputFormat::Sum => { + for record in records { + println!("{} {}", record.digest, record.path.display()); + } + Ok(()) + } + OutputFormat::Text => { + for record in records { + println!( + "{}\t{}\t{}", + record.algorithm, + record.digest, + record.path.display() + ); + } + Ok(()) + } + } +} + +fn render_verify_records(cli: &Cli, records: &[VerifyRecord]) -> Result<(), CliError> { + match effective_output(cli) { + OutputFormat::Json => print_json(&records), + OutputFormat::Toon => print_structured(&records, RenderMode::Toon), + OutputFormat::Jsonl => render_jsonl(records), + OutputFormat::Sum | OutputFormat::Text => { + for record in records { + let status = if record.ok { "ok" } else { "mismatch" }; + println!( + "{}\t{}\t{}", + status, + record.algorithm, + record.path.display() + ); + } + Ok(()) + } + } +} + +fn render_bench_records(cli: &Cli, records: &[BenchRecord]) -> Result<(), CliError> { + match effective_output(cli) { + OutputFormat::Json => print_json(&records), + OutputFormat::Toon => print_structured(&records, RenderMode::Toon), + OutputFormat::Jsonl => render_jsonl(records), + OutputFormat::Sum | OutputFormat::Text => { + for record in records { + let path = record + .path + .as_ref() + .map_or(Cow::Borrowed(""), |path| { + Cow::Owned(path.display().to_string()) + }); + println!( + "{}\t{:.3} ms\t{:.2} MiB/s\t{}", + record.algorithm, record.elapsed_ms, record.throughput_mib_s, path + ); + } + Ok(()) + } + } +} + +fn effective_output(cli: &Cli) -> OutputFormat { + match cli.common.render_mode() { + RenderMode::Json => OutputFormat::Json, + RenderMode::Toon => OutputFormat::Toon, + RenderMode::Text => cli.output, + } +} + +fn render_jsonl(records: &[T]) -> Result<(), CliError> +where + T: Serialize, +{ + let mut stdout = io::stdout().lock(); + for record in records { + serde_json::to_writer(&mut stdout, record) + .map_err(|error| CliError::runtime(format!("failed to render jsonl: {error}")))?; + stdout + .write_all(b"\n") + .map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))?; + } + Ok(()) +} + +fn write_manifest(path: &Path, records: &[HashRecord]) -> Result<(), CliError> { + let mut file = File::create(path).map_err(|error| { + CliError::runtime(format!( + "failed to create manifest {}: {error}", + path.display() + )) + })?; + for record in records { + serde_json::to_writer(&mut file, record) + .map_err(|error| CliError::runtime(format!("failed to render manifest: {error}")))?; + file.write_all(b"\n") + .map_err(|error| CliError::runtime(format!("failed to write manifest: {error}")))?; + } + Ok(()) +} + +fn read_manifest_entries(path: &Path, cli: &Cli) -> Result, CliError> { + let file = File::open(path).map_err(|error| { + CliError::runtime(format!( + "failed to open manifest {}: {error}", + path.display() + )) + })?; + let mut entries = Vec::new(); + for (index, line) in io::BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + CliError::runtime(format!( + "failed to read manifest line {}: {error}", + index + 1 + )) + })?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + entries.push(parse_manifest_line(trimmed, index + 1, cli)?); + } + Ok(entries) +} + +fn parse_manifest_line( + line: &str, + line_number: usize, + cli: &Cli, +) -> Result { + if let Ok(value) = serde_json::from_str::(line) { + return Ok(value); + } + let mut pieces = line.split_whitespace(); + let Some(digest) = pieces.next() else { + return Err(CliError::usage(format!( + "manifest line {line_number} is empty" + ))); + }; + let path = pieces.collect::>().join(" "); + if path.is_empty() { + return Err(CliError::usage(format!( + "manifest line {line_number} must contain a path" + ))); + } + let algorithm = match &cli.algorithms { + AlgorithmSelection::Explicit(algorithms) if algorithms.len() == 1 => { + algorithms[0].info().name.to_string() + } + AlgorithmSelection::Default => Algorithm::Sha256.info().name.to_string(), + _ => { + return Err(CliError::usage(format!( + "manifest line {line_number} uses checksum format; pass exactly one --algorithm" + ))); + } + }; + Ok(ManifestJsonRecord { + path: PathBuf::from(path.trim_start_matches('*')), + algorithm, + digest: digest.to_string(), + }) +} + +fn normalize_expected_digest(digest: &str, uppercase: bool) -> String { + if uppercase { + digest.to_ascii_uppercase() + } else { + digest.to_ascii_lowercase() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn parses_algorithm_aliases() { + assert_eq!(parse_algorithm("SHA-256").ok(), Some(Algorithm::Sha256)); + assert_eq!( + parse_algorithm("xxhash3-128").ok(), + Some(Algorithm::Xxh3_128) + ); + assert_eq!( + parse_algorithm("k12-512").ok(), + Some(Algorithm::KangarooTwelve512) + ); + } + + #[test] + fn every_algorithm_has_expected_hex_length() { + for (algorithm, info) in ALGORITHMS { + let digest = hash_algorithm(*algorithm, b"abc", false); + assert_eq!(digest.len(), info.bytes * 2, "{}", info.name); + } + } + + #[test] + fn algorithm_info_table_matches_discriminants() { + for (index, (algorithm, info)) in ALGORITHMS.iter().enumerate() { + assert_eq!(*algorithm as usize, index, "{}", info.name); + assert_eq!(algorithm.info().name, info.name); + } + } + + #[test] + fn fixed_width_integer_hex_encoding_matches_format_contract() { + assert_eq!(encode_hex(&[0x00, 0x0f, 0xa5, 0xff], false), "000fa5ff"); + assert_eq!(encode_hex(&[0x00, 0x0f, 0xa5, 0xff], true), "000FA5FF"); + assert_eq!(encode_fixed_u64(0xcbf4_3926, 8, false), "cbf43926"); + assert_eq!(encode_fixed_u64(0xcbf4_3926, 8, true), "CBF43926"); + assert_eq!( + encode_fixed_u64(0x995d_c9bb_df19_39fa, 16, false), + "995dc9bbdf1939fa" + ); + assert_eq!( + encode_fixed_u128(0x99aa_06d3_0147_98d8_6001_c324_468d_497f, false), + "99aa06d3014798d86001c324468d497f" + ); + } + + #[test] + fn read_buffer_is_capped_by_known_file_size() { + assert_eq!(read_buffer_size(DEFAULT_CHUNK_SIZE, 0), 1); + assert_eq!(read_buffer_size(DEFAULT_CHUNK_SIZE, 3), 3); + assert_eq!(read_buffer_size(8, 8192), 4096); + assert_eq!( + read_buffer_size(DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_SIZE * 2), + DEFAULT_CHUNK_SIZE + ); + } + + #[test] + fn auto_io_streams_large_common_hashes_without_mmap() { + let file = tempfile::NamedTempFile::new() + .unwrap_or_else(|error| panic!("tempfile failed: {error}")); + file.as_file() + .set_len(u64::try_from(MMAP_THRESHOLD_BYTES + 1).expect("threshold fits u64")) + .unwrap_or_else(|error| panic!("set_len failed: {error}")); + + assert!( + should_stream_algorithm_set(file.path(), IoMode::Auto, &[Algorithm::Sha256], true) + .unwrap_or_else(|error| panic!("stream decision failed: {error}")) + ); + assert!( + should_stream_algorithm_set( + file.path(), + IoMode::Auto, + &[ + Algorithm::Md5, + Algorithm::Sha1, + Algorithm::Sha256, + Algorithm::Sha512, + ], + true, + ) + .unwrap_or_else(|error| panic!("stream decision failed: {error}")) + ); + assert!( + !should_stream_algorithm_set(file.path(), IoMode::Auto, &[Algorithm::Blake3_256], true) + .unwrap_or_else(|error| panic!("stream decision failed: {error}")) + ); + assert!( + !should_stream_algorithm_set(file.path(), IoMode::Mmap, &[Algorithm::Sha256], true) + .unwrap_or_else(|error| panic!("stream decision failed: {error}")) + ); + } + + #[test] + fn streaming_hashes_match_in_memory_hashes_for_common_algorithms() { + let algorithms = [ + Algorithm::Crc32, + Algorithm::Xxh64, + Algorithm::Xxh3_128, + Algorithm::Md5, + Algorithm::Sha1, + Algorithm::Sha256, + Algorithm::Sha512, + Algorithm::Blake3_256, + Algorithm::KangarooTwelve256, + Algorithm::ParallelHash256_528, + ]; + let mut file = tempfile::NamedTempFile::new() + .unwrap_or_else(|error| panic!("tempfile failed: {error}")); + file.write_all(b"abc") + .unwrap_or_else(|error| panic!("fixture write failed: {error}")); + for algorithm in algorithms { + let (records, size) = hash_streaming_algorithm_set(&[algorithm], file.path(), 2, false) + .unwrap_or_else(|error| panic!("streaming hash failed: {error}")); + assert_eq!(size, 3); + let [(actual_algorithm, actual)] = records.as_slice() else { + panic!("expected exactly one streaming hash record"); + }; + assert_eq!(*actual_algorithm, algorithm); + assert_eq!(actual, &hash_algorithm(algorithm, b"abc", false)); + } + } + + #[test] + fn streaming_hash_set_matches_in_memory_hash_set_for_mixed_algorithms() { + let algorithms = [ + Algorithm::Sha256, + Algorithm::Md5, + Algorithm::Xxh3_128, + Algorithm::Blake3_256, + Algorithm::Blake3_512, + Algorithm::KangarooTwelve256, + Algorithm::KangarooTwelve512, + Algorithm::Blake2sp, + ]; + let data = b"abc123\x00\xff\r\n".repeat(3000); + let mut file = tempfile::NamedTempFile::new() + .unwrap_or_else(|error| panic!("tempfile failed: {error}")); + file.write_all(&data) + .unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let (actual, size) = hash_streaming_algorithm_set(&algorithms, file.path(), 17, false) + .unwrap_or_else(|error| panic!("streaming hash set failed: {error}")); + let expected = hash_algorithm_set(&algorithms, &data, false, false, true); + + assert_eq!( + size, + u64::try_from(data.len()).expect("test fixture length fits") + ); + assert_eq!(actual, expected); + } + + #[test] + fn covers_core_known_answer_vectors() { + assert_eq!( + hash_algorithm(Algorithm::Crc32, b"123456789", false), + "cbf43926" + ); + assert_eq!( + hash_algorithm(Algorithm::Crc64Xz, b"123456789", false), + "995dc9bbdf1939fa" + ); + assert_eq!( + hash_algorithm(Algorithm::Md5, b"abc", false), + "900150983cd24fb0d6963f7d28e17f72" + ); + assert_eq!( + hash_algorithm(Algorithm::Sha1, b"abc", false), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!( + hash_algorithm(Algorithm::Sha256, b"abc", false), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn covers_xxhash_official_empty_input_vectors() { + assert_eq!(hash_algorithm(Algorithm::Xxh32, b"", false), "02cc5d05"); + assert_eq!( + hash_algorithm(Algorithm::Xxh64, b"", false), + "ef46db3751d8e999" + ); + assert_eq!( + hash_algorithm(Algorithm::Xxh3_64, b"", false), + "2d06800538d394c2" + ); + assert_eq!( + hash_algorithm(Algorithm::Xxh3_128, b"", false), + "99aa06d3014798d86001c324468d497f" + ); + } + + #[test] + fn covers_sha3_official_abc_vectors() { + assert_eq!( + hash_algorithm(Algorithm::Sha3_224, b"abc", false), + "e642824c3f8cf24ad09234ee7d3c766fc9a3a5168d0c94ad73b46fdf" + ); + assert_eq!( + hash_algorithm(Algorithm::Sha3_256, b"abc", false), + "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532" + ); + assert_eq!( + hash_algorithm(Algorithm::Sha3_384, b"abc", false), + "ec01498288516fc926459f58e2c6ad8df9b473cb0fc08c2596da7cf0e49be4b298d88cea927ac7f539f1edf228376d25" + ); + assert_eq!( + hash_algorithm(Algorithm::Sha3_512, b"abc", false), + "b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0" + ); + } + + #[test] + fn covers_blake3_official_abc_vectors() { + assert_eq!( + hash_algorithm(Algorithm::Blake3_256, b"abc", false), + "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85" + ); + assert_eq!( + hash_algorithm(Algorithm::Blake3_512, b"abc", false), + "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d851fb250ae7393f5d02813b65d521a0d492d9ba09cf7ce7f4cffd900f23374bf0b" + ); + } + + #[test] + fn shared_xof_jobs_match_individual_hashes() { + let algorithms = vec![ + Algorithm::Blake3_256, + Algorithm::Blake3_512, + Algorithm::KangarooTwelve264, + Algorithm::KangarooTwelve256, + Algorithm::KangarooTwelve512, + ]; + let records = hash_algorithm_set(&algorithms, b"abc", false, true, true); + for (algorithm, digest) in records { + assert_eq!(digest, hash_algorithm(algorithm, b"abc", false)); + } + assert_eq!( + hash_algorithm_set(&[Algorithm::Sha256], b"abc", true, false, true)[0].1, + "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD" + ); + } + + #[test] + fn build_hash_jobs_shares_xxh3_when_both_widths_requested() { + assert_eq!( + build_hash_jobs(&[Algorithm::Xxh3_64, Algorithm::Xxh3_128]), + vec![HashJob::Xxh3] + ); + assert_eq!( + build_hash_jobs(&[Algorithm::Xxh3_64]), + vec![HashJob::Single(Algorithm::Xxh3_64)] + ); + } + + #[test] + fn shared_xxh3_job_matches_individual_hashes() { + let algorithms = [Algorithm::Xxh3_64, Algorithm::Xxh3_128]; + let data = b"abc123\x00\xff\r\n".repeat(16 * 1024); + let records = hash_algorithm_set(&algorithms, &data, false, false, true); + + for (algorithm, digest) in records { + assert_eq!(digest, hash_algorithm(algorithm, &data, false)); + } + } + + #[test] + fn covers_streebog_regression_vectors() { + assert_eq!( + hash_algorithm(Algorithm::Streebog256, b"abc", false), + "4e2919cf137ed41ec4fb6270c61826cc4fffb660341e0af3688cd0626d23b481" + ); + assert_eq!( + hash_algorithm(Algorithm::Streebog512, b"abc", false), + "28156e28317da7c98f4fe2bed6b542d0dab85bb224445fcedaf75d46e26d7eb8d5997f3e0915dd6b7f0aab08d9c8beb0d8c64bae2ab8b3c8c6bc53b3bf0db728" + ); + } + + #[test] + fn covers_k12_and_parallelhash_regression_vectors_and_lengths() { + let vectors = [ + ( + Algorithm::KangarooTwelve264, + "ab174f328c55a5510b0b209791bf8b60e801a7cfc2aa42042dcb8f547fbe3a7d3f", + ), + ( + Algorithm::KangarooTwelve256, + "ab174f328c55a5510b0b209791bf8b60e801a7cfc2aa42042dcb8f547fbe3a7d", + ), + ( + Algorithm::KangarooTwelve512, + "ab174f328c55a5510b0b209791bf8b60e801a7cfc2aa42042dcb8f547fbe3a7d3f5b54d116a705d36aac2a7eac7a19e3f0f058cb3c238ac7f034178ae34f212e", + ), + ( + Algorithm::ParallelHash128_264, + "493a6004492b1b7b14be32fe0bbb4f780183cb8311c17f9ac2ab0454d8940b1f33", + ), + ( + Algorithm::ParallelHash256_528, + "44d4ea7d0c3dc171711d98bfce531a1bec23c160c6d6ed9379a1b671f025e66083264e7bcd825d497eda24b88220897e2dbfd86b24c915f03a91addd75f13275511d", + ), + ]; + for (algorithm, expected) in vectors { + let digest = hash_algorithm(algorithm, b"abc", false); + assert_eq!(digest, expected, "{}", algorithm.info().name); + assert_eq!( + digest.len(), + algorithm.info().bytes * 2, + "{}", + algorithm.info().name + ); + } + } + + #[test] + fn parses_size_suffixes() { + assert_eq!(parse_size("--chunk-size", "4K").ok(), Some(4096)); + assert_eq!(parse_size("--chunk-size", "2M").ok(), Some(2 * 1024 * 1024)); + } + + #[test] + fn bench_cli_parses_repeat_warmup_and_mode() { + let (outcome, cli) = parse_cli_from([ + "mhash", + "bench", + "--repeat", + "3", + "--warmup", + "1", + "--bench-mode", + "end-to-end", + "fixture.bin", + ]) + .unwrap_or_else(|error| panic!("bench CLI parse failed: {error}")); + + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.command, Command::Bench); + assert_eq!(cli.bench_repeat, 3); + assert_eq!(cli.bench_warmup, 1); + assert_eq!(cli.bench_mode, BenchMode::EndToEnd); + } + + #[test] + fn bench_cli_uses_cpu_single_run_defaults() { + let (_, cli) = parse_cli_from(["mhash", "bench"]) + .unwrap_or_else(|error| panic!("bench CLI parse failed: {error}")); + + assert_eq!(cli.bench_repeat, 1); + assert_eq!(cli.bench_warmup, 0); + assert_eq!(cli.bench_mode, BenchMode::Cpu); + } + + #[test] + fn bench_cli_rejects_invalid_repeat_warmup_and_mode() { + assert!(parse_cli_from(["mhash", "bench", "--repeat", "0"]).is_err()); + assert!(parse_cli_from(["mhash", "bench", "--warmup", "nope"]).is_err()); + assert!(parse_cli_from(["mhash", "bench", "--bench-mode", "wall"]).is_err()); + } + + #[test] + fn run_hash_verify_list_and_bench_cover_public_dispatch_paths() { + let directory = tempdir().expect("tempdir"); + let file = directory.path().join("sample.txt"); + let manifest = directory.path().join("checksums.jsonl"); + fs::write(&file, b"abc").expect("fixture"); + + let (_, hash_cli) = parse_cli_from([ + "mhash", + "--algorithm", + "sha256,xxh3-64", + "--format", + "json", + "--manifest", + manifest.to_str().expect("utf8 manifest"), + file.to_str().expect("utf8 path"), + ]) + .expect("hash cli"); + assert_eq!(run_hash(&hash_cli).expect("hash"), ExitCode::Success); + assert!(manifest.exists()); + + let (_, verify_cli) = parse_cli_from([ + "mhash", + "verify", + "--algorithm", + "sha256,xxh3-64", + "--jsonl", + manifest.to_str().expect("utf8 manifest"), + ]) + .expect("verify cli"); + assert_eq!(run_verify(&verify_cli).expect("verify"), ExitCode::Success); + + let (_, list_cli) = + parse_cli_from(["mhash", "algorithms", "--format", "jsonl"]).expect("list cli"); + assert_eq!( + run_list_algorithms(&list_cli).expect("list algorithms"), + ExitCode::Success + ); + + let (_, bench_cli) = parse_cli_from([ + "mhash", + "bench", + "--algorithm", + "sha256", + "--format", + "toon", + "--repeat", + "1", + file.to_str().expect("utf8 path"), + ]) + .expect("bench cli"); + assert_eq!(run_bench(&bench_cli).expect("bench"), ExitCode::Success); + } + + #[test] + fn input_collection_and_manifest_parsing_cover_error_paths() { + let directory = tempdir().expect("tempdir"); + let child = directory.path().join("child.txt"); + fs::write(&child, b"abc").expect("child fixture"); + + let (_, directory_cli) = + parse_cli_from(["mhash", directory.path().to_str().expect("utf8 dir")]) + .expect("directory cli"); + assert!(collect_input_paths(&directory_cli).is_err()); + + let (_, recursive_cli) = parse_cli_from([ + "mhash", + "--recursive", + "--ignore", + "*child.txt", + directory.path().to_str().expect("utf8 dir"), + ]) + .expect("recursive cli"); + assert!( + collect_input_paths(&recursive_cli) + .expect("recursive paths") + .is_empty() + ); + assert!(wildcard_match("a/b/child.txt", "*child.txt")); + assert!(is_ignored(&child, &["*child.txt".to_owned()])); + + let (_, default_cli) = parse_cli_from(["mhash"]).expect("default cli"); + assert!(parse_manifest_line("", 1, &default_cli).is_err()); + assert!(parse_manifest_line("abc", 2, &default_cli).is_err()); + + let (_, all_cli) = parse_cli_from(["mhash", "--all"]).expect("all cli"); + assert!(parse_manifest_line("abc file.txt", 3, &all_cli).is_err()); + assert_eq!(normalize_expected_digest("AbC", true), "ABC"); + assert_eq!(normalize_expected_digest("AbC", false), "abc"); + } + + #[test] + fn non_streaming_hash_verify_failures_and_parallel_bench_paths_are_covered() { + let directory = tempdir().expect("tempdir"); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").expect("fixture"); + + let (_, blake_cli) = parse_cli_from([ + "mhash", + "--algorithm", + "blake3-256", + "--io", + "read", + file.to_str().expect("utf8 path"), + ]) + .expect("blake cli"); + let records = hash_path(&blake_cli, &file, &[Algorithm::Blake3_256], true, true) + .expect("blake hash path"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].algorithm, "blake3-256"); + + let (_, mmap_cli) = parse_cli_from([ + "mhash", + "--algorithm", + "sha256", + "--io", + "mmap", + file.to_str().expect("utf8 path"), + ]) + .expect("mmap cli"); + assert_eq!( + read_file_input(&file, mmap_cli.io_mode, mmap_cli.chunk_size) + .expect("mmap input") + .as_bytes(), + b"abc" + ); + + let missing = directory.path().join("missing.txt"); + let entries = vec![ + ManifestJsonRecord { + path: file.clone(), + algorithm: "nope".to_owned(), + digest: "abc".to_owned(), + }, + ManifestJsonRecord { + path: missing, + algorithm: "sha256".to_owned(), + digest: "abc".to_owned(), + }, + ]; + let verify = verify_entries(&mmap_cli, &entries); + assert_eq!(verify.len(), 2); + assert!(verify.iter().all(|record| !record.ok)); + assert!(verify.iter().all(|record| record.error.is_some())); + + let (_, bench_cli) = parse_cli_from([ + "mhash", + "bench", + "--algorithm", + "sha256,xxh3-64", + "--threads", + "2", + "--repeat", + "1", + "--bench-mode", + "end-to-end", + "--io", + "read", + file.to_str().expect("utf8 path"), + ]) + .expect("bench cli"); + let benches = bench_paths( + &bench_cli, + std::slice::from_ref(&file), + &[Algorithm::Sha256, Algorithm::Xxh3_64], + ) + .expect("bench paths"); + assert_eq!(benches.len(), 2); + assert!( + benches + .iter() + .all(|record| record.bench_mode == BenchMode::EndToEnd) + ); + } + + #[test] + fn synthetic_bench_and_thread_pool_error_paths_are_covered() { + let (_, bench_cli) = parse_cli_from([ + "mhash", + "bench", + "--algorithm", + "xxh3-64", + "--repeat", + "1", + "--threads", + "1", + ]) + .expect("bench cli"); + let records = bench_synthetic(&[Algorithm::Xxh3_64], &bench_cli); + assert_eq!(records.len(), 1); + assert_eq!(records[0].bytes, 16 * 1024 * 1024); + + with_thread_pool(Some(1), || Ok::<_, CliError>(())).expect("single-thread pool"); + } + + #[test] + fn parsers_and_renderers_cover_text_sum_and_help_paths() { + assert_eq!( + parse_cli_from(["mhash", "--help"]).expect("help").0, + ParseOutcome::Help + ); + assert_eq!( + parse_cli_from(["mhash", "--version"]).expect("version").0, + ParseOutcome::Version + ); + assert!(is_subcommand(&OsString::from("verify"), "verify")); + assert!(is_subcommand(&OsString::from("ALGORITHMS"), "algorithms")); + assert!(is_subcommand(&OsString::from("bench"), "bench")); + + let (_, mut normalized_cli) = parse_cli_from(["mhash"]).expect("base cli"); + normalized_cli.paths = vec![PathBuf::from("file.txt"), PathBuf::from("--json")]; + normalize_trailing_flag_paths(&mut normalized_cli).expect("normalized"); + assert_eq!(normalized_cli.paths, vec![PathBuf::from("file.txt")]); + assert_eq!(normalized_cli.output, OutputFormat::Json); + + let (_, text_cli) = parse_cli_from(["mhash"]).expect("text cli"); + let (_, sum_cli) = parse_cli_from(["mhash", "--format", "sum"]).expect("sum cli"); + let hash = HashRecord { + path: PathBuf::from("sample.txt"), + size: 3, + algorithm: "sha256", + digest: "abc".to_owned(), + ok: true, + error: None, + }; + render_hash_records(&text_cli, std::slice::from_ref(&hash)).expect("text hash render"); + render_hash_records(&sum_cli, std::slice::from_ref(&hash)).expect("sum hash render"); + + let verify = VerifyRecord { + path: PathBuf::from("sample.txt"), + algorithm: "sha256".to_owned(), + expected: "abc".to_owned(), + actual: Some("def".to_owned()), + ok: false, + error: None, + }; + render_verify_records(&text_cli, &[verify]).expect("verify text render"); + + let bench = BenchRecord { + path: None, + algorithm: "sha256", + repeat: 1, + warmup: 0, + bench_mode: BenchMode::Cpu, + bytes: 3, + elapsed_ms: 1.0, + throughput_mib_s: 2.0, + }; + render_bench_records(&text_cli, &[bench]).expect("bench text render"); + } + + #[test] + fn bench_algorithm_timing_is_sequential_without_explicit_threads() { + assert!(!bench_uses_parallel_algorithms(None)); + assert!(!bench_uses_parallel_algorithms(Some(1))); + assert!(bench_uses_parallel_algorithms(Some(2))); + } + + #[test] + fn large_blake3_jobs_allow_internal_rayon_even_with_parallel_jobs() { + assert!(!allow_blake3_internal_parallelism( + MMAP_THRESHOLD_BYTES - 1, + true, + )); + assert!(allow_blake3_internal_parallelism( + MMAP_THRESHOLD_BYTES, + true, + )); + assert!(allow_blake3_internal_parallelism( + MMAP_THRESHOLD_BYTES, + false, + )); + } + + #[test] + fn blake2sp_streaming_state_uses_incremental_state() { + let state = StreamingHashState::new_single(Algorithm::Blake2sp, 0); + match state { + StreamingHashState::Blake2sp(state) => { + fn assert_incremental_state(_: &blake2s_simd::blake2sp::State) {} + assert_incremental_state(&state); + } + _ => panic!("expected blake2sp streaming state"), + } + } + + #[test] + fn parses_json_manifest_line() { + let cli = Cli { + common: CommonArgs::default(), + command: Command::Verify, + algorithms: AlgorithmSelection::Default, + recursive: false, + ignores: Vec::new(), + manifest: None, + output: OutputFormat::Text, + threads: None, + chunk_size: DEFAULT_CHUNK_SIZE, + io_mode: IoMode::Read, + bench_repeat: 1, + bench_warmup: 0, + bench_mode: BenchMode::Cpu, + uppercase: false, + paths: Vec::new(), + }; + let parsed = parse_manifest_line( + "{\"path\":\"README.md\",\"algorithm\":\"sha256\",\"digest\":\"abc\"}", + 1, + &cli, + ) + .ok(); + assert_eq!( + parsed.map(|item| item.algorithm), + Some("sha256".to_string()) + ); + } +} diff --git a/crates/mhash/src/main.rs b/crates/mhash/src/main.rs new file mode 100644 index 0000000..e8e28ab --- /dev/null +++ b/crates/mhash/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `mhash`. + +fn main() { + std::process::exit(mercury_mhash::main_entry()); +} diff --git a/crates/mhash/tests/mhash_cli.rs b/crates/mhash/tests/mhash_cli.rs new file mode 100644 index 0000000..a9969d5 --- /dev/null +++ b/crates/mhash/tests/mhash_cli.rs @@ -0,0 +1,364 @@ +//! CLI integration tests for the `mhash` binary. + +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn mhash_command() -> assert_cmd::Command { + Command::cargo_bin("mhash").unwrap_or_else(|error| { + panic!("failed to locate mhash binary: {error}"); + }) +} + +#[test] +fn list_algorithms_includes_openhashtab_matrix() { + let mut command = mhash_command(); + command.arg("list-algorithms").arg("--json"); + command + .assert() + .success() + .stdout(predicate::str::contains("parallelhash256-528")) + .stdout(predicate::str::contains("kangarootwelve-264")) + .stdout(predicate::str::contains("streebog-512")); +} + +#[test] +fn hashes_file_as_json() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + command + .arg("--algorithm") + .arg("sha256,blake3-256") + .arg("--json") + .arg(&file); + command + .assert() + .success() + .stdout(predicate::str::contains( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + )) + .stdout(predicate::str::contains("blake3-256")); +} + +#[test] +fn hashes_file_as_jsonl() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + command + .arg("--algorithm") + .arg("sha256,md5") + .arg("--format") + .arg("jsonl") + .arg(&file); + command + .assert() + .success() + .stdout(predicate::str::contains("\"algorithm\":\"md5\"")) + .stdout(predicate::str::contains("\"algorithm\":\"sha256\"")); +} + +#[test] +fn recursive_ignore_and_sum_format_hash_only_included_files() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let keep = directory.path().join("keep.txt"); + let nested = directory.path().join("nested"); + let ignored = nested.join("ignored.skip"); + fs::create_dir(&nested).unwrap_or_else(|error| panic!("fixture dir failed: {error}")); + fs::write(&keep, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + fs::write(&ignored, b"ignored").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + command + .arg("--algorithm") + .arg("sha256") + .arg("--recursive") + .arg("--ignore") + .arg("*.skip") + .arg("--format") + .arg("sum") + .arg(directory.path()); + command + .assert() + .success() + .stdout(predicate::str::contains( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + )) + .stdout(predicate::str::contains("keep.txt")) + .stdout(predicate::str::contains("ignored.skip").not()); +} + +#[test] +fn uppercase_sum_output_uses_uppercase_hex() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + command + .arg("--algorithm") + .arg("sha256") + .arg("--uppercase") + .arg("--format") + .arg("sum") + .arg(&file); + command.assert().success().stdout(predicate::str::contains( + "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD", + )); +} + +#[test] +fn stdin_lines_input_hashes_existing_paths() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + command + .arg("--input-format") + .arg("lines") + .arg("--algorithm") + .arg("md5") + .arg("--format") + .arg("jsonl") + .write_stdin(format!("{}\n", file.display())); + command.assert().success().stdout(predicate::str::contains( + "\"digest\":\"900150983cd24fb0d6963f7d28e17f72\"", + )); +} + +#[test] +fn stdin_jsonl_input_hashes_existing_paths() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + let input = format!( + "{{\"path\":\"{}\"}}\n", + file.display().to_string().replace('\\', "\\\\") + ); + + let mut command = mhash_command(); + command + .arg("--input-format") + .arg("jsonl") + .arg("--algorithm") + .arg("sha1") + .arg("--format") + .arg("jsonl") + .write_stdin(input); + command.assert().success().stdout(predicate::str::contains( + "\"digest\":\"a9993e364706816aba3e25717850c26c9cd0d89d\"", + )); +} + +#[test] +fn help_and_json_hash_output_do_not_expose_backend_controls() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + mhash_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--backend").not()); + + let mut command = mhash_command(); + let output = command + .arg("--algorithm") + .arg("sha256") + .arg("--io") + .arg("read") + .arg("--threads") + .arg("2") + .arg("--json") + .arg(&file) + .assert() + .success() + .get_output() + .stdout + .clone(); + let records: Vec = serde_json::from_slice(&output) + .unwrap_or_else(|error| panic!("json parse failed: {error}")); + + assert_eq!(records.len(), 1); + assert!(records[0].get("requested_backend").is_none()); + assert!(records[0].get("backend").is_none()); + assert!(records[0].get("io").is_none()); +} + +#[test] +fn backend_option_is_not_public_cli() { + let mut command = mhash_command(); + command + .arg("--backend") + .arg("simd") + .arg("README.md") + .assert() + .code(2) + .stderr(predicate::str::contains("unsupported argument")); +} + +#[test] +fn bench_accepts_threads_and_io_options_as_json_smoke() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + let output = command + .arg("bench") + .arg("--algorithm") + .arg("sha256") + .arg("--threads") + .arg("2") + .arg("--io") + .arg("read") + .arg("--format") + .arg("json") + .arg(&file) + .assert() + .success() + .get_output() + .stdout + .clone(); + let records: Vec = serde_json::from_slice(&output) + .unwrap_or_else(|error| panic!("json parse failed: {error}")); + + assert_eq!(records.len(), 1); + assert_eq!(records[0]["algorithm"], "sha256"); + assert_eq!(records[0]["bytes"], 3); + assert!(records[0]["elapsed_ms"].as_f64().is_some()); +} + +#[test] +fn bench_end_to_end_reports_repeat_warmup_and_mode() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abcdef").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + let output = command + .arg("bench") + .arg("--algorithm") + .arg("sha256") + .arg("--repeat") + .arg("2") + .arg("--warmup") + .arg("1") + .arg("--bench-mode") + .arg("e2e") + .arg("--io") + .arg("read") + .arg("--format") + .arg("json") + .arg(&file) + .assert() + .success() + .get_output() + .stdout + .clone(); + let records: Vec = serde_json::from_slice(&output) + .unwrap_or_else(|error| panic!("json parse failed: {error}")); + + assert_eq!(records.len(), 1); + assert_eq!(records[0]["algorithm"], "sha256"); + assert_eq!(records[0]["bytes"], 6); + assert_eq!(records[0]["repeat"], 2); + assert_eq!(records[0]["warmup"], 1); + assert_eq!(records[0]["bench_mode"], "end-to-end"); + assert!(records[0]["elapsed_ms"].as_f64().is_some()); + assert!(records[0]["throughput_mib_s"].as_f64().is_some()); +} + +#[test] +fn verifies_generated_manifest() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + let manifest = directory.path().join("checksums.jsonl"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut hash_command = mhash_command(); + hash_command + .arg("--algorithm") + .arg("sha256") + .arg("--manifest") + .arg(&manifest) + .arg(&file); + hash_command.assert().success(); + + let mut verify_command = mhash_command(); + verify_command.arg("verify").arg(&manifest).arg("--json"); + verify_command + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")); +} + +#[test] +fn verifies_multi_algorithm_manifest_for_one_path() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + let manifest = directory.path().join("checksums.jsonl"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut hash_command = mhash_command(); + hash_command + .arg("--algorithm") + .arg("sha256,md5,xxh3-128") + .arg("--manifest") + .arg(&manifest) + .arg(&file); + hash_command.assert().success(); + + let mut verify_command = mhash_command(); + let output = verify_command + .arg("verify") + .arg(&manifest) + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + let records: Vec = serde_json::from_slice(&output) + .unwrap_or_else(|error| panic!("json parse failed: {error}")); + + assert_eq!(records.len(), 3); + assert!(records.iter().all(|record| record["ok"] == true)); + assert_eq!(records[0]["algorithm"], "md5"); + assert_eq!(records[1]["algorithm"], "sha256"); + assert_eq!(records[2]["algorithm"], "xxh3-128"); +} + +#[test] +fn mercury_output_json_is_honored() { + let directory = tempdir().unwrap_or_else(|error| panic!("tempdir failed: {error}")); + let file = directory.path().join("sample.txt"); + fs::write(&file, b"abc").unwrap_or_else(|error| panic!("fixture write failed: {error}")); + + let mut command = mhash_command(); + command.env("MERCURY_OUTPUT", "json").arg(&file); + command + .assert() + .success() + .stdout(predicate::str::starts_with("[{")); +} + +#[test] +fn invalid_algorithm_exits_usage_error() { + let mut command = mhash_command(); + command.arg("--algorithm").arg("sha999").arg("README.md"); + command + .assert() + .code(2) + .stderr(predicate::str::contains("unsupported algorithm")); +} diff --git a/crates/msudo/Cargo.toml b/crates/msudo/Cargo.toml new file mode 100644 index 0000000..4b3d541 --- /dev/null +++ b/crates/msudo/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "msudo" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Launch Windows commands as admin, SYSTEM, or TrustedInstaller with Mercury-friendly output." + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true +windowsupport = { path = "../windowsupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/msudo/src/lib.rs b/crates/msudo/src/lib.rs new file mode 100644 index 0000000..24c232f --- /dev/null +++ b/crates/msudo/src/lib.rs @@ -0,0 +1,2313 @@ +//! The `msudo` command launches Windows processes with elevated identities. + +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as _; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, print_text, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::{Deserialize, Serialize}; +use windowsupport::{ + LaunchIdentity, LaunchRequest, LaunchResult, PrivilegeMode, ProcessPriority, ShowWindowMode, + TokenIntegrity, TokenStatus, +}; + +const HELP: &str = "\ +Launch Windows commands as admin, SYSTEM, or TrustedInstaller with Mercury-friendly output. + +Windows only. This tool is intentionally high risk. `--user system` and `--user trustedinstaller` +require `--dangerous`. + +Usage: + msudo [OPTIONS] [--] + msudo run [OPTIONS] [--] + msudo status [OPTIONS] + msudo [OPTIONS] --shell + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --user Target identity: current-process, current-user, admin, system, trustedinstaller + --dangerous Required for system or trustedinstaller launches + --shell Shell preset: cmd, powershell, pwsh, wsl, git-bash, mingw, msys2, cygwin, yori, tcc, nu + --integrity Integrity: untrusted, low, medium, medium-plus, high, system + --privileges Privilege policy: default, enable-all, disable-all + --priority Priority: idle, below-normal, normal, above-normal, high, realtime + --show-window Window mode: default, hidden, normal, minimized, maximized + --session Target session id for duplicated tokens + --current-directory Working directory for the launched process + --same-console Reuse the current console in the foreground and wait for the child + --new-window Force a new window for the launched process + --wait Wait for the launched process and return its exit code + -h, --help Show this help text + -V, --version Show the command version + +Examples: + msudo status --json | ConvertFrom-Json + msudo --user admin -- cmd /d /c whoami + msudo --user system --dangerous -- cmd /d /c whoami + msudo --same-console --user system --dangerous --shell powershell + msudo --shell pwsh +"; + +const STATUS_HELP: &str = "\ +Show the current token state, runas availability, and shell preset resolution. + +Usage: + msudo status [OPTIONS] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --quiet Suppress shell inventory in text output + --color Control ANSI color output: auto, never + -h, --help Show this help text + -V, --version Show the command version + +Examples: + msudo status --json | ConvertFrom-Json +"; + +const RUN_HELP: &str = "\ +Run a command or shell with the requested elevated identity. + +Usage: + msudo run [OPTIONS] [--] + msudo [OPTIONS] [--] + msudo [OPTIONS] --shell + +Options: + --format Structured output format: text, json, toon + --json Emit JSON launch results instead of compact text output + --toon Shortcut for --format toon + --quiet Suppress non-essential text output + --color Control ANSI color output: auto, never + --user Target identity: current-process, current-user, admin, system, trustedinstaller + --dangerous Required for system or trustedinstaller launches + --shell Shell preset: cmd, powershell, pwsh, wsl, git-bash, mingw, msys2, cygwin, yori, tcc, nu + --integrity Integrity: untrusted, low, medium, medium-plus, high, system + --privileges Privilege policy: default, enable-all, disable-all + --priority Priority: idle, below-normal, normal, above-normal, high, realtime + --show-window Window mode: default, hidden, normal, minimized, maximized + --session Target session id for duplicated tokens + --current-directory Working directory for the launched process + --same-console Reuse the current console in the foreground and wait for the child + --new-window Force a new window for the launched process + --wait Wait for the launched process and return its exit code + -h, --help Show this help text + -V, --version Show the command version + +Examples: + msudo --user admin -- cmd /d /c whoami + msudo --user system --dangerous -- cmd /d /c whoami + msudo --same-console --user system --dangerous --shell powershell + msudo --shell pwsh + +Interactive shells open in a new window by default; use --same-console to keep them in the +current terminal and wait in the foreground. +"; + +const RELAY_SUBCOMMAND: &str = "__relay"; +static EXIT_CODE_PATH_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: CommandMode, +} + +#[derive(Debug, Clone)] +enum CommandMode { + Help(&'static str), + Status, + Run(RunCli), + Relay(RelayCli), +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +struct RunCli { + user: LaunchIdentity, + dangerous: bool, + shell: Option, + integrity: Option, + privileges: PrivilegeMode, + priority: ProcessPriority, + show_window: ShowWindowMode, + session: Option, + current_directory: Option, + same_console: bool, + new_window: bool, + wait: bool, + command: Vec, +} + +#[derive(Debug, Clone)] +struct RelayCli { + run: RunCli, + exit_code_path: PathBuf, + stdout_path: Option, + stderr_path: Option, + console_pid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RelayExitStatus { + ok: bool, + exit_code: Option, + error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +enum ShellPreset { + #[serde(rename = "cmd")] + Cmd, + #[serde(rename = "powershell")] + PowerShell, + #[serde(rename = "pwsh")] + Pwsh, + #[serde(rename = "wsl")] + Wsl, + #[serde(rename = "git-bash")] + GitBash, + #[serde(rename = "mingw")] + Mingw, + #[serde(rename = "msys2")] + Msys2, + #[serde(rename = "cygwin")] + Cygwin, + #[serde(rename = "yori")] + Yori, + #[serde(rename = "tcc")] + Tcc, + #[serde(rename = "nu")] + Nu, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ShellResolutionSource { + KnownLocation, + Path, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ShellStatus { + preset: ShellPreset, + available: bool, + executable: Option, + resolution_source: Option, + error: Option, +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct StatusReport { + ok: bool, + host: String, + supports_runas: bool, + is_elevated: bool, + is_admin_member: bool, + integrity: TokenIntegrity, + current_user: Option, + session_id: Option, + active_session_id: Option, + can_admin: bool, + can_current_user: bool, + can_system: bool, + can_trustedinstaller: bool, + trustedinstaller_installed: bool, + trustedinstaller_running: bool, + shells: Vec, +} + +#[derive(Debug)] +struct RelayLaunch { + arguments: Vec, + exit_path: PathBuf, + stdout_path: Option, + stderr_path: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, cli)) => { + if let CommandMode::Help(help) = cli.command { + print!("{help}"); + } else { + print!("{HELP}"); + } + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("msudo {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn run(cli: &Cli) -> Result { + let provider = WindowsSudoProvider; + run_with_provider(cli, &provider) +} + +fn run_with_provider(cli: &Cli, provider: &dyn SudoProvider) -> Result { + match &cli.command { + CommandMode::Help(help) => { + print!("{help}"); + Ok(ExitCode::Success.as_i32()) + } + CommandMode::Status => run_status(cli.common, provider), + CommandMode::Run(run_cli) => run_launch(cli.common.render_mode(), run_cli, provider), + CommandMode::Relay(relay) => run_relay(relay, provider), + } +} + +fn run_status(common: CommonArgs, provider: &dyn SudoProvider) -> Result { + let status = provider.status()?; + let report = build_status_report(status); + match common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print_text(render_status_text(&report, common.quiet))?, + } + Ok(ExitCode::Success.as_i32()) +} + +fn run_launch( + render_mode: RenderMode, + run_cli: &RunCli, + provider: &dyn SudoProvider, +) -> Result { + validate_run_cli(run_cli)?; + let status = provider.status()?; + let prepared = prepare_launch(run_cli)?; + if !status.is_elevated && launch_requires_elevated_relay(run_cli) { + let relay = build_relay_arguments(run_cli); + let result = provider.elevate_current_process( + &relay.arguments, + effective_wait(run_cli), + run_cli.show_window, + )?; + if effective_wait(run_cli) { + let exit_code = read_relay_exit_code(&relay.exit_path)?; + replay_relay_output(relay.stdout_path.as_deref(), relay.stderr_path.as_deref())?; + return Ok(exit_code); + } + return Ok(exit_code_from_launch(result)); + } + let result = provider.launch_request(&prepared)?; + if matches!(render_mode, RenderMode::Json) { + print_json(&result)?; + } + Ok(exit_code_from_launch(result)) +} + +const fn launch_requires_elevated_relay(run_cli: &RunCli) -> bool { + match run_cli.user { + LaunchIdentity::CurrentProcess => false, + LaunchIdentity::CurrentUser => { + run_cli.session.is_some() + || run_cli.integrity.is_some() + || !matches!(run_cli.privileges, PrivilegeMode::Default) + } + LaunchIdentity::Admin | LaunchIdentity::System | LaunchIdentity::TrustedInstaller => true, + } +} + +fn run_relay(relay: &RelayCli, provider: &dyn SudoProvider) -> Result { + validate_run_cli(&relay.run)?; + if relay.run.same_console { + let console_pid = relay.console_pid.ok_or_else(|| { + CliError::usage("internal same-console relay requires --console-pid ") + })?; + provider.attach_parent_console(console_pid)?; + } + let mut prepared = prepare_launch(&relay.run)?; + prepared.stdout_path = relay + .stdout_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + prepared.stderr_path = relay + .stderr_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + match provider.launch_request(&prepared) { + Ok(result) => { + let exit_code = exit_code_from_launch(result); + write_relay_exit_status( + &relay.exit_code_path, + &RelayExitStatus { + ok: true, + exit_code: Some(exit_code), + error: None, + }, + )?; + Ok(exit_code) + } + Err(error) => { + write_relay_exit_status( + &relay.exit_code_path, + &RelayExitStatus { + ok: false, + exit_code: None, + error: Some(error.to_string()), + }, + )?; + Err(error) + } + } +} + +fn exit_code_from_launch(result: LaunchResult) -> i32 { + result + .exit_code + .unwrap_or_else(|| ExitCode::Success.as_i32()) +} + +fn build_status_report(status: TokenStatus) -> StatusReport { + StatusReport { + ok: true, + host: std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()), + supports_runas: true, + is_elevated: status.is_elevated, + is_admin_member: status.is_admin_member, + integrity: status.integrity, + current_user: status.current_user, + session_id: status.session_id, + active_session_id: status.active_session_id, + can_admin: status.can_admin, + can_current_user: status.can_current_user, + can_system: status.can_system, + can_trustedinstaller: status.can_trustedinstaller, + trustedinstaller_installed: status.trustedinstaller_installed, + trustedinstaller_running: status.trustedinstaller_running, + shells: ShellPreset::all() + .into_iter() + .map(resolve_shell_status) + .collect(), + } +} + +fn render_status_text(report: &StatusReport, quiet: bool) -> String { + let mut rendered = String::new(); + let _ = writeln!( + rendered, + "host={} elevated={} admin_member={} integrity={:?}", + report.host, report.is_elevated, report.is_admin_member, report.integrity + ); + let _ = writeln!( + rendered, + "runas={} current_user={} system={} trustedinstaller={} session={} active_session={}", + report.supports_runas, + report.can_current_user, + report.can_system, + report.can_trustedinstaller, + format_optional_u32(report.session_id), + format_optional_u32(report.active_session_id) + ); + if !quiet { + for shell in &report.shells { + let _ = writeln!( + rendered, + "shell={} available={} executable={} source={}", + shell.preset.name(), + shell.available, + shell.executable.as_deref().unwrap_or("-"), + shell + .resolution_source + .map_or("-", shell_resolution_source_name), + ); + } + } + rendered +} + +fn validate_run_cli(run_cli: &RunCli) -> Result<(), CliError> { + if matches!( + run_cli.user, + LaunchIdentity::System | LaunchIdentity::TrustedInstaller + ) && !run_cli.dangerous + { + return Err(CliError::usage( + "--user system and --user trustedinstaller require --dangerous", + )); + } + if run_cli.command.is_empty() && run_cli.shell.is_none() { + return Err(CliError::usage( + "provide a command after -- or select --shell ", + )); + } + if run_cli.same_console && run_cli.new_window { + return Err(CliError::usage( + "--same-console cannot be combined with --new-window", + )); + } + if matches!(run_cli.user, LaunchIdentity::CurrentProcess) + && (run_cli.integrity.is_some() + || !matches!(run_cli.privileges, PrivilegeMode::Default) + || run_cli.session.is_some()) + { + return Err(CliError::usage( + "--user current-process cannot be combined with token shaping options", + )); + } + Ok(()) +} + +fn prepare_launch(run_cli: &RunCli) -> Result { + let interactive_shell = is_interactive_shell(run_cli); + let current_directory = effective_current_directory(run_cli); + let prepared_command = if run_cli.command.is_empty() { + prepare_interactive_shell(run_cli.shell.expect("shell checked in validate"))? + } else if let Some(preset) = run_cli.shell { + prepare_shell_wrapped_command(preset, &run_cli.command)? + } else { + prepare_direct_command( + &run_cli.command[0], + &run_cli.command[1..], + run_cli.user, + std::env::var_os("PATH").as_deref(), + std::env::var_os("PATHEXT").as_deref(), + )? + }; + let new_window = effective_new_window(run_cli, interactive_shell); + Ok(LaunchRequest { + program: prepared_command.program, + args: prepared_command.args, + current_directory, + stdout_path: None, + stderr_path: None, + identity: run_cli.user, + privileges: run_cli.privileges, + integrity: run_cli.integrity, + priority: run_cli.priority, + show_window: run_cli.show_window, + session: run_cli.session, + same_console: run_cli.same_console, + new_window, + wait: effective_wait(run_cli), + }) +} + +fn is_interactive_shell(run_cli: &RunCli) -> bool { + run_cli.command.is_empty() && run_cli.shell.is_some() +} + +const fn effective_wait(run_cli: &RunCli) -> bool { + run_cli.wait || run_cli.same_console +} + +fn effective_current_directory(run_cli: &RunCli) -> Option { + run_cli.current_directory.clone().or_else(|| { + std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()) + }) +} + +const fn effective_new_window(run_cli: &RunCli, interactive_shell: bool) -> bool { + if run_cli.same_console { + false + } else { + run_cli.new_window || interactive_shell + } +} + +fn build_relay_arguments(run_cli: &RunCli) -> RelayLaunch { + let exit_path = unique_exit_code_path(); + let (stdout_path, stderr_path) = if effective_wait(run_cli) && !is_interactive_shell(run_cli) { + (Some(unique_exit_code_path()), Some(unique_exit_code_path())) + } else { + (None, None) + }; + let mut arguments = vec![ + RELAY_SUBCOMMAND.to_string(), + "--user".to_string(), + launch_identity_name(run_cli.user).to_string(), + "--exit-code-path".to_string(), + exit_path.display().to_string(), + ]; + if run_cli.dangerous { + arguments.push("--dangerous".to_string()); + } + if let Some(shell) = run_cli.shell { + arguments.push("--shell".to_string()); + arguments.push(shell.name().to_string()); + } + if let Some(integrity) = run_cli.integrity { + arguments.push("--integrity".to_string()); + arguments.push(token_integrity_name(integrity).to_string()); + } + if !matches!(run_cli.privileges, PrivilegeMode::Default) { + arguments.push("--privileges".to_string()); + arguments.push(privilege_mode_name(run_cli.privileges).to_string()); + } + if !matches!(run_cli.priority, ProcessPriority::Normal) { + arguments.push("--priority".to_string()); + arguments.push(priority_name(run_cli.priority).to_string()); + } + if !matches!(run_cli.show_window, ShowWindowMode::Default) { + arguments.push("--show-window".to_string()); + arguments.push(show_window_name(run_cli.show_window).to_string()); + } + if let Some(session) = run_cli.session { + arguments.push("--session".to_string()); + arguments.push(session.to_string()); + } + if let Some(current_directory) = effective_current_directory(run_cli) { + arguments.push("--current-directory".to_string()); + arguments.push(current_directory); + } + if run_cli.same_console { + arguments.push("--same-console".to_string()); + arguments.push("--console-pid".to_string()); + arguments.push(std::process::id().to_string()); + } + if let Some(path) = &stdout_path { + arguments.push("--stdout-path".to_string()); + arguments.push(path.display().to_string()); + } + if let Some(path) = &stderr_path { + arguments.push("--stderr-path".to_string()); + arguments.push(path.display().to_string()); + } + if run_cli.new_window { + arguments.push("--new-window".to_string()); + } + if effective_wait(run_cli) { + arguments.push("--wait".to_string()); + } + if !run_cli.command.is_empty() { + arguments.push("--".to_string()); + arguments.extend(run_cli.command.iter().cloned()); + } + RelayLaunch { + arguments, + exit_path, + stdout_path, + stderr_path, + } +} + +fn unique_exit_code_path() -> PathBuf { + let temp_dir = std::env::temp_dir(); + for _ in 0..32 { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |value| value.as_nanos()); + let counter = EXIT_CODE_PATH_COUNTER.fetch_add(1, Ordering::Relaxed); + let candidate = temp_dir.join(format!( + "msudo-exit-{}-{unique}-{counter}.json", + std::process::id() + )); + if !candidate.exists() { + return candidate; + } + } + let counter = EXIT_CODE_PATH_COUNTER.fetch_add(1, Ordering::Relaxed); + temp_dir.join(format!( + "msudo-exit-fallback-{}-{counter}.json", + std::process::id() + )) +} + +fn write_relay_exit_status(path: &Path, status: &RelayExitStatus) -> Result<(), CliError> { + let payload = serde_json::to_string(status).map_err(|error| { + CliError::runtime(format!("failed to encode relay exit status: {error}")) + })?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + CliError::runtime(format!( + "failed to create relay exit status at {}: {error}", + path.display() + )) + })?; + file.write_all(payload.as_bytes()).map_err(|error| { + CliError::runtime(format!( + "failed to write relay exit status to {}: {error}", + path.display() + )) + }) +} + +fn read_relay_exit_code(path: &Path) -> Result { + let deadline = Instant::now() + Duration::from_secs(2); + let content = loop { + match fs::read_to_string(path) { + Ok(content) => break content, + Err(error) + if Instant::now() < deadline + && matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) => + { + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => { + return Err(CliError::runtime(format!( + "failed to read relay exit code from {}: {error}", + path.display() + ))); + } + } + }; + let _ = fs::remove_file(path); + if let Ok(status) = serde_json::from_str::(content.trim()) { + if status.ok { + return status.exit_code.ok_or_else(|| { + CliError::runtime("relay exit status was missing an exit code".to_string()) + }); + } + return Err(CliError::runtime(status.error.unwrap_or_else(|| { + "privileged relay failed without an error message".to_string() + }))); + } + content.trim().parse::().map_err(|error| { + CliError::runtime(format!( + "failed to parse relay exit status from {}: {error}", + path.display() + )) + }) +} + +fn replay_relay_output( + stdout_path: Option<&Path>, + stderr_path: Option<&Path>, +) -> Result<(), CliError> { + if let Some(path) = stdout_path { + replay_one_relay_output(path, false)?; + } + if let Some(path) = stderr_path { + replay_one_relay_output(path, true)?; + } + Ok(()) +} + +fn replay_one_relay_output(path: &Path, stderr: bool) -> Result<(), CliError> { + let bytes = fs::read(path).map_err(|error| { + CliError::runtime(format!( + "failed to read relay output from {}: {error}", + path.display() + )) + })?; + let _ = fs::remove_file(path); + if stderr { + std::io::stderr().write_all(&bytes).map_err(|error| { + CliError::runtime(format!("failed to replay relay stderr: {error}")) + })?; + std::io::stderr() + .flush() + .map_err(|error| CliError::runtime(format!("failed to flush relay stderr: {error}")))?; + } else { + std::io::stdout().write_all(&bytes).map_err(|error| { + CliError::runtime(format!("failed to replay relay stdout: {error}")) + })?; + std::io::stdout() + .flush() + .map_err(|error| CliError::runtime(format!("failed to flush relay stdout: {error}")))?; + } + Ok(()) +} + +fn prepare_interactive_shell(shell: ShellPreset) -> Result { + let resolution = resolve_shell(shell)?; + Ok(PreparedCommand { + program: resolution.executable, + args: resolution.default_args, + }) +} + +fn prepare_shell_wrapped_command( + shell: ShellPreset, + command: &[String], +) -> Result { + let resolution = resolve_shell(shell)?; + let shell_command = quote_for_shell(shell, command)?; + let args = match shell { + ShellPreset::Cmd | ShellPreset::Tcc | ShellPreset::Yori => { + let mut args = resolution.default_args; + args.push("/d".to_string()); + args.push("/s".to_string()); + args.push("/c".to_string()); + args.push(shell_command); + args + } + ShellPreset::PowerShell | ShellPreset::Pwsh => { + let mut args = resolution.default_args; + args.push("-Command".to_string()); + args.push(shell_command); + args + } + ShellPreset::Wsl => { + let mut args = resolution.default_args; + args.push("--".to_string()); + args.extend(command.iter().cloned()); + args + } + ShellPreset::GitBash | ShellPreset::Mingw | ShellPreset::Msys2 | ShellPreset::Cygwin => { + let mut args = resolution.default_args; + args.push("-lc".to_string()); + args.push(shell_command); + args + } + ShellPreset::Nu => { + let mut args = resolution.default_args; + args.push("-c".to_string()); + args.push(shell_command); + args + } + }; + Ok(PreparedCommand { + program: resolution.executable, + args, + }) +} + +fn prepare_direct_command( + program: &str, + args: &[String], + identity: LaunchIdentity, + path: Option<&OsStr>, + pathext: Option<&OsStr>, +) -> Result { + let resolution = resolve_direct_command_from_path(program, path, pathext); + validate_direct_command_policy(program, identity, resolution.source)?; + Ok(PreparedCommand { + program: resolution.executable, + args: args.to_vec(), + }) +} + +fn quote_for_shell(shell: ShellPreset, values: &[String]) -> Result { + match shell { + ShellPreset::PowerShell | ShellPreset::Pwsh => Ok(quote_powershell_invocation(values)), + ShellPreset::Cmd | ShellPreset::Tcc | ShellPreset::Yori => Ok(values + .iter() + .map(|value| quote_windows_style(value)) + .collect::, _>>()? + .join(" ")), + _ => Ok(values + .iter() + .map(|value| quote_posix_style(value)) + .collect::>() + .join(" ")), + } +} + +fn quote_powershell_invocation(values: &[String]) -> String { + let Some((program, args)) = values.split_first() else { + return "& ''".to_string(); + }; + let program = quote_powershell_string(program); + if args.is_empty() { + return format!("& {program}"); + } + let args = args + .iter() + .map(|value| quote_powershell_string(value)) + .collect::>() + .join(", "); + format!("& {program} @({args})") +} + +fn quote_powershell_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn quote_windows_style(value: &str) -> Result { + if let Some(character) = value + .chars() + .find(|character| matches!(character, '%' | '`' | '\r' | '\n')) + { + return Err(CliError::usage(format!( + "unsafe shell metacharacter {character:?} is not allowed with cmd-style shells" + ))); + } + if value.is_empty() { + return Ok("\"\"".to_string()); + } + if !value.contains([' ', '\t', '"', '&', '|', '^', '>', '<']) { + return Ok(value.to_string()); + } + Ok(format!("\"{}\"", value.replace('"', "\\\""))) +} + +fn quote_posix_style(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if !value.contains([ + ' ', '\t', '\'', '"', '$', '|', '&', ';', '>', '<', '`', '\r', '\n', '%', + ]) { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn resolve_shell_status(preset: ShellPreset) -> ShellStatus { + match preset.try_resolve() { + Ok(resolution) => ShellStatus { + preset, + available: true, + executable: Some(resolution.executable), + resolution_source: Some(resolution.source), + error: None, + }, + Err(error) => ShellStatus { + preset, + available: false, + executable: None, + resolution_source: None, + error: Some(error), + }, + } +} + +#[derive(Debug, Clone)] +struct PreparedCommand { + program: String, + args: Vec, +} + +#[derive(Debug, Clone)] +struct ResolvedShell { + executable: String, + default_args: Vec, + source: ShellResolutionSource, +} + +trait SudoProvider { + fn status(&self) -> Result; + fn elevate_current_process( + &self, + arguments: &[String], + wait: bool, + show_window: ShowWindowMode, + ) -> Result; + fn attach_parent_console(&self, parent_pid: u32) -> Result<(), CliError>; + fn launch_request(&self, request: &LaunchRequest) -> Result; +} + +struct WindowsSudoProvider; + +impl SudoProvider for WindowsSudoProvider { + fn status(&self) -> Result { + windowsupport::current_token_status().map_err(|error| CliError::runtime(error.to_string())) + } + + fn elevate_current_process( + &self, + arguments: &[String], + wait: bool, + show_window: ShowWindowMode, + ) -> Result { + windowsupport::elevate_current_process(arguments, wait, show_window) + .map_err(|error| CliError::runtime(error.to_string())) + } + + fn attach_parent_console(&self, parent_pid: u32) -> Result<(), CliError> { + windowsupport::attach_parent_console(parent_pid) + .map_err(|error| CliError::runtime(error.to_string())) + } + + fn launch_request(&self, request: &LaunchRequest) -> Result { + windowsupport::launch_request(request).map_err(|error| CliError::runtime(error.to_string())) + } +} + +impl ShellPreset { + const fn all() -> [Self; 11] { + [ + Self::Cmd, + Self::PowerShell, + Self::Pwsh, + Self::Wsl, + Self::GitBash, + Self::Mingw, + Self::Msys2, + Self::Cygwin, + Self::Yori, + Self::Tcc, + Self::Nu, + ] + } + + const fn name(self) -> &'static str { + match self { + Self::Cmd => "cmd", + Self::PowerShell => "powershell", + Self::Pwsh => "pwsh", + Self::Wsl => "wsl", + Self::GitBash => "git-bash", + Self::Mingw => "mingw", + Self::Msys2 => "msys2", + Self::Cygwin => "cygwin", + Self::Yori => "yori", + Self::Tcc => "tcc", + Self::Nu => "nu", + } + } + + fn try_resolve(self) -> Result { + resolve_shell_from_path( + self, + std::env::var_os("PATH").as_deref(), + std::env::var_os("PATHEXT").as_deref(), + ) + } +} + +fn default_shell_args(shell: ShellPreset) -> Vec { + match shell { + ShellPreset::PowerShell | ShellPreset::Pwsh => vec!["-NoProfile".to_string()], + _ => Vec::new(), + } +} + +const fn shell_candidates(shell: ShellPreset) -> &'static [&'static str] { + match shell { + ShellPreset::Cmd => &["cmd.exe", "cmd"], + ShellPreset::PowerShell => &["powershell.exe", "powershell"], + ShellPreset::Pwsh => &["pwsh.exe", "pwsh"], + ShellPreset::Wsl => &["wsl.exe", "wsl"], + ShellPreset::GitBash => &["bash.exe", "bash", "git-bash.exe"], + ShellPreset::Mingw | ShellPreset::Msys2 | ShellPreset::Cygwin => &[], + ShellPreset::Yori => &["yori.exe", "yori"], + ShellPreset::Tcc => &["tcc.exe", "tcc"], + ShellPreset::Nu => &["nu.exe", "nu"], + } +} + +const fn shell_known_locations(shell: ShellPreset) -> &'static [&'static str] { + match shell { + ShellPreset::Cmd => &["C:\\Windows\\System32\\cmd.exe"], + ShellPreset::PowerShell => { + &["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"] + } + ShellPreset::Pwsh => &[ + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe", + ], + ShellPreset::Wsl => &["C:\\Windows\\System32\\wsl.exe"], + ShellPreset::GitBash => &[ + "C:\\Program Files\\Git\\usr\\bin\\bash.exe", + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files\\Git\\git-bash.exe", + ], + ShellPreset::Mingw | ShellPreset::Msys2 => &["C:\\msys64\\usr\\bin\\bash.exe"], + ShellPreset::Cygwin => &["C:\\cygwin64\\bin\\bash.exe"], + ShellPreset::Yori => &["C:\\Program Files\\Yori\\yori.exe"], + ShellPreset::Tcc => &["C:\\Program Files\\JPSoft\\TCC.exe"], + ShellPreset::Nu => &[ + "C:\\Program Files\\Nushell\\bin\\nu.exe", + "C:\\Program Files\\nu\\bin\\nu.exe", + ], + } +} + +fn shell_resolution_candidates(shell: ShellPreset) -> Vec<&'static str> { + let known = shell_known_locations(shell); + let path = shell_candidates(shell); + let mut values = Vec::with_capacity(known.len() + path.len()); + values.extend_from_slice(known); + values.extend_from_slice(path); + values +} + +fn resolve_shell(shell: ShellPreset) -> Result { + shell.try_resolve().map_err(CliError::runtime) +} + +fn resolve_shell_from_path( + shell: ShellPreset, + path: Option<&OsStr>, + pathext: Option<&OsStr>, +) -> Result { + for candidate in shell_resolution_candidates(shell) { + if let Some(resolution) = find_executable(candidate, path, pathext) { + return Ok(ResolvedShell { + executable: resolution.executable, + default_args: default_shell_args(shell), + source: resolution.source, + }); + } + } + Err(format!("no executable found for {}", shell.name())) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedExecutable { + executable: String, + source: ShellResolutionSource, +} + +fn find_executable( + candidate: &str, + path: Option<&OsStr>, + pathext: Option<&OsStr>, +) -> Option { + let candidate_path = Path::new(candidate); + if candidate_path.components().count() > 1 && candidate_path.exists() { + return Some(ResolvedExecutable { + executable: candidate_path.display().to_string(), + source: ShellResolutionSource::KnownLocation, + }); + } + find_executable_in_path(candidate, path?, pathext).map(|executable| ResolvedExecutable { + executable, + source: ShellResolutionSource::Path, + }) +} + +fn find_executable_in_path( + candidate: &str, + path: &OsStr, + pathext: Option<&OsStr>, +) -> Option { + let pathext = pathext.map_or_else( + || vec![".exe".to_string(), ".cmd".to_string(), ".bat".to_string()], + |value| { + value + .to_string_lossy() + .split(';') + .map(str::to_ascii_lowercase) + .collect::>() + }, + ); + for directory in std::env::split_paths(path) { + let direct = directory.join(candidate); + if direct.exists() { + return Some(direct.display().to_string()); + } + if direct.extension().is_none() { + for extension in &pathext { + let path = directory.join(format!("{candidate}{extension}")); + if path.exists() { + return Some(path.display().to_string()); + } + } + } + } + None +} + +fn resolve_direct_command_from_path( + program: &str, + path: Option<&OsStr>, + pathext: Option<&OsStr>, +) -> ResolvedExecutable { + let program_path = Path::new(program); + if program_path.components().count() > 1 { + return ResolvedExecutable { + executable: program.to_string(), + source: ShellResolutionSource::KnownLocation, + }; + } + for candidate in direct_command_known_locations(program) { + let candidate_path = Path::new(candidate); + if candidate_path.exists() { + return ResolvedExecutable { + executable: candidate_path.display().to_string(), + source: ShellResolutionSource::KnownLocation, + }; + } + } + find_executable_in_path(program, path.unwrap_or_else(|| OsStr::new("")), pathext).map_or_else( + || ResolvedExecutable { + executable: program.to_string(), + source: ShellResolutionSource::Path, + }, + |executable| ResolvedExecutable { + executable, + source: ShellResolutionSource::Path, + }, + ) +} + +fn direct_command_known_locations(program: &str) -> &'static [&'static str] { + match program.to_ascii_lowercase().as_str() { + "cmd" | "cmd.exe" => shell_known_locations(ShellPreset::Cmd), + "powershell" | "powershell.exe" => shell_known_locations(ShellPreset::PowerShell), + "pwsh" | "pwsh.exe" => shell_known_locations(ShellPreset::Pwsh), + "wsl" | "wsl.exe" => shell_known_locations(ShellPreset::Wsl), + _ => &[], + } +} + +fn validate_direct_command_policy( + program: &str, + identity: LaunchIdentity, + source: ShellResolutionSource, +) -> Result<(), CliError> { + if matches!( + identity, + LaunchIdentity::Admin | LaunchIdentity::System | LaunchIdentity::TrustedInstaller + ) && matches!(source, ShellResolutionSource::Path) + { + return Err(CliError::usage(format!( + "{identity:?} launch refuses PATH-resolved bare command '{program}'; use an explicit path or --shell " + ))); + } + Ok(()) +} + +const fn shell_resolution_source_name(source: ShellResolutionSource) -> &'static str { + match source { + ShellResolutionSource::KnownLocation => "known-location", + ShellResolutionSource::Path => "path", + } +} + +fn format_optional_u32(value: Option) -> String { + value.map_or_else(|| "-".to_string(), |item| item.to_string()) +} + +const fn launch_identity_name(identity: LaunchIdentity) -> &'static str { + match identity { + LaunchIdentity::CurrentProcess => "current-process", + LaunchIdentity::CurrentUser => "current-user", + LaunchIdentity::Admin => "admin", + LaunchIdentity::System => "system", + LaunchIdentity::TrustedInstaller => "trustedinstaller", + } +} + +const fn token_integrity_name(level: TokenIntegrity) -> &'static str { + match level { + TokenIntegrity::Untrusted => "untrusted", + TokenIntegrity::Low => "low", + TokenIntegrity::Medium => "medium", + TokenIntegrity::MediumPlus => "medium-plus", + TokenIntegrity::High => "high", + TokenIntegrity::System => "system", + TokenIntegrity::Unknown => "unknown", + } +} + +const fn privilege_mode_name(mode: PrivilegeMode) -> &'static str { + match mode { + PrivilegeMode::Default => "default", + PrivilegeMode::EnableAll => "enable-all", + PrivilegeMode::DisableAll => "disable-all", + } +} + +const fn priority_name(priority: ProcessPriority) -> &'static str { + match priority { + ProcessPriority::Idle => "idle", + ProcessPriority::BelowNormal => "below-normal", + ProcessPriority::Normal => "normal", + ProcessPriority::AboveNormal => "above-normal", + ProcessPriority::High => "high", + ProcessPriority::Realtime => "realtime", + } +} + +const fn show_window_name(mode: ShowWindowMode) -> &'static str { + match mode { + ShowWindowMode::Default => "default", + ShowWindowMode::Hidden => "hidden", + ShowWindowMode::Normal => "normal", + ShowWindowMode::Minimized => "minimized", + ShowWindowMode::Maximized => "maximized", + } +} + +#[allow(clippy::too_many_lines)] +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut run = RunCli { + user: LaunchIdentity::Admin, + dangerous: false, + shell: None, + integrity: None, + privileges: PrivilegeMode::Default, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Default, + session: None, + current_directory: None, + same_console: false, + new_window: false, + wait: false, + command: Vec::new(), + }; + let mut mode = None::; + let mut relay_exit_code_path = None::; + let mut relay_stdout_path = None::; + let mut relay_stderr_path = None::; + let mut relay_console_pid = None::; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + let help = match mode.as_deref() { + Some("status") => STATUS_HELP, + Some("run" | RELAY_SUBCOMMAND) => RUN_HELP, + _ => HELP, + }; + return Ok(( + ParseOutcome::Help, + Cli { + common, + command: CommandMode::Help(help), + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + command: CommandMode::Status, + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("user") => { + run.user = parse_launch_identity(&parser_value_string(&mut parser, "--user")?)?; + } + Long("dangerous") => run.dangerous = true, + Long("shell") => { + run.shell = Some(parse_shell_preset(&parser_value_string( + &mut parser, + "--shell", + )?)?); + } + Long("integrity") => { + run.integrity = Some(parse_token_integrity(&parser_value_string( + &mut parser, + "--integrity", + )?)?); + } + Long("privileges") => { + run.privileges = + parse_privilege_mode(&parser_value_string(&mut parser, "--privileges")?)?; + } + Long("priority") => { + run.priority = parse_priority(&parser_value_string(&mut parser, "--priority")?)?; + } + Long("show-window") => { + run.show_window = + parse_show_window_mode(&parser_value_string(&mut parser, "--show-window")?)?; + } + Long("session") => { + run.session = Some(parse_u32_flag( + "--session", + &parser_value_string(&mut parser, "--session")?, + )?); + } + Long("current-directory") => { + run.current_directory = + Some(parser_value_string(&mut parser, "--current-directory")?); + } + Long("same-console") => run.same_console = true, + Long("new-window") => run.new_window = true, + Long("wait") => run.wait = true, + Long("exit-code-path") => { + relay_exit_code_path = Some(PathBuf::from(parser_value_string( + &mut parser, + "--exit-code-path", + )?)); + } + Long("stdout-path") => { + relay_stdout_path = Some(PathBuf::from(parser_value_string( + &mut parser, + "--stdout-path", + )?)); + } + Long("stderr-path") => { + relay_stderr_path = Some(PathBuf::from(parser_value_string( + &mut parser, + "--stderr-path", + )?)); + } + Long("console-pid") => { + relay_console_pid = Some(parse_u32_flag( + "--console-pid", + &parser_value_string(&mut parser, "--console-pid")?, + )?); + } + ArgValue(value) => { + let token = os_to_utf8(value, "subcommand")?; + if mode.is_none() && matches!(token.as_str(), "status" | "run" | RELAY_SUBCOMMAND) { + mode = Some(token); + continue; + } + run.command = collect_command_values(&mut parser, token)?; + break; + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let command = match mode.as_deref() { + Some("status") => CommandMode::Status, + Some("run") => { + if run.command.is_empty() && run.shell.is_none() { + return Err(CliError::usage( + "provide a command after -- or select --shell ", + )); + } + CommandMode::Run(run) + } + None => { + if run.command.is_empty() && run.shell.is_none() { + CommandMode::Help(HELP) + } else { + CommandMode::Run(run) + } + } + Some(RELAY_SUBCOMMAND) => CommandMode::Relay(RelayCli { + run, + exit_code_path: relay_exit_code_path.ok_or_else(|| { + CliError::usage("internal relay requires --exit-code-path ") + })?, + stdout_path: relay_stdout_path, + stderr_path: relay_stderr_path, + console_pid: relay_console_pid, + }), + Some(other) => { + return Err(CliError::usage(format!( + "unknown subcommand '{other}'; expected status or run" + ))); + } + }; + + Ok((ParseOutcome::Run, Cli { common, command })) +} + +fn collect_command_values( + parser: &mut lexopt::Parser, + first: String, +) -> Result, CliError> { + let mut values = vec![first]; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + ArgValue(value) => values.push(os_to_utf8(value, "command argument")?), + _ => { + return Err(CliError::usage( + "command arguments must appear after -- and cannot include extra flags", + )); + } + } + } + Ok(values) +} + +fn parse_launch_identity(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "admin" => Ok(LaunchIdentity::Admin), + "current-process" | "self" => Ok(LaunchIdentity::CurrentProcess), + "current-user" | "user" => Ok(LaunchIdentity::CurrentUser), + "system" => Ok(LaunchIdentity::System), + "trustedinstaller" => Ok(LaunchIdentity::TrustedInstaller), + _ => Err(CliError::usage(format!( + "invalid --user value '{value}'; expected current-process, current-user, admin, system, or trustedinstaller" + ))), + } +} + +fn parse_shell_preset(value: &str) -> Result { + match value { + "cmd" => Ok(ShellPreset::Cmd), + "powershell" => Ok(ShellPreset::PowerShell), + "pwsh" => Ok(ShellPreset::Pwsh), + "wsl" => Ok(ShellPreset::Wsl), + "git-bash" => Ok(ShellPreset::GitBash), + "mingw" => Ok(ShellPreset::Mingw), + "msys2" => Ok(ShellPreset::Msys2), + "cygwin" => Ok(ShellPreset::Cygwin), + "yori" => Ok(ShellPreset::Yori), + "tcc" => Ok(ShellPreset::Tcc), + "nu" => Ok(ShellPreset::Nu), + other => Err(CliError::usage(format!("invalid --shell value '{other}'"))), + } +} + +fn parse_token_integrity(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "untrusted" => Ok(TokenIntegrity::Untrusted), + "low" => Ok(TokenIntegrity::Low), + "medium" => Ok(TokenIntegrity::Medium), + "medium-plus" => Ok(TokenIntegrity::MediumPlus), + "high" => Ok(TokenIntegrity::High), + "system" => Ok(TokenIntegrity::System), + _ => Err(CliError::usage(format!( + "invalid --integrity value '{value}'" + ))), + } +} + +fn parse_privilege_mode(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "default" => Ok(PrivilegeMode::Default), + "enable-all" => Ok(PrivilegeMode::EnableAll), + "disable-all" => Ok(PrivilegeMode::DisableAll), + _ => Err(CliError::usage(format!( + "invalid --privileges value '{value}'" + ))), + } +} + +fn parse_priority(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "idle" => Ok(ProcessPriority::Idle), + "below-normal" | "belownormal" => Ok(ProcessPriority::BelowNormal), + "normal" => Ok(ProcessPriority::Normal), + "above-normal" | "abovenormal" => Ok(ProcessPriority::AboveNormal), + "high" => Ok(ProcessPriority::High), + "realtime" | "real-time" => Ok(ProcessPriority::Realtime), + _ => Err(CliError::usage(format!( + "invalid --priority value '{value}'" + ))), + } +} + +fn parse_show_window_mode(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "default" => Ok(ShowWindowMode::Default), + "hidden" | "hide" => Ok(ShowWindowMode::Hidden), + "normal" | "show" => Ok(ShowWindowMode::Normal), + "minimized" | "minimize" => Ok(ShowWindowMode::Minimized), + "maximized" | "maximize" => Ok(ShowWindowMode::Maximized), + _ => Err(CliError::usage(format!( + "invalid --show-window value '{value}'" + ))), + } +} + +fn parse_u32_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_to_utf8(value, flag) +} + +fn os_to_utf8(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone)] + struct MockProvider { + status: TokenStatus, + elevate_result: LaunchResult, + launch_result: LaunchResult, + elevated_arguments: Vec, + attached_console_pids: Vec, + launched: Vec, + relay_exit_code_to_write: Option, + elevate_wait: Option, + } + + impl MockProvider { + fn new() -> Self { + Self { + status: TokenStatus { + is_elevated: true, + is_admin_member: true, + integrity: TokenIntegrity::High, + current_user: Some("TEST\\User".to_string()), + session_id: Some(1), + active_session_id: Some(1), + can_admin: true, + can_current_user: true, + can_system: true, + can_trustedinstaller: true, + trustedinstaller_installed: true, + trustedinstaller_running: false, + }, + elevate_result: LaunchResult { + pid: Some(100), + exit_code: Some(0), + identity: LaunchIdentity::Admin, + }, + launch_result: LaunchResult { + pid: Some(101), + exit_code: Some(0), + identity: LaunchIdentity::Admin, + }, + elevated_arguments: Vec::new(), + attached_console_pids: Vec::new(), + launched: Vec::new(), + relay_exit_code_to_write: None, + elevate_wait: None, + } + } + } + + impl SudoProvider for std::cell::RefCell { + fn status(&self) -> Result { + Ok(self.borrow().status.clone()) + } + + fn elevate_current_process( + &self, + arguments: &[String], + wait: bool, + _show_window: ShowWindowMode, + ) -> Result { + let mut state = self.borrow_mut(); + state.elevated_arguments = arguments.to_vec(); + state.elevate_wait = Some(wait); + if let Some(exit_code) = state.relay_exit_code_to_write { + let exit_path = arguments + .windows(2) + .find(|pair| pair[0] == "--exit-code-path") + .map(|pair| PathBuf::from(&pair[1])) + .expect("relay exit path"); + fs::write(&exit_path, exit_code.to_string()) + .expect("write relay exit code for test"); + for relay_path in arguments + .windows(2) + .filter(|pair| pair[0] == "--stdout-path" || pair[0] == "--stderr-path") + .map(|pair| PathBuf::from(&pair[1])) + { + fs::write(&relay_path, b"").expect("write relay output for test"); + } + } + Ok(state.elevate_result) + } + + fn attach_parent_console(&self, parent_pid: u32) -> Result<(), CliError> { + self.borrow_mut().attached_console_pids.push(parent_pid); + Ok(()) + } + + fn launch_request(&self, request: &LaunchRequest) -> Result { + self.borrow_mut().launched.push(request.clone()); + Ok(self.borrow().launch_result) + } + } + + #[test] + fn parse_status_subcommand_and_json_flag() { + let (outcome, cli) = + parse_cli_from(["msudo", "status", "--json"]).expect("cli should parse"); + assert!(matches!(outcome, ParseOutcome::Run)); + assert!(cli.common.json); + assert!(matches!(cli.command, CommandMode::Status)); + } + + #[test] + fn parse_default_command_mode_accepts_bare_payload() { + let (_, cli) = parse_cli_from([ + "msudo", "--user", "admin", "--", "cmd", "/d", "/c", "whoami", + ]) + .expect("cli should parse"); + let CommandMode::Run(run) = cli.command else { + panic!("expected run mode"); + }; + assert_eq!(run.command[0], "cmd"); + assert_eq!(run.command[3], "whoami"); + } + + #[test] + fn parse_shell_preset_and_window_flags() { + let (_, cli) = parse_cli_from([ + "msudo", + "--shell", + "pwsh", + "--new-window", + "--priority", + "high", + ]) + .expect("cli should parse"); + let CommandMode::Run(run) = cli.command else { + panic!("expected run mode"); + }; + assert_eq!(run.shell, Some(ShellPreset::Pwsh)); + assert!(run.new_window); + assert_eq!(run.priority, ProcessPriority::High); + } + + #[test] + fn parse_same_console_flag() { + let (_, cli) = parse_cli_from(["msudo", "--same-console", "--shell", "powershell"]) + .expect("cli should parse"); + let CommandMode::Run(run) = cli.command else { + panic!("expected run mode"); + }; + assert!(run.same_console); + } + + #[test] + fn parse_current_identity_user_modes() { + let (_, current_user) = parse_cli_from(["msudo", "--user", "current-user", "--", "cmd"]) + .expect("current user parses"); + let CommandMode::Run(run) = current_user.command else { + panic!("expected run mode"); + }; + assert_eq!(run.user, LaunchIdentity::CurrentUser); + + let (_, current_process) = + parse_cli_from(["msudo", "--user", "current-process", "--", "cmd"]) + .expect("current process parses"); + let CommandMode::Run(run) = current_process.command else { + panic!("expected run mode"); + }; + assert_eq!(run.user, LaunchIdentity::CurrentProcess); + } + + #[test] + fn validate_requires_dangerous_for_system_and_trustedinstaller() { + let mut run = sample_run(); + run.user = LaunchIdentity::System; + assert!(validate_run_cli(&run).is_err()); + run.user = LaunchIdentity::TrustedInstaller; + assert!(validate_run_cli(&run).is_err()); + run.dangerous = true; + assert!(validate_run_cli(&run).is_ok()); + } + + #[test] + fn validate_rejects_conflicting_console_mode_flags() { + let mut run = sample_run(); + run.same_console = true; + run.new_window = true; + let error = validate_run_cli(&run).expect_err("validation should fail"); + assert!( + error + .to_string() + .contains("--same-console cannot be combined with --new-window") + ); + } + + #[test] + fn status_report_contains_required_stable_fields() { + let report = build_status_report(TokenStatus { + is_elevated: false, + is_admin_member: true, + integrity: TokenIntegrity::Medium, + current_user: Some("User".to_string()), + session_id: Some(1), + active_session_id: Some(1), + can_admin: true, + can_current_user: true, + can_system: false, + can_trustedinstaller: false, + trustedinstaller_installed: true, + trustedinstaller_running: false, + }); + let json = serde_json::to_string(&report).expect("json"); + assert!(json.contains("\"ok\":true")); + assert!(json.contains("\"supports_runas\":true")); + assert!(json.contains("\"host\":")); + assert!(json.contains("\"is_elevated\":false")); + } + + #[test] + fn render_status_text_uses_cli_shell_names() { + let rendered = render_status_text( + &StatusReport { + ok: true, + host: "localhost".to_string(), + supports_runas: true, + is_elevated: true, + is_admin_member: true, + integrity: TokenIntegrity::High, + current_user: Some("User".to_string()), + session_id: Some(1), + active_session_id: Some(1), + can_admin: true, + can_current_user: true, + can_system: true, + can_trustedinstaller: true, + trustedinstaller_installed: true, + trustedinstaller_running: false, + shells: vec![ShellStatus { + preset: ShellPreset::GitBash, + available: true, + executable: Some("C:\\Program Files\\Git\\usr\\bin\\bash.exe".to_string()), + resolution_source: Some(ShellResolutionSource::KnownLocation), + error: None, + }], + }, + false, + ); + assert!(rendered.contains("shell=git-bash")); + assert!(!rendered.contains("shell=GitBash")); + } + + #[test] + fn quiet_status_text_omits_shell_inventory() { + let rendered = render_status_text( + &StatusReport { + ok: true, + host: "localhost".to_string(), + supports_runas: true, + is_elevated: true, + is_admin_member: true, + integrity: TokenIntegrity::High, + current_user: Some("User".to_string()), + session_id: Some(1), + active_session_id: Some(1), + can_admin: true, + can_current_user: true, + can_system: true, + can_trustedinstaller: true, + trustedinstaller_installed: true, + trustedinstaller_running: false, + shells: vec![ShellStatus { + preset: ShellPreset::Pwsh, + available: true, + executable: Some("pwsh.exe".to_string()), + resolution_source: Some(ShellResolutionSource::Path), + error: None, + }], + }, + true, + ); + assert!(!rendered.contains("shell=")); + } + + #[test] + fn shell_preset_json_names_match_cli_names() { + for preset in ShellPreset::all() { + assert_eq!( + serde_json::to_string(&preset).expect("preset json"), + format!("\"{}\"", preset.name()) + ); + } + } + + #[test] + fn shell_wrapping_for_pwsh_and_cmd_is_stable() { + let pwsh = prepare_shell_wrapped_command( + ShellPreset::Pwsh, + &[ + "git".to_string(), + "status".to_string(), + "--short".to_string(), + ], + ) + .expect("pwsh shell command"); + let normalized = pwsh.program.to_ascii_lowercase(); + assert!( + shell_resolution_candidates(ShellPreset::Pwsh) + .iter() + .any(|candidate| { normalized.ends_with(&candidate.to_ascii_lowercase()) }) + ); + assert!(pwsh.args.contains(&"-Command".to_string())); + assert_eq!( + pwsh.args.last().expect("pwsh command"), + "& 'git' @('status', '--short')" + ); + + let cmd = prepare_shell_wrapped_command( + ShellPreset::Cmd, + &["tool.exe".to_string(), "two words".to_string()], + ) + .expect("cmd shell command"); + assert!(cmd.args.contains(&"/c".to_string())); + assert!(cmd.args.last().expect("last arg").contains("\"two words\"")); + } + + #[test] + fn shell_wrapping_rejects_or_quotes_shell_metacharacters() { + let cmd = prepare_shell_wrapped_command( + ShellPreset::Cmd, + &["tool.exe".to_string(), "%PATH%".to_string()], + ) + .expect_err("cmd percent expansion should be rejected"); + assert!(cmd.to_string().contains("unsafe shell metacharacter")); + + let posix = quote_for_shell( + ShellPreset::GitBash, + &[ + "tool".to_string(), + ">".to_string(), + "<".to_string(), + "`whoami`".to_string(), + "two\nlines".to_string(), + "%PATH%".to_string(), + ], + ) + .expect("posix metacharacters should be quoted"); + assert_eq!(posix, "tool '>' '<' '`whoami`' 'two\nlines' '%PATH%'"); + } + + #[test] + fn non_elevated_launch_relays_through_provider() { + let (_, cli) = parse_cli_from([ + "msudo", "--user", "admin", "--", "cmd", "/d", "/c", "whoami", + ]) + .expect("cli should parse"); + let provider = std::cell::RefCell::new(MockProvider::new()); + provider.borrow_mut().status.is_elevated = false; + let code = run_with_provider(&cli, &provider).expect("run should succeed"); + assert_eq!(code, ExitCode::Success.as_i32()); + let elevated_arguments = provider.borrow().elevated_arguments.clone(); + assert!(elevated_arguments.contains(&RELAY_SUBCOMMAND.to_string())); + assert!(elevated_arguments.contains(&"--exit-code-path".to_string())); + assert_eq!(provider.borrow().elevate_wait, Some(false)); + } + + #[test] + fn non_elevated_current_process_does_not_relay() { + let (_, cli) = parse_cli_from([ + "msudo", + "--user", + "current-process", + "--", + "cmd", + "/d", + "/c", + "whoami", + ]) + .expect("cli should parse"); + let provider = std::cell::RefCell::new(MockProvider::new()); + provider.borrow_mut().status.is_elevated = false; + let code = run_with_provider(&cli, &provider).expect("run should succeed"); + assert_eq!(code, ExitCode::Success.as_i32()); + assert!(provider.borrow().elevated_arguments.is_empty()); + assert_eq!( + provider.borrow().launched[0].identity, + LaunchIdentity::CurrentProcess + ); + } + + #[test] + fn non_elevated_wait_uses_relay_exit_code_file() { + let (_, cli) = parse_cli_from([ + "msudo", + "--user", + "system", + "--dangerous", + "--wait", + "--", + "cmd", + "/d", + "/c", + "exit 9", + ]) + .expect("cli should parse"); + let provider = std::cell::RefCell::new(MockProvider::new()); + provider.borrow_mut().status.is_elevated = false; + provider.borrow_mut().relay_exit_code_to_write = Some(9); + let code = run_with_provider(&cli, &provider).expect("run should succeed"); + assert_eq!(code, 9); + assert_eq!(provider.borrow().elevate_wait, Some(true)); + } + + #[test] + fn non_elevated_same_console_forces_waiting_relay() { + let (_, cli) = parse_cli_from([ + "msudo", + "--same-console", + "--user", + "system", + "--dangerous", + "--", + "cmd", + "/d", + "/c", + "exit 0", + ]) + .expect("cli should parse"); + let provider = std::cell::RefCell::new(MockProvider::new()); + provider.borrow_mut().status.is_elevated = false; + provider.borrow_mut().relay_exit_code_to_write = Some(0); + let code = run_with_provider(&cli, &provider).expect("run should succeed"); + assert_eq!(code, 0); + let elevated_arguments = provider.borrow().elevated_arguments.clone(); + assert!(elevated_arguments.contains(&"--same-console".to_string())); + assert!(elevated_arguments.contains(&"--console-pid".to_string())); + assert_eq!(provider.borrow().elevate_wait, Some(true)); + } + + #[test] + fn same_console_relay_attaches_parent_console_before_launch() { + let relay_path = unique_exit_code_path(); + let (_, cli) = parse_cli_from([ + "msudo", + "__relay", + "--same-console", + "--console-pid", + "4242", + "--exit-code-path", + relay_path.to_string_lossy().as_ref(), + "--user", + "system", + "--dangerous", + "--wait", + "--", + "cmd", + "/d", + "/c", + "exit 0", + ]) + .expect("relay cli should parse"); + let provider = std::cell::RefCell::new(MockProvider::new()); + let code = run_with_provider(&cli, &provider).expect("relay should succeed"); + assert_eq!(code, 0); + assert_eq!(provider.borrow().attached_console_pids, vec![4242]); + let _ = std::fs::remove_file(relay_path); + } + + #[test] + fn validate_allows_interactive_system_and_trustedinstaller_shells() { + let mut run = sample_run(); + run.command.clear(); + run.user = LaunchIdentity::System; + run.dangerous = true; + assert!(validate_run_cli(&run).is_ok()); + run.user = LaunchIdentity::TrustedInstaller; + assert!(validate_run_cli(&run).is_ok()); + } + + #[test] + fn elevated_launch_calls_provider_directly() { + let (_, cli) = parse_cli_from([ + "msudo", + "--user", + "system", + "--dangerous", + "--", + "cmd", + "/d", + "/c", + "whoami", + ]) + .expect("cli should parse"); + let provider = std::cell::RefCell::new(MockProvider::new()); + let code = run_with_provider(&cli, &provider).expect("run should succeed"); + assert_eq!(code, ExitCode::Success.as_i32()); + assert_eq!(provider.borrow().launched.len(), 1); + assert_eq!( + provider.borrow().launched[0].identity, + LaunchIdentity::System + ); + } + + #[test] + fn prepare_launch_resolves_bare_direct_commands_from_path() { + let temp_root = std::env::temp_dir().join(format!( + "msudo-prepare-launch-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&temp_root).expect("create temp root"); + let tool = temp_root.join("tool.exe"); + std::fs::write(&tool, []).expect("create tool stub"); + let resolved = find_executable_in_path( + "tool", + temp_root.as_os_str(), + Some(std::ffi::OsStr::new(".EXE")), + ) + .expect("tool should resolve"); + let _ = std::fs::remove_file(&tool); + let _ = std::fs::remove_dir(&temp_root); + assert_eq!(resolved, tool.display().to_string()); + } + + #[test] + fn elevated_direct_commands_reject_path_resolved_bare_programs() { + let temp_root = std::env::temp_dir().join(format!( + "msudo-direct-path-policy-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_root).expect("create temp root"); + let tool = temp_root.join("tool.exe"); + fs::write(&tool, []).expect("create tool stub"); + + let error = prepare_direct_command( + "tool", + &["--version".to_string()], + LaunchIdentity::Admin, + Some(temp_root.as_os_str()), + Some(std::ffi::OsStr::new(".EXE")), + ) + .expect_err("admin direct command should reject PATH-resolved bare programs"); + + let _ = fs::remove_file(&tool); + let _ = fs::remove_dir(&temp_root); + + assert!( + error + .to_string() + .contains("refuses PATH-resolved bare command") + ); + } + + #[test] + fn current_process_direct_commands_keep_path_resolution() { + let temp_root = std::env::temp_dir().join(format!( + "msudo-current-process-path-policy-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_root).expect("create temp root"); + let tool = temp_root.join("tool.exe"); + fs::write(&tool, []).expect("create tool stub"); + + let prepared = prepare_direct_command( + "tool", + &["--version".to_string()], + LaunchIdentity::CurrentProcess, + Some(temp_root.as_os_str()), + Some(std::ffi::OsStr::new(".EXE")), + ) + .expect("current-process direct command may use PATH"); + + let _ = fs::remove_file(&tool); + let _ = fs::remove_dir(&temp_root); + + assert_eq!(prepared.program, tool.display().to_string()); + assert_eq!(prepared.args, vec!["--version".to_string()]); + } + + #[test] + fn prepare_launch_defaults_interactive_shells_to_new_window() { + let (_, cli) = + parse_cli_from(["msudo", "--shell", "powershell"]).expect("cli should parse"); + let CommandMode::Run(run) = cli.command else { + panic!("expected run mode"); + }; + let prepared = prepare_launch(&run).expect("launch should prepare"); + assert!(prepared.new_window); + } + + #[test] + fn prepare_launch_same_console_keeps_interactive_shell_in_foreground() { + let (_, cli) = parse_cli_from(["msudo", "--same-console", "--shell", "powershell"]) + .expect("cli should parse"); + let CommandMode::Run(run) = cli.command else { + panic!("expected run mode"); + }; + let prepared = prepare_launch(&run).expect("launch should prepare"); + assert!(prepared.same_console); + assert!(!prepared.new_window); + assert!(prepared.wait); + } + + #[test] + fn prepare_launch_resolves_direct_program_to_executable_when_available() { + let resolved_cmd = resolve_direct_command_from_path( + "cmd", + std::env::var_os("PATH").as_deref(), + std::env::var_os("PATHEXT").as_deref(), + ); + assert!( + resolved_cmd + .executable + .to_ascii_lowercase() + .ends_with("cmd.exe") + ); + } + + #[test] + fn shell_resolution_candidates_prefer_known_git_bash_locations() { + let candidates = shell_resolution_candidates(ShellPreset::GitBash); + assert_eq!( + candidates.first().copied(), + Some("C:\\Program Files\\Git\\usr\\bin\\bash.exe") + ); + assert!(candidates.contains(&"git-bash.exe")); + } + + #[cfg(windows)] + #[test] + fn shell_resolution_prefers_known_locations_over_path_hijack_and_reports_source() { + let temp_root = std::env::temp_dir().join(format!( + "msudo-shell-hijack-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_root).expect("create temp root"); + let fake_cmd = temp_root.join("cmd.exe"); + fs::write(&fake_cmd, []).expect("create fake cmd"); + + let resolved = resolve_shell_from_path( + ShellPreset::Cmd, + Some(temp_root.as_os_str()), + Some(std::ffi::OsStr::new(".EXE")), + ) + .expect("cmd should resolve"); + + let _ = fs::remove_file(&fake_cmd); + let _ = fs::remove_dir(&temp_root); + + assert_ne!(resolved.executable, fake_cmd.display().to_string()); + assert_eq!(resolved.source, ShellResolutionSource::KnownLocation); + assert!( + resolved + .executable + .to_ascii_lowercase() + .ends_with("\\system32\\cmd.exe") + ); + } + + #[test] + fn msys_like_presets_do_not_fall_back_to_generic_bash() { + for preset in [ShellPreset::Mingw, ShellPreset::Msys2, ShellPreset::Cygwin] { + let candidates = shell_resolution_candidates(preset); + assert!(!candidates.contains(&"bash.exe")); + assert!(!candidates.contains(&"bash")); + } + } + + #[test] + fn read_relay_exit_code_parses_and_removes_temp_file() { + let path = unique_exit_code_path(); + assert!(!path.exists()); + fs::write(&path, "17").expect("write exit code"); + let code = read_relay_exit_code(&path).expect("read exit code"); + assert_eq!(code, 17); + assert!(!path.exists()); + } + + #[test] + fn write_relay_exit_status_refuses_replaced_path() { + let path = unique_exit_code_path(); + fs::write(&path, "attacker").expect("attacker replacement"); + + let error = write_relay_exit_status( + &path, + &RelayExitStatus { + ok: true, + exit_code: Some(0), + error: None, + }, + ) + .expect_err("relay writer should refuse existing replacement"); + + assert!( + error + .to_string() + .contains("failed to create relay exit status") + ); + assert_eq!( + fs::read_to_string(&path).expect("replacement remains"), + "attacker" + ); + let _ = fs::remove_file(path); + } + + #[test] + fn read_relay_exit_code_surfaces_json_error_status() { + let path = unique_exit_code_path(); + write_relay_exit_status( + &path, + &RelayExitStatus { + ok: false, + exit_code: None, + error: Some("CreateProcessWithTokenW failed with code 5".to_string()), + }, + ) + .expect("write relay status"); + let error = read_relay_exit_code(&path).expect_err("relay should surface error"); + assert!( + error + .to_string() + .contains("CreateProcessWithTokenW failed with code 5") + ); + assert!(!path.exists()); + } + + fn sample_run() -> RunCli { + RunCli { + user: LaunchIdentity::Admin, + dangerous: false, + shell: Some(ShellPreset::Pwsh), + integrity: None, + privileges: PrivilegeMode::Default, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Default, + session: None, + current_directory: None, + same_console: false, + new_window: false, + wait: false, + command: vec![ + "cmd".to_string(), + "/d".to_string(), + "/c".to_string(), + "whoami".to_string(), + ], + } + } +} diff --git a/crates/msudo/src/main.rs b/crates/msudo/src/main.rs new file mode 100644 index 0000000..baf0f6a --- /dev/null +++ b/crates/msudo/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `msudo`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(msudo::main_entry()); +} diff --git a/crates/msudo/tests/contract.rs b/crates/msudo/tests/contract.rs new file mode 100644 index 0000000..9fe007f --- /dev/null +++ b/crates/msudo/tests/contract.rs @@ -0,0 +1,125 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; + +fn cargo_command() -> Command { + Command::cargo_bin("msudo").expect("binary") +} + +#[test] +fn help_mentions_dangerous_flag_and_shell_presets() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "current-process, current-user, admin, system, trustedinstaller", + )) + .stdout(predicate::str::contains("--dangerous")) + .stdout(predicate::str::contains("trustedinstaller")) + .stdout(predicate::str::contains("Shell preset")) + .stdout(predicate::str::contains("pwsh")) + .stdout(predicate::str::contains("git-bash")); +} + +#[test] +fn status_json_exposes_stable_top_level_fields() { + let output = cargo_command() + .args(["status", "--json"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let report: Value = serde_json::from_slice(&output).expect("valid status json"); + let object = report.as_object().expect("status json object"); + for field in [ + "ok", + "host", + "supports_runas", + "is_elevated", + "is_admin_member", + "integrity", + "current_user", + "session_id", + "active_session_id", + "can_admin", + "can_current_user", + "can_system", + "can_trustedinstaller", + "trustedinstaller_installed", + "trustedinstaller_running", + "shells", + ] { + assert!(object.contains_key(field), "missing status field: {field}"); + } + + assert_eq!(object.get("ok").and_then(Value::as_bool), Some(true)); + assert!(matches!(object.get("host"), Some(Value::String(_)))); + assert!(matches!(object.get("supports_runas"), Some(Value::Bool(_)))); + assert!(matches!(object.get("is_elevated"), Some(Value::Bool(_)))); + assert!(matches!( + object.get("is_admin_member"), + Some(Value::Bool(_)) + )); + assert!(matches!(object.get("integrity"), Some(Value::String(_)))); + assert!(matches!( + object.get("current_user"), + Some(Value::Null | Value::String(_)) + )); + assert!(matches!( + object.get("session_id"), + Some(Value::Null | Value::Number(_)) + )); + assert!(matches!( + object.get("active_session_id"), + Some(Value::Null | Value::Number(_)) + )); + for field in [ + "can_admin", + "can_current_user", + "can_system", + "can_trustedinstaller", + "trustedinstaller_installed", + "trustedinstaller_running", + ] { + assert!( + matches!(object.get(field), Some(Value::Bool(_))), + "status field should be boolean: {field}" + ); + } + + let shells = object + .get("shells") + .and_then(Value::as_array) + .expect("shells array"); + assert!(!shells.is_empty(), "shell preset list should not be empty"); + + let first_shell = shells[0].as_object().expect("shell status object"); + for field in ["preset", "available", "executable", "error"] { + assert!( + first_shell.contains_key(field), + "missing shell status field: {field}" + ); + } +} + +#[test] +fn help_mentions_console_and_window_launch_controls() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "current-process, current-user, admin, system, trustedinstaller", + )) + .stdout(predicate::str::contains("--same-console")) + .stdout(predicate::str::contains("Reuse the current console")) + .stdout(predicate::str::contains("--new-window")) + .stdout(predicate::str::contains( + "Force a new window for the launched process", + )); +} diff --git a/crates/msudo/tests/msudo_cli.rs b/crates/msudo/tests/msudo_cli.rs new file mode 100644 index 0000000..c3f876b --- /dev/null +++ b/crates/msudo/tests/msudo_cli.rs @@ -0,0 +1,93 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("msudo").expect("binary") +} + +#[test] +fn system_user_requires_dangerous_flag() { + let mut command = cargo_command(); + command + .args(["--user", "system", "--", "cmd", "/d", "/c", "whoami"]) + .assert() + .code(2) + .stderr(predicate::str::contains("--dangerous")); +} + +#[test] +fn run_subcommand_without_payload_is_usage_error() { + let mut command = cargo_command(); + command + .arg("run") + .assert() + .code(2) + .stderr(predicate::str::contains( + "provide a command after -- or select --shell ", + )); +} + +#[test] +fn current_process_user_rejects_token_shaping_before_launch() { + let mut command = cargo_command(); + command + .args([ + "--user", + "current-process", + "--privileges", + "enable-all", + "--", + "cmd", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "--user current-process cannot be combined with token shaping options", + )); +} + +#[test] +fn current_user_identity_is_accepted_by_the_installed_cli_parser() { + let mut command = cargo_command(); + command + .args([ + "--user", + "current-user", + "--same-console", + "--new-window", + "--", + "cmd", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "--same-console cannot be combined with --new-window", + )); +} + +#[test] +fn short_nsudo_style_user_values_are_not_native_cli() { + let mut command = cargo_command(); + command + .args(["--user", "p", "--privileges", "enable-all", "--", "cmd"]) + .assert() + .code(2) + .stderr(predicate::str::contains("invalid --user value 'p'")); +} + +#[test] +fn run_help_mentions_same_console_and_new_window_defaults() { + let mut command = cargo_command(); + command + .args(["run", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--format ")) + .stdout(predicate::str::contains("--json")) + .stdout(predicate::str::contains("--toon")) + .stdout(predicate::str::contains("--same-console")) + .stdout(predicate::str::contains("--new-window")) + .stdout(predicate::str::contains( + "Interactive shells open in a new window by default", + )); +} diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml new file mode 100644 index 0000000..1406f01 --- /dev/null +++ b/crates/outline/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "outline" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Emit AST-backed source and heuristic config outlines for AI-friendly file navigation." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/outline/src/lib.rs b/crates/outline/src/lib.rs new file mode 100644 index 0000000..293bcf3 --- /dev/null +++ b/crates/outline/src/lib.rs @@ -0,0 +1,1627 @@ +//! The `outline` command emits AST-backed code and heuristic config summaries. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use codeindex::{CodeIndexer, IndexedSymbol, SymbolKind}; +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +/// CLI arguments for the `outline` binary. +#[derive(Debug, Clone)] +pub struct Cli { + /// Shared output and stdin policy flags. + pub common: CommonArgs, + /// Maximum nested depth to include. + pub depth: Option, + /// Maximum number of outline items to emit per file after filtering. + pub limit: Option, + /// Restrict extraction to code, config, or all supported families. + pub kind: OutlineKind, + /// File paths to inspect when stdin is empty. + pub paths: Vec, +} + +const HELP: &str = "\ +Emit AST-backed and heuristic file structure summaries for code and config files. + +Usage: + outline [OPTIONS] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --depth Maximum nested depth to include + --limit Maximum number of outline items to emit per file + --kind Restrict extraction to all, code, or config + Supported code Rust, C#, PowerShell, Python, Go, Java, JavaScript, TypeScript + -h, --help Show this help text + -V, --version Show the command version + +Examples: + outline .\\fixtures\\reading\\sample.rs + outline .\\fixtures\\reading\\sample.go + outline .\\fixtures\\reading\\sample.java + outline .\\fixtures\\reading\\sample.ts + outline --depth 1 .\\fixtures\\reading\\config.toml + '.\\fixtures\\reading\\config.yaml' | outline --json | ConvertFrom-Json +"; + +/// High-level file families supported by the outline command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutlineKind { + /// Read both code and config families. + All, + /// Read code families such as Rust and C#. + Code, + /// Read config families such as JSON, TOML, and YAML. + Config, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum FileFamily { + Rust, + Csharp, + Powershell, + Python, + Go, + Java, + Javascript, + Typescript, + Markdown, + Json, + Toml, + Yaml, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ItemKind { + Mod, + Struct, + Enum, + Trait, + Impl, + Fn, + Const, + Static, + Type, + Namespace, + Class, + Interface, + Record, + Method, + Var, + Call, + Heading, + Table, + Key, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct OutlineItem { + line: usize, + depth: usize, + kind: ItemKind, + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct OutlineReport { + path: String, + family: Option, + supported: bool, + items: Vec, + omitted_items: usize, + error: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("outline {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + depth: None, + limit: None, + kind: OutlineKind::All, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("depth") => { + cli.depth = Some(parse_usize_flag( + "--depth", + &parser_value_string(&mut parser, "--depth")?, + )?); + } + Long("limit") => { + cli.limit = Some(parse_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + Long("kind") => { + cli.kind = parse_outline_kind(&parser_value_string(&mut parser, "--kind")?)?; + } + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_outline_kind(value: &str) -> Result { + match value { + "all" => Ok(OutlineKind::All), + "code" => Ok(OutlineKind::Code), + "config" => Ok(OutlineKind::Config), + other => Err(CliError::usage(format!( + "invalid --kind value '{other}'; expected all, code, or config" + ))), + } +} + +fn run(cli: &Cli) -> Result { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let mut reports = Vec::new(); + for path in &paths { + if let Some(report) = inspect_path(path, cli.depth, cli.limit, cli.kind)? { + reports.push(report); + } + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&reports)?, + RenderMode::Toon => print_structured(&reports, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_reports(&reports)), + } + + Ok(if reports.is_empty() { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let paths = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !paths.is_empty() { + return Ok(paths); + } + } + + common::expand_input_patterns(&cli.paths, "outline") +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_paths(buffer, input_format, "outline")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn inspect_path( + path: &Path, + max_depth: Option, + limit: Option, + kind: OutlineKind, +) -> Result, CliError> { + let path_text = path.display().to_string(); + let content = fs::read_to_string(path) + .map_err(|error| CliError::runtime(format!("failed to read {path_text}: {error}")))?; + let Some(family) = detect_family(path) else { + return Ok(Some(OutlineReport { + path: path_text, + family: None, + supported: false, + items: Vec::new(), + omitted_items: 0, + error: Some( + "unsupported outline family; try codeshape for AST-backed multi-language structure" + .to_string(), + ), + })); + }; + if !family_matches_kind(family, kind) { + return Ok(None); + } + + let mut items = extract_ast_items(path, &content, family) + .unwrap_or_else(|| extract_items(&content, family)) + .into_iter() + .filter(|item| max_depth.is_none_or(|depth| item.depth <= depth)) + .collect::>(); + let omitted_items = limit.map_or(0, |item_limit| items.len().saturating_sub(item_limit)); + if let Some(item_limit) = limit { + items.truncate(item_limit); + } + + Ok(Some(OutlineReport { + path: path_text, + family: Some(family), + supported: true, + items, + omitted_items, + error: None, + })) +} + +fn detect_family(path: &Path) -> Option { + match path.extension().and_then(|ext| ext.to_str()) { + Some("rs") => Some(FileFamily::Rust), + Some("cs") => Some(FileFamily::Csharp), + Some("ps1" | "psm1" | "psd1") => Some(FileFamily::Powershell), + Some("py") => Some(FileFamily::Python), + Some("go") => Some(FileFamily::Go), + Some("java") => Some(FileFamily::Java), + Some("js" | "mjs" | "cjs" | "jsx") => Some(FileFamily::Javascript), + Some("ts" | "tsx") => Some(FileFamily::Typescript), + Some("md" | "markdown") => Some(FileFamily::Markdown), + Some("json") => Some(FileFamily::Json), + Some("toml") => Some(FileFamily::Toml), + Some("yaml" | "yml") => Some(FileFamily::Yaml), + _ => None, + } +} + +const fn family_matches_kind(family: FileFamily, kind: OutlineKind) -> bool { + match kind { + OutlineKind::All => true, + OutlineKind::Code => matches!( + family, + FileFamily::Rust + | FileFamily::Csharp + | FileFamily::Powershell + | FileFamily::Python + | FileFamily::Go + | FileFamily::Java + | FileFamily::Javascript + | FileFamily::Typescript + ), + OutlineKind::Config => matches!( + family, + FileFamily::Json | FileFamily::Toml | FileFamily::Yaml + ), + } +} + +fn extract_items(content: &str, family: FileFamily) -> Vec { + match family { + FileFamily::Rust => extract_rust_items(content), + FileFamily::Csharp => extract_csharp_items(content), + FileFamily::Powershell => extract_powershell_items(content), + FileFamily::Python + | FileFamily::Go + | FileFamily::Java + | FileFamily::Javascript + | FileFamily::Typescript => Vec::new(), + FileFamily::Markdown => extract_markdown_items(content), + FileFamily::Json => extract_json_items(content), + FileFamily::Toml => extract_toml_items(content), + FileFamily::Yaml => extract_yaml_items(content), + } +} + +fn extract_ast_items(path: &Path, content: &str, family: FileFamily) -> Option> { + if !matches!( + family, + FileFamily::Rust + | FileFamily::Csharp + | FileFamily::Powershell + | FileFamily::Python + | FileFamily::Go + | FileFamily::Java + | FileFamily::Javascript + | FileFamily::Typescript + ) { + return None; + } + + let mut indexer = CodeIndexer::new(); + let symbols = indexer.index_source_summary(path, content).ok()?; + let items = symbols + .iter() + .map(symbol_to_outline_item) + .collect::>(); + (!items.is_empty()).then_some(items) +} + +fn symbol_to_outline_item(symbol: &IndexedSymbol) -> OutlineItem { + OutlineItem { + line: symbol.start_line, + depth: symbol.depth, + kind: symbol_kind_to_outline_kind(symbol.kind), + name: symbol.name.clone(), + } +} + +const fn symbol_kind_to_outline_kind(kind: SymbolKind) -> ItemKind { + match kind { + SymbolKind::Module => ItemKind::Mod, + SymbolKind::Namespace => ItemKind::Namespace, + SymbolKind::Class => ItemKind::Class, + SymbolKind::Struct => ItemKind::Struct, + SymbolKind::Enum => ItemKind::Enum, + SymbolKind::Interface => ItemKind::Interface, + SymbolKind::Record => ItemKind::Record, + SymbolKind::Trait => ItemKind::Trait, + SymbolKind::Impl => ItemKind::Impl, + SymbolKind::TypeAlias => ItemKind::Type, + SymbolKind::Function => ItemKind::Fn, + SymbolKind::Method | SymbolKind::Constructor => ItemKind::Method, + SymbolKind::Const => ItemKind::Const, + SymbolKind::Static => ItemKind::Static, + } +} + +fn extract_rust_items(content: &str) -> Vec { + extract_code_items(content, classify_rust_line) +} + +fn extract_csharp_items(content: &str) -> Vec { + extract_code_items(content, classify_csharp_line) +} + +fn extract_powershell_items(content: &str) -> Vec { + let mut items = Vec::new(); + let mut depth = 0_usize; + + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim_start(); + if let Some((kind, name)) = classify_powershell_line(trimmed) { + items.push(OutlineItem { + line: index + 1, + depth, + kind, + name, + }); + } else if depth == 0 { + if let Some(name) = take_powershell_variable_name(trimmed) { + items.push(OutlineItem { + line: index + 1, + depth, + kind: ItemKind::Var, + name, + }); + } else if let Some(name) = take_powershell_call_name(trimmed) { + items.push(OutlineItem { + line: index + 1, + depth, + kind: ItemKind::Call, + name, + }); + } + } + + depth += line.bytes().filter(|byte| *byte == b'{').count(); + depth = depth.saturating_sub(line.bytes().filter(|byte| *byte == b'}').count()); + } + + items +} + +fn extract_markdown_items(content: &str) -> Vec { + content + .lines() + .enumerate() + .filter_map(|(index, line)| { + parse_markdown_heading(line).map(|(depth, name)| OutlineItem { + line: index + 1, + depth, + kind: ItemKind::Heading, + name, + }) + }) + .collect() +} + +fn extract_code_items(content: &str, mut classifier: F) -> Vec +where + F: FnMut(&str) -> Option<(ItemKind, String)>, +{ + let mut items = Vec::new(); + let mut depth = 0_usize; + + for (index, line) in content.lines().enumerate() { + if let Some((kind, name)) = classifier(line) { + items.push(OutlineItem { + line: index + 1, + depth, + kind, + name, + }); + } + + depth += line.bytes().filter(|byte| *byte == b'{').count(); + depth = depth.saturating_sub(line.bytes().filter(|byte| *byte == b'}').count()); + } + + items +} + +fn extract_json_items(content: &str) -> Vec { + let mut items = Vec::new(); + let mut depth = 0_usize; + + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with('}') { + depth = depth.saturating_sub(1); + } + + if let Some(name) = parse_double_quoted_key(trimmed) { + items.push(OutlineItem { + line: index + 1, + depth: depth.saturating_sub(1), + kind: ItemKind::Key, + name, + }); + } + + depth += line.bytes().filter(|byte| *byte == b'{').count(); + depth = depth.saturating_sub(line.bytes().filter(|byte| *byte == b'}').count()); + } + + items +} + +fn extract_toml_items(content: &str) -> Vec { + let mut items = Vec::new(); + let mut table_depth = 0_usize; + + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some(table) = trimmed + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + let normalized = table.trim_matches('[').trim_matches(']'); + table_depth = normalized.split('.').count().saturating_sub(1); + items.push(OutlineItem { + line: index + 1, + depth: table_depth, + kind: ItemKind::Table, + name: normalized.to_string(), + }); + continue; + } + if let Some((name, _)) = trimmed.split_once('=') { + items.push(OutlineItem { + line: index + 1, + depth: table_depth + 1, + kind: ItemKind::Key, + name: name.trim().to_string(), + }); + } + } + + items +} + +fn extract_yaml_items(content: &str) -> Vec { + content + .lines() + .enumerate() + .filter_map(|(index, line)| { + parse_yaml_key(line).map(|(depth, name)| OutlineItem { + line: index + 1, + depth, + kind: ItemKind::Key, + name, + }) + }) + .collect::>() +} + +fn classify_rust_line(line: &str) -> Option<(ItemKind, String)> { + let trimmed = line.trim_start(); + let visible = strip_rust_visibility(trimmed); + for (kind, keyword) in [ + (ItemKind::Mod, "mod"), + (ItemKind::Struct, "struct"), + (ItemKind::Enum, "enum"), + (ItemKind::Trait, "trait"), + (ItemKind::Const, "const"), + (ItemKind::Static, "static"), + (ItemKind::Type, "type"), + ] { + if let Some(name) = take_identifier_after_keyword(visible, keyword) { + return Some((kind, name.to_string())); + } + } + + if let Some(name) = take_rust_impl_name(trimmed) { + return Some((ItemKind::Impl, name)); + } + + let mut cursor = visible; + loop { + if let Some(rest) = strip_keyword(cursor, "async") { + cursor = rest; + continue; + } + if let Some(rest) = strip_keyword(cursor, "unsafe") { + cursor = rest; + continue; + } + break; + } + + take_identifier_after_keyword(cursor, "fn").map(|name| (ItemKind::Fn, name.to_string())) +} + +fn classify_csharp_line(line: &str) -> Option<(ItemKind, String)> { + let trimmed = line.trim_start(); + if let Some(name) = take_csharp_path_after_keyword(trimmed, "namespace") { + return Some((ItemKind::Namespace, name.to_string())); + } + + let members = strip_csharp_modifiers(trimmed); + for (kind, keyword) in [ + (ItemKind::Class, "class"), + (ItemKind::Struct, "struct"), + (ItemKind::Enum, "enum"), + (ItemKind::Interface, "interface"), + (ItemKind::Record, "record"), + ] { + if let Some(name) = take_identifier_after_keyword(members, keyword) { + return Some((kind, name.to_string())); + } + } + + take_csharp_method_name(members).map(|name| (ItemKind::Method, name)) +} + +fn classify_powershell_line(line: &str) -> Option<(ItemKind, String)> { + if let Some(name) = take_powershell_name_after_keyword(line, "class") { + return Some((ItemKind::Class, name.to_string())); + } + if let Some(name) = take_powershell_name_after_keyword(line, "enum") { + return Some((ItemKind::Enum, name.to_string())); + } + for keyword in ["function", "filter", "workflow"] { + if let Some(name) = take_powershell_name_after_keyword(line, keyword) { + return Some((ItemKind::Fn, name.to_string())); + } + } + None +} + +fn strip_rust_visibility(input: &str) -> &str { + let trimmed = input.trim_start(); + if let Some(rest) = trimmed.strip_prefix("pub ") { + return rest.trim_start(); + } + if let Some(rest) = trimmed.strip_prefix("pub(") { + if let Some(index) = rest.find(')') { + return rest[index + 1..].trim_start(); + } + } + trimmed +} + +fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> { + input.strip_prefix(keyword).and_then(|rest| { + rest.chars() + .next() + .is_some_and(char::is_whitespace) + .then_some(rest.trim_start()) + }) +} + +fn take_identifier_after_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> { + let rest = strip_keyword(input, keyword)?; + take_identifier(rest) +} + +fn take_identifier(input: &str) -> Option<&str> { + let mut end = 0_usize; + for (index, character) in input.char_indices() { + let valid = if index == 0 { + character == '_' || character.is_ascii_alphabetic() + } else { + character == '_' || character.is_ascii_alphanumeric() + }; + if !valid { + break; + } + end = index + character.len_utf8(); + } + (end > 0).then_some(&input[..end]) +} + +fn take_powershell_name_after_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> { + let rest = strip_keyword(input, keyword)?; + let mut end = 0_usize; + for (index, character) in rest.char_indices() { + let valid = if index == 0 { + character == '_' + || character.is_ascii_alphabetic() + || character == '[' + || character == ':' + } else { + character == '_' + || character == '-' + || character == ':' + || character == '.' + || character == '[' + || character == ']' + || character.is_ascii_alphanumeric() + }; + if !valid { + break; + } + end = index + character.len_utf8(); + } + (end > 0).then_some(&rest[..end]) +} + +fn take_powershell_variable_name(line: &str) -> Option { + let trimmed = line.trim_start(); + if !trimmed.starts_with('$') { + return None; + } + + let mut end = 1_usize; + for (index, character) in trimmed.char_indices().skip(1) { + let valid = character == '_' || character == ':' || character.is_ascii_alphanumeric(); + if !valid { + break; + } + end = index + character.len_utf8(); + } + + let name = &trimmed[..end]; + trimmed[end..] + .trim_start() + .starts_with('=') + .then(|| name.to_string()) +} + +fn take_powershell_call_name(line: &str) -> Option { + let trimmed = line.trim_start(); + if trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed.starts_with('{') + || trimmed.starts_with('}') + || trimmed.starts_with('$') + { + return None; + } + + let trimmed = trimmed.strip_prefix("& ").unwrap_or(trimmed); + if [ + "if", "elseif", "else", "foreach", "for", "while", "switch", "return", "throw", "try", + "catch", "finally", "param", + ] + .iter() + .any(|keyword| { + trimmed == *keyword + || trimmed + .strip_prefix(keyword) + .is_some_and(|rest| rest.starts_with([' ', '('])) + }) { + return None; + } + + let end = trimmed + .find([' ', '\t', '|', '(', '{']) + .unwrap_or(trimmed.len()); + let candidate = trimmed[..end].trim(); + if candidate.is_empty() + || !candidate.chars().next().is_some_and(|character| { + character == '$' + || character == '.' + || character == '_' + || character == '/' + || character.is_ascii_alphabetic() + }) + { + None + } else { + Some(candidate.to_string()) + } +} + +fn take_rust_impl_name(input: &str) -> Option { + let rest = strip_keyword(input.trim_start(), "impl")?; + let rest = skip_rust_generics(rest); + take_rust_path(rest).map(ToOwned::to_owned) +} + +fn skip_rust_generics(input: &str) -> &str { + let trimmed = input.trim_start(); + if !trimmed.starts_with('<') { + return trimmed; + } + + let mut depth = 0_usize; + for (index, character) in trimmed.char_indices() { + match character { + '<' => depth += 1, + '>' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return trimmed[index + character.len_utf8()..].trim_start(); + } + } + _ => {} + } + } + + trimmed +} + +fn take_rust_path(input: &str) -> Option<&str> { + let mut end = 0_usize; + for (index, character) in input.char_indices() { + let valid = character == '_' + || character.is_ascii_alphanumeric() + || matches!(character, ':' | '<' | '>'); + if !valid { + break; + } + end = index + character.len_utf8(); + } + (end > 0).then_some(&input[..end]) +} + +fn strip_csharp_modifiers(mut input: &str) -> &str { + loop { + let trimmed = input.trim_start(); + let mut matched = false; + for keyword in [ + "public", + "private", + "protected", + "internal", + "static", + "sealed", + "abstract", + "partial", + "readonly", + "virtual", + "override", + "async", + ] { + if let Some(rest) = strip_keyword(trimmed, keyword) { + input = rest; + matched = true; + break; + } + } + if !matched { + return trimmed; + } + } +} + +fn take_csharp_path_after_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> { + let rest = strip_keyword(input, keyword)?; + let mut end = 0_usize; + for (index, character) in rest.char_indices() { + let valid = character == '_' || character == '.' || character.is_ascii_alphanumeric(); + if !valid { + break; + } + end = index + character.len_utf8(); + } + (end > 0).then_some(&rest[..end]) +} + +fn take_csharp_method_name(input: &str) -> Option { + let open = input.find('(')?; + let prefix = input[..open].trim_end(); + if prefix.is_empty() || prefix.contains('=') || prefix.contains("&&") || prefix.contains("||") { + return None; + } + + let words = prefix.split_whitespace().collect::>(); + if words.len() < 2 { + return None; + } + if matches!( + words[0], + "if" | "else" + | "for" + | "foreach" + | "while" + | "switch" + | "catch" + | "using" + | "return" + | "throw" + | "new" + ) { + return None; + } + + let candidate = *words.last()?; + take_identifier(candidate).map(ToOwned::to_owned) +} + +fn parse_double_quoted_key(line: &str) -> Option { + if !line.starts_with('"') { + return None; + } + + let end = find_closing_double_quote(line)?; + let literal = &line[..=end]; + let rest = line[end + 1..].trim_start(); + rest.starts_with(':') + .then(|| serde_json::from_str::(literal).ok()) + .flatten() +} + +fn find_closing_double_quote(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut escaped = false; + for (index, byte) in bytes.iter().enumerate().skip(1) { + if escaped { + escaped = false; + continue; + } + match *byte { + b'\\' => escaped = true, + b'"' => return Some(index), + _ => {} + } + } + None +} + +fn parse_yaml_key(line: &str) -> Option<(usize, String)> { + let indent = line + .chars() + .take_while(|character| *character == ' ') + .count(); + let trimmed = line[indent..].trim_end(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("- ") { + return None; + } + if let Some(name) = parse_double_quoted_key(trimmed) { + return Some((indent / 2, name)); + } + + let (name, _) = trimmed.split_once(':')?; + let normalized = name.trim(); + if normalized.is_empty() || normalized != name || normalized.contains(char::is_whitespace) { + return None; + } + Some((indent / 2, normalized.to_string())) +} + +fn parse_markdown_heading(line: &str) -> Option<(usize, String)> { + let trimmed = line.trim_start(); + let depth = trimmed + .chars() + .take_while(|character| *character == '#') + .count(); + if depth == 0 || depth > 6 { + return None; + } + + let rest = trimmed[depth..].trim_start(); + if rest.is_empty() { + return None; + } + + Some((depth - 1, rest.to_string())) +} + +fn render_reports(reports: &[OutlineReport]) -> String { + let mut rendered = String::new(); + + for (index, report) in reports.iter().enumerate() { + if index > 0 { + rendered.push('\n'); + } + writeln!( + rendered, + "path={} supported={} family={} items={} omitted_items={} error={}", + report.path, + report.supported, + report.family.map_or("-", family_label), + report.items.len(), + report.omitted_items, + report.error.as_deref().unwrap_or("-") + ) + .expect("writing to a String cannot fail"); + if report.supported { + for item in &report.items { + writeln!( + rendered, + "{} depth={} {} {}", + item.line, + item.depth, + item_kind_label(item.kind), + item.name + ) + .expect("writing to a String cannot fail"); + } + } + } + + rendered +} + +const fn family_label(family: FileFamily) -> &'static str { + match family { + FileFamily::Rust => "rust", + FileFamily::Csharp => "csharp", + FileFamily::Powershell => "powershell", + FileFamily::Python => "python", + FileFamily::Go => "go", + FileFamily::Java => "java", + FileFamily::Javascript => "javascript", + FileFamily::Typescript => "typescript", + FileFamily::Markdown => "markdown", + FileFamily::Json => "json", + FileFamily::Toml => "toml", + FileFamily::Yaml => "yaml", + } +} + +const fn item_kind_label(kind: ItemKind) -> &'static str { + match kind { + ItemKind::Mod => "mod", + ItemKind::Struct => "struct", + ItemKind::Enum => "enum", + ItemKind::Trait => "trait", + ItemKind::Impl => "impl", + ItemKind::Fn => "fn", + ItemKind::Const => "const", + ItemKind::Static => "static", + ItemKind::Type => "type", + ItemKind::Namespace => "namespace", + ItemKind::Class => "class", + ItemKind::Interface => "interface", + ItemKind::Record => "record", + ItemKind::Method => "method", + ItemKind::Var => "var", + ItemKind::Call => "call", + ItemKind::Heading => "heading", + ItemKind::Table => "table", + ItemKind::Key => "key", + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn java_ast_outline_extracts_nested_symbols() { + let temp = tempdir().expect("tempdir"); + let java_path = temp.path().join("Sample.java"); + fs::write( + &java_path, + "package demo; +public class Sample { + public Sample() {} + void run() {} + record Result(String name) {} +} +", + ) + .expect("java fixture"); + + let report = inspect_path(&java_path, None, None, OutlineKind::Code) + .expect("java report") + .expect("supported java"); + assert_eq!(report.family, Some(FileFamily::Java)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Class && item.name == "Sample") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Method && item.name == "run") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Record && item.name == "Result") + ); + } + + #[test] + fn go_ast_outline_extracts_types_functions_and_methods() { + let temp = tempdir().expect("tempdir"); + let go_path = temp.path().join("sample.go"); + fs::write( + &go_path, + "package demo\ntype Service struct{}\nfunc NewService() *Service { return &Service{} }\nfunc (s *Service) Run() error { return nil }\n", + ) + .expect("go fixture"); + + let report = inspect_path(&go_path, None, None, OutlineKind::Code) + .expect("go report") + .expect("supported go"); + assert_eq!(report.family, Some(FileFamily::Go)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Struct && item.name == "Service") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Fn && item.name == "NewService") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Method && item.name == "Run") + ); + } + + #[test] + fn python_ast_outline_extracts_classes_functions_and_methods() { + let temp = tempdir().expect("tempdir"); + let python_path = temp.path().join("sample.py"); + fs::write( + &python_path, + "class Worker:\n def run(self):\n return 'ok'\n\ndef build_worker():\n return Worker()\n", + ) + .expect("python fixture"); + + let report = inspect_path(&python_path, None, None, OutlineKind::Code) + .expect("python report") + .expect("supported python"); + assert_eq!(report.family, Some(FileFamily::Python)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Class && item.name == "Worker") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Method && item.name == "run" && item.depth == 1) + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Fn && item.name == "build_worker") + ); + } + + #[test] + fn typescript_ast_outline_extracts_interfaces_types_classes_and_functions() { + let temp = tempdir().expect("tempdir"); + let typescript_path = temp.path().join("sample.ts"); + fs::write( + &typescript_path, + "interface Runner { run(): string }\ntype Options = { name: string }\nclass Worker { run(): string { return 'ok' } }\nexport const build = () => new Worker();\n", + ) + .expect("typescript fixture"); + + let report = inspect_path(&typescript_path, None, None, OutlineKind::Code) + .expect("typescript report") + .expect("supported typescript"); + assert_eq!(report.family, Some(FileFamily::Typescript)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Interface && item.name == "Runner") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Type && item.name == "Options") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Fn && item.name == "build") + ); + } + + #[test] + fn rust_ast_outline_extracts_impl_methods() { + let temp = tempdir().expect("tempdir"); + let rust_path = temp.path().join("client.rs"); + fs::write( + &rust_path, + "pub struct Client; + +impl Client { + pub const LIMIT: usize = 16; + + pub fn connect(&self) {} +} +", + ) + .expect("rust fixture"); + + let report = inspect_path(&rust_path, None, None, OutlineKind::Code) + .expect("rust report") + .expect("supported rust"); + assert_eq!(report.family, Some(FileFamily::Rust)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Impl && item.name == "Client") + ); + assert!(report.items.iter().any(|item| item.kind == ItemKind::Method + && item.name == "connect" + && item.depth == 1)); + } + + #[test] + fn csharp_ast_outline_extracts_file_scoped_namespace_and_constructors() { + let temp = tempdir().expect("tempdir"); + let csharp_path = temp.path().join("Widget.cs"); + fs::write( + &csharp_path, + "namespace Demo.Tools; + +public class Widget +{ + public Widget() {} + + public void Run() {} +} +", + ) + .expect("csharp fixture"); + + let report = inspect_path(&csharp_path, None, None, OutlineKind::Code) + .expect("csharp report") + .expect("supported csharp"); + assert_eq!(report.family, Some(FileFamily::Csharp)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Namespace && item.name == "Demo.Tools") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Class && item.name == "Widget") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Method && item.name == "Widget") + ); + } + + #[test] + fn powershell_ast_outline_extracts_class_methods() { + let temp = tempdir().expect("tempdir"); + let powershell_path = temp.path().join("runner.ps1"); + fs::write( + &powershell_path, + "class Runner { + [void] Run() {} +} + +function Invoke-Runner {} +", + ) + .expect("powershell fixture"); + + let report = inspect_path(&powershell_path, None, None, OutlineKind::Code) + .expect("powershell report") + .expect("supported powershell"); + assert_eq!(report.family, Some(FileFamily::Powershell)); + assert!(report.supported); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Class && item.name == "Runner") + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Method && item.name == "Run" && item.depth == 1) + ); + assert!( + report + .items + .iter() + .any(|item| item.kind == ItemKind::Fn && item.name == "Invoke-Runner") + ); + } + + #[test] + fn rust_and_csharp_extractors_find_structural_items() { + let rust = + extract_rust_items("pub mod demo {\n pub struct Client {}\n fn run() {}\n}\n"); + assert!( + rust.iter() + .any(|item| item.kind == ItemKind::Mod && item.name == "demo") + ); + assert!( + rust.iter() + .any(|item| item.kind == ItemKind::Struct && item.depth == 1) + ); + assert!( + rust.iter() + .any(|item| item.kind == ItemKind::Fn && item.name == "run") + ); + + let csharp = extract_csharp_items( + "namespace Demo {\n public class PlayerController {\n private int ComputeScore(int baseScore) {\n return baseScore;\n }\n }\n}\n", + ); + assert!(csharp.iter().any(|item| item.kind == ItemKind::Namespace)); + assert!(csharp.iter().any(|item| item.kind == ItemKind::Class)); + assert!( + csharp + .iter() + .any(|item| item.kind == ItemKind::Method && item.name == "ComputeScore") + ); + } + + #[test] + fn csharp_method_heuristics_skip_control_flow_and_expression_calls() { + let csharp = extract_csharp_items( + "else if (obj is Component)\n&& error.Message.IndexOf(\"pipe\", StringComparison.OrdinalIgnoreCase) >= 0;\nprivate static bool IsBrokenPipe(IOException error)\n", + ); + assert!( + csharp + .iter() + .any(|item| { item.kind == ItemKind::Method && item.name == "IsBrokenPipe" }) + ); + assert!(!csharp.iter().any(|item| item.name == "if")); + assert!(!csharp.iter().any(|item| item.name == "error")); + } + + #[test] + fn code_outline_depth_does_not_drift_after_closing_braces() { + let csharp = extract_csharp_items( + "namespace Demo\n{\n class Sample\n {\n void First()\n {\n }\n\n void Second()\n {\n }\n }\n}\n", + ); + let methods = csharp + .into_iter() + .filter(|item| item.kind == ItemKind::Method) + .collect::>(); + assert_eq!(methods.len(), 2); + assert_eq!(methods[0].depth, 2); + assert_eq!(methods[1].depth, 2); + } + + #[test] + fn config_extractors_honor_depth_and_labels() { + let json = extract_json_items("{\n \"app\": {\n \"logging\": {}\n }\n}\n"); + assert_eq!(json[0].depth, 0); + assert_eq!(json[1].depth, 1); + + let toml = + extract_toml_items("[workspace]\nroot = \".\"\n[plugins.metrics]\nenabled = true\n"); + assert!( + toml.iter() + .any(|item| item.kind == ItemKind::Table && item.depth == 0) + ); + assert!( + toml.iter() + .any(|item| item.kind == ItemKind::Table && item.depth == 1) + ); + assert!( + toml.iter() + .any(|item| item.kind == ItemKind::Key && item.depth == 2) + ); + + let yaml = extract_yaml_items("app:\n logging:\n level: debug\n"); + assert_eq!(yaml[0].depth, 0); + assert_eq!(yaml[1].depth, 1); + assert_eq!(yaml[2].depth, 2); + } + + #[test] + fn path_parsing_and_report_rendering_cover_jsonl_and_text() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("demo.rs"); + let second = temp.path().join("demo.cs"); + fs::write(&first, "fn run() {}\n").expect("first"); + fs::write(&second, "class Demo {}\n").expect("second"); + let paths = parse_paths_from_string( + &format!( + "{{\"path\":{}}}\n{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("json path"), + serde_json::to_string(&second.display().to_string()).expect("json path") + ), + InputFormat::Jsonl, + ) + .expect("jsonl paths"); + assert_eq!(paths.len(), 2); + assert_eq!(detect_family(Path::new("demo.rs")), Some(FileFamily::Rust)); + assert!(family_matches_kind(FileFamily::Yaml, OutlineKind::Config)); + + let rendered = render_reports(&[OutlineReport { + path: "demo.rs".to_string(), + family: Some(FileFamily::Rust), + supported: true, + items: vec![OutlineItem { + line: 10, + depth: 0, + kind: ItemKind::Fn, + name: "run".to_string(), + }], + omitted_items: 0, + error: None, + }]); + assert!(rendered.contains("family=rust")); + assert!(rendered.contains("10 depth=0 fn run")); + } + + #[test] + fn path_parsing_reports_invalid_auto_and_jsonl_inputs() { + let auto_paths = parse_paths_from_string("{\"name\":\"demo\"}\n", InputFormat::Auto) + .expect("auto objects without path should return empty"); + assert!(auto_paths.is_empty()); + + let jsonl_error = + parse_paths_from_string("not-json\n", InputFormat::Jsonl).expect_err("bad jsonl"); + assert!(matches!( + jsonl_error, + CliError::Usage(message) + if message.contains("stdin JSONL path line 1 is not valid JSON") + )); + } + + #[test] + fn inspect_path_and_run_cover_filtering_and_unsupported_files() { + let temp = tempdir().expect("tempdir"); + let rust_path = temp.path().join("sample.rs"); + let text_path = temp.path().join("sample.txt"); + fs::write(&rust_path, "pub fn run() {}\n").expect("rust fixture"); + fs::write(&text_path, "plain text\n").expect("text fixture"); + + assert!( + inspect_path(&rust_path, Some(0), None, OutlineKind::Code) + .expect("rust report") + .is_some() + ); + assert!( + inspect_path(&rust_path, None, None, OutlineKind::Config) + .expect("kind filter") + .is_none() + ); + + let unsupported = inspect_path(&text_path, None, None, OutlineKind::All) + .expect("unsupported report") + .expect("unsupported entry"); + assert!(!unsupported.supported); + assert_eq!(unsupported.family, None); + assert!( + unsupported + .error + .as_deref() + .is_some_and(|message| message.contains("try codeshape")) + ); + + let success = run(&Cli { + common: common_args(true, InputFormat::Auto), + depth: Some(0), + limit: None, + kind: OutlineKind::Code, + paths: vec![rust_path.clone()], + }) + .expect("json run"); + assert_eq!(success, ExitCode::Success); + + let no_results = run(&Cli { + common: common_args(false, InputFormat::Lines), + depth: None, + limit: None, + kind: OutlineKind::Config, + paths: vec![rust_path], + }) + .expect("filtered run"); + assert_eq!(no_results, ExitCode::NoResults); + } + + #[test] + fn code_and_toml_extractors_cover_more_shapes() { + let rust = extract_rust_items( + "pub const LIMIT: usize = 1;\npub static NAME: &str = \"demo\";\npub type Alias = usize;\nimpl Client {}\n", + ); + assert!(rust.iter().any(|item| item.kind == ItemKind::Const)); + assert!(rust.iter().any(|item| item.kind == ItemKind::Static)); + assert!(rust.iter().any(|item| item.kind == ItemKind::Type)); + assert!(rust.iter().any(|item| item.kind == ItemKind::Impl)); + + let csharp = extract_csharp_items( + "namespace Demo;\npublic interface IRunner {}\npublic record RunnerRecord();\npublic enum Mode { Fast }\n", + ); + assert!(csharp.iter().any(|item| item.kind == ItemKind::Interface)); + assert!(csharp.iter().any(|item| item.kind == ItemKind::Record)); + assert!(csharp.iter().any(|item| item.kind == ItemKind::Enum)); + + let powershell = extract_powershell_items( + "function Get-Thing {}\nclass Demo {}\nenum Mode { Fast }\nworkflow Invoke-Work {}\n$path = Join-Path $root 'out'\nNew-Item -Path $path\n)\n", + ); + assert!(powershell.iter().any(|item| item.kind == ItemKind::Fn)); + assert!(powershell.iter().any(|item| item.kind == ItemKind::Class)); + assert!(powershell.iter().any(|item| item.kind == ItemKind::Enum)); + assert!(powershell.iter().any(|item| item.kind == ItemKind::Var)); + assert!( + powershell + .iter() + .any(|item| item.kind == ItemKind::Call && item.name == "New-Item") + ); + assert!(!powershell.iter().any(|item| item.name == ")")); + + let toml = extract_toml_items( + "# comment\n[workspace]\nroot = \".\"\n\n[plugins.metrics]\nenabled = true\n", + ); + assert_eq!(toml.len(), 4); + assert!(toml.iter().any(|item| item.name == "workspace")); + assert!(toml.iter().any(|item| item.name == "enabled")); + } + + #[test] + fn config_extractors_handle_escaped_and_quoted_keys() { + let json = extract_json_items("{\n \"say\\\"hi\": 1\n}\n"); + assert_eq!( + json, + vec![OutlineItem { + line: 2, + depth: 0, + kind: ItemKind::Key, + name: "say\"hi".to_string(), + }] + ); + + let yaml = extract_yaml_items("\"service.name\":\n child-key: value\n"); + assert_eq!(yaml[0].name, "service.name"); + assert_eq!(yaml[1].name, "child-key"); + + let markdown = extract_markdown_items("# Title\n## Section\n"); + assert_eq!(markdown[0].kind, ItemKind::Heading); + assert_eq!(markdown[0].name, "Title"); + assert_eq!(markdown[1].depth, 1); + } +} diff --git a/crates/outline/src/main.rs b/crates/outline/src/main.rs new file mode 100644 index 0000000..5bc2995 --- /dev/null +++ b/crates/outline/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `outline`. + +fn main() { + std::process::exit(outline::main_entry()); +} diff --git a/crates/outline/tests/outline_cli.rs b/crates/outline/tests/outline_cli.rs new file mode 100644 index 0000000..a3fdd53 --- /dev/null +++ b/crates/outline/tests/outline_cli.rs @@ -0,0 +1,189 @@ +//! Integration tests for the `outline` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("outline").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +#[test] +fn outlines_rust_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.rs")) + .assert() + .success() + .stdout(predicate::str::contains("family=rust")) + .stdout(predicate::str::contains("18 depth=0 fn run")) + .stdout(predicate::str::contains("2 depth=1 struct Client")); +} + +#[test] +fn outlines_csharp_source_as_json() { + let mut command = cargo_command(); + command + .arg("--json") + .arg(fixture("reading/sample.cs")) + .assert() + .success() + .stdout(predicate::str::contains("\"family\":\"csharp\"")) + .stdout(predicate::str::contains("\"kind\":\"class\"")) + .stdout(predicate::str::contains("\"name\":\"PlayerController\"")) + .stdout(predicate::str::contains("\"name\":\"ComputeScore\"")); +} + +#[test] +fn outlines_java_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.java")) + .assert() + .success() + .stdout(predicate::str::contains("family=java")) + .stdout(predicate::str::contains("5 depth=0 class OutlineDemo")) + .stdout(predicate::str::contains("11 depth=1 method run")) + .stdout(predicate::str::contains("20 depth=1 record Result")) + .stdout(predicate::str::contains("22 depth=1 interface Nested")); +} + +#[test] +fn outlines_go_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.go")) + .assert() + .success() + .stdout(predicate::str::contains("family=go")) + .stdout(predicate::str::contains("9 depth=0 interface Runner")) + .stdout(predicate::str::contains("13 depth=0 struct Service")) + .stdout(predicate::str::contains("17 depth=0 fn NewService")) + .stdout(predicate::str::contains("21 depth=1 method Run")); +} + +#[test] +fn outlines_python_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.py")) + .assert() + .success() + .stdout(predicate::str::contains("family=python")) + .stdout(predicate::str::contains("1 depth=0 class Worker")) + .stdout(predicate::str::contains("5 depth=1 method run")) + .stdout(predicate::str::contains("12 depth=0 fn build_worker")); +} + +#[test] +fn outlines_javascript_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.js")) + .assert() + .success() + .stdout(predicate::str::contains("family=javascript")) + .stdout(predicate::str::contains("1 depth=0 class Widget")) + .stdout(predicate::str::contains("6 depth=1 method render")) + .stdout(predicate::str::contains("15 depth=0 fn loadWidget")); +} + +#[test] +fn outlines_typescript_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.ts")) + .assert() + .success() + .stdout(predicate::str::contains("family=typescript")) + .stdout(predicate::str::contains("1 depth=0 interface Runner")) + .stdout(predicate::str::contains("5 depth=0 type WorkerOptions")) + .stdout(predicate::str::contains("12 depth=1 method run")) + .stdout(predicate::str::contains("21 depth=0 fn defaultWorker")); +} + +#[test] +fn outlines_powershell_source_in_text_mode() { + let mut command = cargo_command(); + command + .arg(fixture("reading/sample.ps1")) + .assert() + .success() + .stdout(predicate::str::contains("family=powershell")) + .stdout(predicate::str::contains("fn Get-Widget")) + .stdout(predicate::str::contains("class WidgetBuilder")) + .stdout(predicate::str::contains("enum WidgetMode")); +} + +#[test] +fn outlines_config_files_with_depth_limit() { + let mut command = cargo_command(); + command + .arg("--depth") + .arg("1") + .arg(fixture("reading/config.toml")) + .assert() + .success() + .stdout(predicate::str::contains("family=toml")) + .stdout(predicate::str::contains("1 depth=0 table workspace")) + .stdout(predicate::str::contains("7 depth=1 table plugins.metrics")) + .stdout(predicate::str::contains("enabled").not()); +} + +#[test] +fn supports_powershell_path_pipeline() { + let binary = assert_cmd::cargo::cargo_bin("outline"); + let input = fixture("reading/config.yaml"); + let script = format!( + "{} | & {} --json", + ps_quote(input.display()), + ps_quote(binary.display()) + ); + + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("\"family\":\"yaml\"")) + .stdout(predicate::str::contains("\"name\":\"logging\"")); +} + +#[test] +fn help_includes_outline_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--depth")) + .stdout(predicate::str::contains("Supported code")) + .stdout(predicate::str::contains("sample.go")) + .stdout(predicate::str::contains("sample.java")) + .stdout(predicate::str::contains("sample.ts")) + .stdout(predicate::str::contains( + "outline .\\fixtures\\reading\\sample.rs", + )) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} diff --git a/crates/pathshadow/Cargo.toml b/crates/pathshadow/Cargo.toml new file mode 100644 index 0000000..9de0666 --- /dev/null +++ b/crates/pathshadow/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "pathshadow" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect PATH resolution order and shadowed commands." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/pathshadow/src/lib.rs b/crates/pathshadow/src/lib.rs new file mode 100644 index 0000000..e821730 --- /dev/null +++ b/crates/pathshadow/src/lib.rs @@ -0,0 +1,1352 @@ +//! The `pathshadow` command inspects command resolution order across `PATH`. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + parse_input_format, print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +/// Shell resolution mode used to interpret command lookup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShellMode { + /// Use raw `PATH` plus `PATHEXT` lookup only. + Path, + /// Use `cmd.exe` semantics, which match `PATH` plus `PATHEXT`. + Cmd, + /// Ask `PowerShell` which command would run. + Powershell, +} + +/// Labels the origin of a command match. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + /// Match resolved from Scoop shims. + ScoopShim, + /// Match resolved from Cargo's binary directory. + CargoBin, + /// Match resolved from Windows system directories. + System, + /// Match resolved from an unclassified directory. + Other, +} + +/// Explains why a command match won or lost the resolution race. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShadowReason { + /// This entry is the first matching command on `PATH`. + PathWinner, + /// This entry lost because an earlier `PATH` directory already matched. + ShadowedByEarlierPath, +} + +/// Describes a single command candidate discovered on `PATH`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CommandMatch { + /// Command name without extension. + pub command: String, + /// Resolved path to the executable or script. + pub path: PathBuf, + /// Classified source directory for the match. + pub source_kind: SourceKind, + /// Whether this entry wins in normal shell resolution order. + pub is_primary: bool, + /// Zero-based index of the containing directory in the active `PATH`. + pub path_rank: usize, + /// The winning executable path for this command group. + pub winner_path: PathBuf, + /// Stable explanation for why this entry won or lost. + pub shadow_reason: ShadowReason, + /// Optional shell mode used for winner annotation. + pub shell_mode: Option, + /// Shell winner name when a shell-specific query ran. + pub shell_winner_name: Option, + /// Shell winner kind such as `application` or `external_script`. + pub shell_winner_type: Option, + /// Shell winner path when one is available. + pub shell_winner_path: Option, + /// Shell winner target such as an alias definition, cmdlet module, or path. + pub shell_winner_target: Option, + /// Whether this exact entry is the shell winner. + pub shell_is_primary: Option, +} + +/// CLI arguments for the `pathshadow` binary. +#[derive(Debug, Clone)] +pub struct Cli { + /// Shared output and stdin policy flags. + pub common: CommonArgs, + /// Scan the full `PATH` for duplicate commands instead of named targets. + pub all_duplicates: bool, + /// Collapse same-directory variant noise such as `.cmd` and `.exe` pairs. + pub high_signal: bool, + /// Emit grouped per-command summaries instead of raw candidate rows. + pub summary: bool, + /// Shell model to use for winner annotations. + pub shell_mode: ShellMode, + /// Explicit command names to inspect. + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CommandSummary { + command: String, + total_matches: usize, + shadowed_count: usize, + winner_path: PathBuf, + top_shadowed_path: Option, + winner_source_kind: SourceKind, + winner_path_rank: usize, + shell_mode: Option, + shell_winner_name: Option, + shell_winner_type: Option, + shell_winner_path: Option, + shell_winner_target: Option, + shell_disagrees: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShellResolution { + name: String, + kind: String, + path: Option, + target: Option, +} + +const HELP: &str = "\ +Inspect PATH resolution order and shadowed commands. + +Usage: + pathshadow [OPTIONS] [COMMAND...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --all-duplicates Scan the full PATH for duplicate commands + --high-signal Collapse same-directory extension variants into one winner + --summary Emit one grouped row per command + --shell Resolution model: path, cmd, powershell + -h, --help Show this help text + -V, --version Show the command version + +Examples: + pathshadow rg + pathshadow python npm --shell powershell + 'cargo' | pathshadow --json | ConvertFrom-Json + pathshadow --all-duplicates --high-signal --summary --json | ConvertFrom-Json + +JSON fields: + raw rows: command, path, source_kind, path_rank, winner_path, shadow_reason, shell_winner_* + summary: command, total_matches, shadowed_count, winner_path, shell_winner_target, shell_disagrees +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("pathshadow {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + all_duplicates: false, + high_signal: false, + summary: false, + shell_mode: ShellMode::Path, + commands: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("all-duplicates") => cli.all_duplicates = true, + Long("high-signal") => cli.high_signal = true, + Long("summary") => cli.summary = true, + Long("shell") => { + cli.shell_mode = parse_shell_mode(&parser_value_string(&mut parser, "--shell")?)?; + } + ArgValue(value) => cli.commands.push(os_string_to_string(value, "command")?), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_string_to_string(value, flag) +} + +fn os_string_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_shell_mode(value: &str) -> Result { + match value { + "path" => Ok(ShellMode::Path), + "cmd" => Ok(ShellMode::Cmd), + "powershell" => Ok(ShellMode::Powershell), + other => Err(CliError::usage(format!( + "invalid --shell value '{other}'; expected path, cmd, or powershell" + ))), + } +} + +/// Classifies a path according to common Windows command installation roots. +#[must_use] +pub fn classify_source(path: &Path) -> SourceKind { + let lower = path.to_string_lossy().to_ascii_lowercase(); + if lower.contains("\\scoop\\shims") { + SourceKind::ScoopShim + } else if lower.contains("\\.cargo\\bin") { + SourceKind::CargoBin + } else if lower.contains("\\windows\\system32") { + SourceKind::System + } else { + SourceKind::Other + } +} + +/// Resolves all matching commands in the order the shell would consider them. +#[must_use] +pub fn find_command_matches( + command: &str, + path_dirs: &[PathBuf], + pathext: &[String], +) -> Vec { + CommandPathIndex::build_lossy(path_dirs, pathext).find_command_matches(command) +} + +struct CommandPathIndex { + path_dirs: Vec, + pathext: Vec, + entries_by_rank: Vec>, +} + +impl CommandPathIndex { + fn build(path_dirs: &[PathBuf], pathext: &[String]) -> Result { + let mut entries_by_rank = Vec::with_capacity(path_dirs.len()); + for directory in path_dirs { + let mut entries = HashMap::new(); + if !directory.is_dir() { + entries_by_rank.push(entries); + continue; + } + for entry in fs::read_dir(directory).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", directory.display())) + })? { + let entry = entry.map_err(|error| { + CliError::runtime(format!("failed to inspect entry: {error}")) + })?; + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy().to_ascii_lowercase(); + entries.entry(file_name).or_insert_with(|| entry.path()); + } + entries_by_rank.push(entries); + } + + Ok(Self { + path_dirs: path_dirs.to_vec(), + pathext: pathext.to_vec(), + entries_by_rank, + }) + } + + fn build_lossy(path_dirs: &[PathBuf], pathext: &[String]) -> Self { + Self::build(path_dirs, pathext).unwrap_or_else(|_| Self { + path_dirs: path_dirs.to_vec(), + pathext: pathext.to_vec(), + entries_by_rank: vec![HashMap::new(); path_dirs.len()], + }) + } + + fn find_command_matches(&self, command: &str) -> Vec { + let mut seen = HashSet::new(); + let mut matches = Vec::new(); + let has_extension = Path::new(command).extension().is_some(); + let candidates = if has_extension { + vec![command.to_ascii_lowercase()] + } else { + self.pathext + .iter() + .map(|ext| format!("{command}{ext}").to_ascii_lowercase()) + .collect::>() + }; + + for (rank, candidate_dir) in self.path_dirs.iter().enumerate() { + let Some(entries) = self.entries_by_rank.get(rank) else { + continue; + }; + for candidate in &candidates { + let Some(actual_path) = entries.get(candidate) else { + continue; + }; + let normalized = actual_path.to_string_lossy().to_ascii_lowercase(); + if seen.insert(normalized) { + matches.push(CommandMatch { + command: command.to_string(), + path: actual_path.clone(), + source_kind: classify_source(candidate_dir), + is_primary: false, + path_rank: rank, + winner_path: PathBuf::new(), + shadow_reason: ShadowReason::PathWinner, + shell_mode: None, + shell_winner_name: None, + shell_winner_type: None, + shell_winner_path: None, + shell_winner_target: None, + shell_is_primary: None, + }); + } + } + } + + annotate_matches(&mut matches); + matches + } +} + +/// Executes the `pathshadow` command with the provided arguments. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] for empty command sets and [`CliError::Runtime`] for directory traversal failures. +pub fn run(cli: &Cli) -> Result { + let path_dirs = path_directories(); + let pathext = path_extensions(); + let requested_commands = if cli.all_duplicates { + cli.commands.clone() + } else { + let commands = collect_commands(cli)?; + if commands.is_empty() { + return Err(CliError::usage( + "provide a command name, pipe command names into stdin, or pass --all-duplicates", + )); + } + commands + }; + let mut matches = if cli.all_duplicates { + filter_duplicate_matches( + find_all_duplicates(&path_dirs, &pathext)?, + &requested_commands, + ) + } else { + let mut all = Vec::new(); + let command_index = CommandPathIndex::build_lossy(&path_dirs, &pathext); + for command in &requested_commands { + all.extend(command_index.find_command_matches(command)); + } + all + }; + + if cli.high_signal { + matches = apply_high_signal(matches, cli.shell_mode, &pathext); + } + if cli.shell_mode == ShellMode::Powershell { + annotate_powershell_resolution(&mut matches)?; + } + + match cli.common.render_mode() { + RenderMode::Json | RenderMode::Toon => { + if cli.summary { + print_structured(&summarize_matches(&matches), cli.common.render_mode())?; + } else { + print_structured(&matches, cli.common.render_mode())?; + } + } + RenderMode::Text => { + for warning in + render_missing_command_messages(&requested_commands, &matches, cli.summary) + { + eprintln!("{warning}"); + } + if cli.summary { + for entry in summarize_matches(&matches) { + println!("{}", render_text_summary(&entry)); + } + } else { + for entry in &matches { + println!("{}", render_text_match(entry)); + } + } + } + } + + Ok(if matches.is_empty() { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn filter_duplicate_matches(matches: Vec, commands: &[String]) -> Vec { + if commands.is_empty() { + return matches; + } + + let filters = commands + .iter() + .map(|command| command.to_ascii_lowercase()) + .collect::>(); + matches + .into_iter() + .filter(|entry| filters.contains(&entry.command.to_ascii_lowercase())) + .collect() +} + +fn collect_commands(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.commands.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let commands = parse_commands_from_string(&buffer); + if !commands.is_empty() { + return Ok(commands); + } + } + + Ok(cli.commands.clone()) +} + +fn path_directories() -> Vec { + parse_path_directories(std::env::var_os("PATH").as_deref()) +} + +fn path_extensions() -> Vec { + parse_path_extensions(std::env::var("PATHEXT").ok().as_deref()) +} + +fn parse_path_directories(path: Option<&OsStr>) -> Vec { + path.map(|value| std::env::split_paths(value).collect::>()) + .unwrap_or_default() +} + +fn parse_path_extensions(pathext: Option<&str>) -> Vec { + let mut exts = pathext + .unwrap_or(".COM;.EXE;.BAT;.CMD;.PS1") + .split(';') + .map(str::trim) + .filter(|ext| !ext.is_empty()) + .map(str::to_ascii_uppercase) + .collect::>(); + if exts.is_empty() { + exts.push(".EXE".to_string()); + } + exts +} + +fn parse_commands_from_string(buffer: &str) -> Vec { + buffer + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect::>() +} + +fn find_all_duplicates( + path_dirs: &[PathBuf], + pathext: &[String], +) -> Result, CliError> { + let allowed = pathext + .iter() + .map(|ext| ext.to_ascii_lowercase()) + .collect::>(); + let mut grouped = BTreeMap::>::new(); + + for directory in path_dirs { + if !directory.is_dir() { + continue; + } + + for entry in fs::read_dir(directory).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", directory.display())) + })? { + let entry = entry + .map_err(|error| CliError::runtime(format!("failed to inspect entry: {error}")))?; + let path = entry.path(); + let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else { + continue; + }; + let normalized_extension = format!(".{}", extension.to_ascii_lowercase()); + if !allowed.contains(&normalized_extension) { + continue; + } + + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let rank = path_rank(path_dirs, &path); + + grouped + .entry(stem.to_ascii_lowercase()) + .or_default() + .push(CommandMatch { + command: stem.to_string(), + path, + source_kind: classify_source(directory), + is_primary: false, + path_rank: rank, + winner_path: PathBuf::new(), + shadow_reason: ShadowReason::PathWinner, + shell_mode: None, + shell_winner_name: None, + shell_winner_type: None, + shell_winner_path: None, + shell_winner_target: None, + shell_is_primary: None, + }); + } + } + + let mut duplicates = Vec::new(); + for (_, mut group) in grouped { + if group.len() < 2 { + continue; + } + group.sort_by_key(|entry| entry.path_rank); + annotate_matches(&mut group); + duplicates.extend(group); + } + + duplicates.sort_by(|left, right| { + left.command + .cmp(&right.command) + .then_with(|| left.path.cmp(&right.path)) + }); + Ok(duplicates) +} + +fn path_rank(path_dirs: &[PathBuf], path: &Path) -> usize { + path.parent() + .and_then(|parent| path_dirs.iter().position(|candidate| candidate == parent)) + .unwrap_or(usize::MAX) +} + +#[cfg(test)] +fn resolve_actual_path(directory: &Path, candidate: &str) -> Option { + let target = candidate.to_ascii_lowercase(); + fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .find_map(|entry| { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + (file_name.to_ascii_lowercase() == target).then(|| entry.path()) + }) +} + +fn annotate_matches(matches: &mut [CommandMatch]) { + let Some(winner_path) = matches.first().map(|entry| entry.path.clone()) else { + return; + }; + + for (index, entry) in matches.iter_mut().enumerate() { + entry.is_primary = index == 0; + entry.winner_path.clone_from(&winner_path); + entry.shadow_reason = if entry.is_primary { + ShadowReason::PathWinner + } else { + ShadowReason::ShadowedByEarlierPath + }; + } +} + +fn apply_high_signal( + matches: Vec, + shell_mode: ShellMode, + pathext: &[String], +) -> Vec { + let mut grouped = BTreeMap::>::new(); + for entry in matches { + grouped + .entry(entry.command.clone()) + .or_default() + .push(entry); + } + + let mut filtered = Vec::new(); + for (_, group) in grouped { + let mut per_directory = BTreeMap::::new(); + for entry in group { + let key = entry + .path + .parent() + .map(|parent| parent.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match per_directory.get(&key) { + Some(existing) + if extension_rank(&entry.path, shell_mode, pathext) + >= extension_rank(&existing.path, shell_mode, pathext) => {} + _ => { + per_directory.insert(key, entry); + } + } + } + + let mut group = per_directory.into_values().collect::>(); + group.sort_by(|left, right| { + left.path_rank + .cmp(&right.path_rank) + .then_with(|| left.path.cmp(&right.path)) + }); + annotate_matches(&mut group); + filtered.extend(group); + } + + filtered.sort_by(|left, right| { + left.command + .cmp(&right.command) + .then_with(|| left.path.cmp(&right.path)) + }); + filtered +} + +fn extension_rank(path: &Path, shell_mode: ShellMode, pathext: &[String]) -> usize { + let extension = path + .extension() + .and_then(|item| item.to_str()) + .map(|item| format!(".{}", item.to_ascii_uppercase())) + .unwrap_or_default(); + let preferred = match shell_mode { + ShellMode::Powershell => { + let mut order = vec![".PS1".to_string(), ".PSM1".to_string(), ".PSD1".to_string()]; + for item in pathext { + if !order.iter().any(|existing| existing == item) { + order.push(item.clone()); + } + } + order + } + ShellMode::Path | ShellMode::Cmd => pathext.to_vec(), + }; + preferred + .iter() + .position(|item| item == &extension) + .unwrap_or(preferred.len()) +} + +fn annotate_powershell_resolution(matches: &mut [CommandMatch]) -> Result<(), CliError> { + let commands = matches + .iter() + .map(|entry| entry.command.clone()) + .collect::>() + .into_iter() + .collect::>(); + let by_command = resolve_powershell_commands(&commands)?; + + for entry in matches { + entry.shell_mode = Some(ShellMode::Powershell); + if let Some(resolution) = by_command.get(&entry.command) { + entry.shell_winner_name = Some(resolution.name.clone()); + entry.shell_winner_type = Some(resolution.kind.clone()); + entry.shell_winner_path = resolution.path.clone(); + entry.shell_winner_target = resolution.target.clone(); + entry.shell_is_primary = Some( + resolution + .path + .as_ref() + .is_some_and(|winner| paths_equal(winner, &entry.path)), + ); + } + } + Ok(()) +} + +fn resolve_powershell_commands( + commands: &[String], +) -> Result, CliError> { + if commands.is_empty() { + return Ok(BTreeMap::new()); + } + + let host = resolve_powershell_host().ok_or_else(|| { + CliError::runtime("failed to locate powershell or pwsh for --shell powershell") + })?; + let script = build_powershell_resolution_script(commands); + let output = Command::new(host) + .args(["-NoProfile", "-Command", &script]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| { + CliError::runtime(format!("failed to query PowerShell resolution: {error}")) + })?; + if !output.status.success() { + return Ok(BTreeMap::new()); + } + let text = String::from_utf8_lossy(&output.stdout); + parse_shell_resolutions(&text) +} + +fn build_powershell_resolution_script(commands: &[String]) -> String { + let names = commands + .iter() + .map(|command| format!("'{}'", command.replace('\'', "''"))) + .collect::>() + .join(","); + format!( + "$names = @({names}); foreach ($requested in $names) {{ $cmd = Get-Command -Name $requested -ErrorAction SilentlyContinue | Select-Object -First 1 Name,CommandType,Path,Definition,Source; if ($null -eq $cmd) {{ continue }}; $target = if ($cmd.Path) {{ $cmd.Path }} elseif ($cmd.CommandType -eq 'Alias' -and $cmd.Definition) {{ $cmd.Definition }} elseif ($cmd.Source) {{ $cmd.Source }} elseif ($cmd.Definition) {{ ($cmd.Definition -split \"`r?`n\")[0] }} else {{ '' }}; $fields = @($requested, $cmd.Name, $cmd.CommandType, $cmd.Path, $target) | ForEach-Object {{ if ($null -eq $_) {{ '' }} else {{ $_.ToString().Replace(\"`t\", ' ').Replace(\"`r\", ' ').Replace(\"`n\", ' ') }} }}; [Console]::Out.WriteLine(($fields -join \"`t\")) }}" + ) +} + +fn resolve_powershell_host() -> Option { + resolve_powershell_host_from_path( + std::env::var_os("PATH").as_deref(), + std::env::var_os("PATHEXT").as_deref(), + ) +} + +fn resolve_powershell_host_from_path( + path: Option<&OsStr>, + pathext: Option<&OsStr>, +) -> Option { + let known_locations = [ + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe", + ]; + if let Some(known) = known_locations + .into_iter() + .find(|candidate| Path::new(candidate).exists()) + { + return Some(PathBuf::from(known)); + } + + let path_dirs = parse_path_directories(path); + let pathext = parse_path_extensions(pathext.and_then(OsStr::to_str)); + ["powershell", "pwsh"].into_iter().find_map(|name| { + find_command_matches(name, &path_dirs, &pathext) + .into_iter() + .next() + .map(|entry| entry.path) + }) +} + +fn parse_shell_resolutions(text: &str) -> Result, CliError> { + let mut resolutions = BTreeMap::new(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + let (requested, resolution) = parse_shell_resolution_line(line)?; + resolutions.insert(requested, resolution); + } + Ok(resolutions) +} + +fn parse_shell_resolution_line(line: &str) -> Result<(String, ShellResolution), CliError> { + let mut parts = line.splitn(5, '\t'); + let requested = parts + .next() + .filter(|item| !item.is_empty()) + .ok_or_else(|| CliError::runtime("invalid PowerShell resolution output: missing query"))?; + let winner_name = parts + .next() + .filter(|item| !item.is_empty()) + .ok_or_else(|| CliError::runtime("invalid PowerShell resolution output: missing name"))?; + let winner_type = parts + .next() + .filter(|item| !item.is_empty()) + .ok_or_else(|| { + CliError::runtime("invalid PowerShell resolution output: missing command type") + })?; + let winner_path = parts + .next() + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(PathBuf::from); + let winner_target = parts + .next() + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned); + Ok(( + requested.to_string(), + ShellResolution { + name: winner_name.to_string(), + kind: winner_type.to_ascii_lowercase(), + path: winner_path, + target: winner_target, + }, + )) +} + +fn paths_equal(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +fn summarize_matches(matches: &[CommandMatch]) -> Vec { + let mut grouped = BTreeMap::>::new(); + for entry in matches { + grouped + .entry(entry.command.clone()) + .or_default() + .push(entry); + } + + grouped + .into_iter() + .filter_map(|(command, group)| { + let winner = group.iter().copied().find(|entry| entry.is_primary)?; + let shadowed_count = group.iter().filter(|entry| !entry.is_primary).count(); + let top_shadowed_path = group + .iter() + .copied() + .find(|entry| !entry.is_primary) + .map(|entry| entry.path.clone()); + let shell_winner_path = winner.shell_winner_path.clone(); + let shell_winner_target = winner.shell_winner_target.clone(); + Some(CommandSummary { + command, + total_matches: group.len(), + shadowed_count, + winner_path: winner.winner_path.clone(), + top_shadowed_path, + winner_source_kind: winner.source_kind, + winner_path_rank: winner.path_rank, + shell_mode: winner.shell_mode, + shell_winner_name: winner.shell_winner_name.clone(), + shell_winner_type: winner.shell_winner_type.clone(), + shell_winner_path, + shell_winner_target, + shell_disagrees: winner.shell_winner_type.is_some() + && !winner + .shell_winner_path + .as_ref() + .is_some_and(|path| paths_equal(path, &winner.winner_path)), + }) + }) + .collect() +} + +fn render_text_match(entry: &CommandMatch) -> String { + let winner = if entry.is_primary { + "self".to_string() + } else { + entry.winner_path.display().to_string() + }; + let mut text = format!( + "{} {} rank={} reason={} source={} winner={} path={}", + entry.command, + if entry.is_primary { + "primary" + } else { + "shadowed" + }, + entry.path_rank, + shadow_reason_label(entry.shadow_reason), + source_kind_label(entry.source_kind), + winner, + entry.path.display() + ); + if let Some(kind) = entry.shell_winner_type.as_ref() { + let shell_name = entry.shell_winner_name.as_deref().unwrap_or("-"); + let shell_target = shell_target_text( + entry.shell_winner_target.as_deref(), + entry.shell_winner_path.as_deref(), + ); + let shell_primary = entry + .shell_is_primary + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let _ = write!( + text, + " shell={} shell_name={} shell_type={} shell_target={} shell_primary={}", + entry.shell_mode.map_or("path", shell_mode_label), + shell_name, + kind, + shell_target, + shell_primary + ); + } + text +} + +fn render_text_summary(entry: &CommandSummary) -> String { + let mut text = format!( + "{} total={} shadowed={} winner={} rank={} source={}", + entry.command, + entry.total_matches, + entry.shadowed_count, + entry.winner_path.display(), + entry.winner_path_rank, + source_kind_label(entry.winner_source_kind), + ); + if let Some(shadowed_path) = &entry.top_shadowed_path { + write!(text, " shadowed_path={}", shadowed_path.display()) + .expect("writing to a String cannot fail"); + } + if let Some(shell_mode) = entry.shell_mode { + let shell_name = entry.shell_winner_name.as_deref().unwrap_or("-"); + let shell_target = shell_target_text( + entry.shell_winner_target.as_deref(), + entry.shell_winner_path.as_deref(), + ); + let shell_type = entry.shell_winner_type.as_deref().unwrap_or("-"); + let _ = write!( + text, + " shell={} shell_name={} shell_type={} shell_target={} shell_disagrees={}", + shell_mode_label(shell_mode), + shell_name, + shell_type, + shell_target, + entry.shell_disagrees + ); + } + text +} + +fn shell_target_text(target: Option<&str>, path: Option<&Path>) -> String { + target + .map(ToOwned::to_owned) + .or_else(|| path.map(|path| path.display().to_string())) + .unwrap_or_else(|| "-".to_string()) +} + +fn render_missing_command_messages( + requested_commands: &[String], + matches: &[CommandMatch], + summary_mode: bool, +) -> Vec { + let present = matches + .iter() + .map(|entry| entry.command.to_ascii_lowercase()) + .collect::>(); + + requested_commands + .iter() + .filter(|command| !present.contains(&command.to_ascii_lowercase())) + .map(|command| { + if summary_mode { + format!("pathshadow: command '{command}' not found on PATH; no summary row emitted") + } else { + format!("pathshadow: command '{command}' not found on PATH") + } + }) + .collect() +} + +const fn source_kind_label(kind: SourceKind) -> &'static str { + match kind { + SourceKind::ScoopShim => "scoop_shim", + SourceKind::CargoBin => "cargo_bin", + SourceKind::System => "system", + SourceKind::Other => "other", + } +} + +const fn shadow_reason_label(reason: ShadowReason) -> &'static str { + match reason { + ShadowReason::PathWinner => "path_winner", + ShadowReason::ShadowedByEarlierPath => "shadowed_by_earlier_path", + } +} + +const fn shell_mode_label(mode: ShellMode) -> &'static str { + match mode { + ShellMode::Path => "path", + ShellMode::Cmd => "cmd", + ShellMode::Powershell => "powershell", + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format: common::InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_path_helpers_normalize_environment_values() { + let first = PathBuf::from(r"C:\tools"); + let second = PathBuf::from(r"C:\Users\example\.cargo\bin"); + let joined = OsString::from(format!("{};{}", first.display(), second.display())); + + assert_eq!( + parse_path_directories(Some(joined.as_os_str())), + vec![first, second] + ); + assert_eq!(parse_path_directories(None), Vec::::new()); + + assert_eq!( + parse_path_extensions(Some(".cmd;.exe;;.ps1")), + vec![".CMD", ".EXE", ".PS1"] + ); + assert_eq!(parse_path_extensions(Some("")), vec![".EXE"]); + assert_eq!( + parse_path_extensions(None), + vec![".COM", ".EXE", ".BAT", ".CMD", ".PS1"] + ); + assert_eq!( + parse_commands_from_string(" demo \n\ncargo\n"), + vec!["demo", "cargo"] + ); + } + + #[test] + fn duplicate_detection_marks_primary_entries_by_path_rank() { + let first = tempdir().expect("first path"); + let second = tempdir().expect("second path"); + let third = tempdir().expect("third path"); + fs::write(first.path().join("demo.cmd"), "@echo first").expect("first duplicate"); + fs::write(second.path().join("demo.exe"), "binary").expect("second duplicate"); + fs::write(third.path().join("solo.exe"), "binary").expect("unique command"); + + let duplicates = find_all_duplicates( + &[ + first.path().to_path_buf(), + second.path().to_path_buf(), + third.path().to_path_buf(), + ], + &[".CMD".into(), ".EXE".into()], + ) + .expect("duplicates"); + + assert_eq!(duplicates.len(), 2); + assert!(duplicates.iter().all(|entry| entry.command == "demo")); + assert_eq!( + duplicates.iter().filter(|entry| entry.is_primary).count(), + 1 + ); + assert!(duplicates.iter().any(|entry| { + entry.is_primary + && entry.path == first.path().join("demo.cmd") + && entry.path_rank == 0 + && entry.winner_path == first.path().join("demo.cmd") + && entry.shadow_reason == ShadowReason::PathWinner + })); + assert!(duplicates.iter().any(|entry| { + !entry.is_primary + && entry.path == second.path().join("demo.exe") + && entry.path_rank == 1 + && entry.winner_path == first.path().join("demo.cmd") + && entry.shadow_reason == ShadowReason::ShadowedByEarlierPath + })); + } + + #[test] + fn parse_cli_supports_shell_summary_and_high_signal_modes() { + let (_, cli) = parse_cli_from([ + "pathshadow", + "--shell", + "powershell", + "--high-signal", + "--summary", + "cargo", + ]) + .expect("cli parse"); + + assert_eq!(cli.shell_mode, ShellMode::Powershell); + assert!(cli.high_signal); + assert!(cli.summary); + assert_eq!(cli.commands, vec!["cargo".to_string()]); + } + + #[test] + fn high_signal_filter_collapses_same_directory_variants() { + let first = tempdir().expect("first path"); + let second = tempdir().expect("second path"); + fs::write(first.path().join("demo.cmd"), "@echo first").expect("first cmd"); + fs::write(first.path().join("demo.exe"), "binary").expect("first exe"); + fs::write(second.path().join("demo.exe"), "binary").expect("second exe"); + + let duplicates = find_all_duplicates( + &[first.path().to_path_buf(), second.path().to_path_buf()], + &[".CMD".into(), ".EXE".into()], + ) + .expect("duplicates"); + let filtered = + apply_high_signal(duplicates, ShellMode::Cmd, &[".CMD".into(), ".EXE".into()]); + + assert_eq!(filtered.len(), 2); + assert!( + filtered + .iter() + .any(|entry| entry.path == first.path().join("demo.cmd")) + ); + assert!( + filtered + .iter() + .any(|entry| entry.path == second.path().join("demo.exe")) + ); + assert!( + !filtered + .iter() + .any(|entry| entry.path == first.path().join("demo.exe")) + ); + } + + #[test] + fn summary_groups_matches_by_command() { + let first = tempdir().expect("first path"); + let second = tempdir().expect("second path"); + fs::write(first.path().join("demo.cmd"), "@echo first").expect("first cmd"); + fs::write(second.path().join("demo.exe"), "binary").expect("second exe"); + + let duplicates = find_all_duplicates( + &[first.path().to_path_buf(), second.path().to_path_buf()], + &[".CMD".into(), ".EXE".into()], + ) + .expect("duplicates"); + let summary = summarize_matches(&duplicates); + + assert_eq!(summary.len(), 1); + assert_eq!(summary[0].command, "demo"); + assert_eq!(summary[0].total_matches, 2); + assert_eq!(summary[0].shadowed_count, 1); + assert_eq!(summary[0].winner_path, first.path().join("demo.cmd")); + assert_eq!( + summary[0].top_shadowed_path, + Some(second.path().join("demo.exe")) + ); + } + + #[test] + fn shell_resolution_parsing_keeps_alias_targets() { + let parsed = parse_shell_resolutions( + "where\twhere\tAlias\t\tWhere-Object\nnpm\tnpm.ps1\tExternalScript\tC:\\Tools\\npm.ps1\tC:\\Tools\\npm.ps1\n", + ) + .expect("shell resolutions"); + + assert_eq!( + parsed + .get("where") + .expect("where resolution") + .target + .as_deref(), + Some("Where-Object") + ); + assert_eq!(parsed.get("where").expect("where resolution").kind, "alias"); + assert_eq!( + parsed.get("npm").expect("npm resolution").path, + Some(PathBuf::from(r"C:\Tools\npm.ps1")) + ); + } + + #[cfg(windows)] + #[test] + fn resolve_powershell_host_prefers_known_locations_over_path_hijack() { + let temp_root = tempdir().expect("temp root"); + let fake = temp_root.path().join("powershell.exe"); + fs::write(&fake, "echo fake").expect("fake powershell"); + + let resolved = resolve_powershell_host_from_path( + Some(temp_root.path().as_os_str()), + Some(OsStr::new(".EXE")), + ) + .expect("powershell host"); + + assert_ne!(resolved, fake); + assert!( + resolved + .to_string_lossy() + .to_ascii_lowercase() + .contains("powershell.exe") + ); + assert_ne!(resolved.parent(), Some(temp_root.path())); + } + + #[test] + fn helpers_cover_resolution_ranking_and_labels() { + let directory = tempdir().expect("tempdir"); + let command_path = directory.path().join("Demo.CMD"); + fs::write(&command_path, "@echo demo").expect("command"); + + let resolved = resolve_actual_path(directory.path(), "demo.cmd").expect("resolved path"); + assert_eq!(resolved, command_path); + + let path_dirs = vec![directory.path().to_path_buf()]; + assert_eq!(path_rank(&path_dirs, &resolved), 0); + assert_eq!( + path_rank(&path_dirs, Path::new(r"C:\elsewhere\demo.exe")), + usize::MAX + ); + + assert_eq!(source_kind_label(SourceKind::ScoopShim), "scoop_shim"); + assert_eq!(source_kind_label(SourceKind::CargoBin), "cargo_bin"); + assert_eq!(source_kind_label(SourceKind::System), "system"); + assert_eq!(source_kind_label(SourceKind::Other), "other"); + assert_eq!(shadow_reason_label(ShadowReason::PathWinner), "path_winner"); + assert_eq!( + shadow_reason_label(ShadowReason::ShadowedByEarlierPath), + "shadowed_by_earlier_path" + ); + assert_eq!( + classify_source(Path::new(r"C:\Windows\System32\where.exe")), + SourceKind::System + ); + } + + #[test] + fn find_command_matches_honors_explicit_extensions() { + let directory = tempdir().expect("tempdir"); + let command_path = directory.path().join("demo.cmd"); + fs::write(&command_path, "@echo demo").expect("command"); + + let matches = find_command_matches( + "demo.cmd", + &[directory.path().to_path_buf()], + &[".CMD".into(), ".EXE".into()], + ); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].path, command_path); + assert!(matches[0].is_primary); + assert_eq!(matches[0].path_rank, 0); + assert_eq!(matches[0].winner_path, command_path); + assert_eq!(matches[0].shadow_reason, ShadowReason::PathWinner); + } + + #[test] + fn command_index_reuses_directory_scan_for_multiple_queries() { + let directory = tempdir().expect("tempdir"); + let demo_path = directory.path().join("demo.cmd"); + let cargo_path = directory.path().join("cargo.exe"); + fs::write(&demo_path, "@echo demo").expect("demo command"); + fs::write(&cargo_path, "binary").expect("cargo command"); + + let index = CommandPathIndex::build( + &[directory.path().to_path_buf()], + &[".CMD".into(), ".EXE".into()], + ) + .expect("index"); + let demo = index.find_command_matches("demo"); + let cargo = index.find_command_matches("cargo.exe"); + + assert_eq!(demo.len(), 1); + assert_eq!(demo[0].path, demo_path); + assert_eq!(cargo.len(), 1); + assert_eq!(cargo[0].path, cargo_path); + } + + #[test] + fn run_requires_explicit_commands_without_duplicate_scan() { + let error = run(&Cli { + common: common_args(false), + all_duplicates: false, + high_signal: false, + summary: false, + shell_mode: ShellMode::Path, + commands: Vec::new(), + }) + .expect_err("missing command should fail"); + + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("provide a command name") + )); + } + + #[test] + fn missing_commands_render_human_readable_text_warnings() { + let warnings = render_missing_command_messages(&["missing-demo".into()], &[], true); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("missing-demo")); + assert!(warnings[0].contains("not found on PATH")); + } + + #[test] + fn run_can_emit_text_results_for_known_commands() { + let exit_code = run(&Cli { + common: common_args(false), + all_duplicates: false, + high_signal: false, + summary: false, + shell_mode: ShellMode::Path, + commands: vec!["cargo".into()], + }) + .expect("cargo should resolve on this machine"); + + assert_eq!(exit_code, ExitCode::Success); + } +} diff --git a/crates/pathshadow/src/main.rs b/crates/pathshadow/src/main.rs new file mode 100644 index 0000000..1cf3420 --- /dev/null +++ b/crates/pathshadow/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `pathshadow`. + +fn main() { + std::process::exit(pathshadow::main_entry()); +} diff --git a/crates/pathshadow/tests/pathshadow_cli.rs b/crates/pathshadow/tests/pathshadow_cli.rs new file mode 100644 index 0000000..268c439 --- /dev/null +++ b/crates/pathshadow/tests/pathshadow_cli.rs @@ -0,0 +1,157 @@ +//! Integration tests for the `pathshadow` command. + +use std::fs; + +use assert_cmd::Command; +use pathshadow::{SourceKind, classify_source, find_command_matches}; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("pathshadow").expect("binary") +} + +fn classify_path(path: &str) -> SourceKind { + classify_source(std::path::Path::new(path)) +} + +fn demo_path_fixture() -> (tempfile::TempDir, tempfile::TempDir, String) { + let first = tempdir().expect("first path"); + let second = tempdir().expect("second path"); + fs::write(first.path().join("demo.cmd"), "@echo first").expect("first command"); + fs::write(second.path().join("demo.exe"), "binary").expect("second command"); + let path_value = format!("{};{}", first.path().display(), second.path().display()); + (first, second, path_value) +} + +#[test] +fn classifies_common_install_roots() { + assert_eq!( + classify_path(r"C:\Users\example\scoop\shims\rg.exe"), + SourceKind::ScoopShim + ); + assert_eq!( + classify_path(r"C:\Users\example\scoop\shims"), + SourceKind::ScoopShim + ); + assert_eq!( + classify_path(r"C:\Users\example\.cargo\bin\cargo.exe"), + SourceKind::CargoBin + ); + assert_eq!( + classify_path(r"C:\Users\example\.cargo\bin"), + SourceKind::CargoBin + ); +} + +#[test] +fn finds_shadowed_matches_in_path_order() { + let (first, second, _) = demo_path_fixture(); + + let matches = find_command_matches( + "demo", + &[first.path().to_path_buf(), second.path().to_path_buf()], + &[".CMD".into(), ".EXE".into()], + ); + + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].path, first.path().join("demo.cmd")); + assert!(matches[0].is_primary); + assert!(!matches[1].is_primary); +} + +#[test] +fn reports_duplicates_from_cli() { + let (_first, _second, path_value) = demo_path_fixture(); + let mut command = cargo_command(); + command + .arg("demo") + .env("PATH", path_value) + .env("PATHEXT", ".CMD;.EXE") + .assert() + .success() + .stdout(predicate::str::contains("demo")) + .stdout(predicate::str::contains("primary")) + .stdout(predicate::str::contains("shadowed")); +} + +#[test] +fn supports_powershell_pipeline() { + let (_first, _second, path_value) = demo_path_fixture(); + + let binary = assert_cmd::cargo::cargo_bin("pathshadow"); + let script = format!("'demo' | & '{}'", binary.display()); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .env("PATH", path_value) + .env("PATHEXT", ".CMD;.EXE") + .assert() + .success() + .stdout(predicate::str::contains("demo")); +} + +#[test] +fn emits_rank_reason_and_winner_in_json_output() { + let (first, _second, path_value) = demo_path_fixture(); + + let winner = first.path().join("demo.cmd"); + let winner_json = winner.display().to_string().replace('\\', "\\\\"); + let mut command = cargo_command(); + command + .arg("demo") + .arg("--json") + .env("PATH", path_value) + .env("PATHEXT", ".CMD;.EXE") + .assert() + .success() + .stdout(predicate::str::contains("\"path_rank\":0")) + .stdout(predicate::str::contains("\"path_rank\":1")) + .stdout(predicate::str::contains( + "\"shadow_reason\":\"path_winner\"", + )) + .stdout(predicate::str::contains( + "\"shadow_reason\":\"shadowed_by_earlier_path\"", + )) + .stdout(predicate::str::contains(format!( + "\"winner_path\":\"{winner_json}\"" + ))); +} + +#[test] +fn help_includes_path_diagnosis_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("pathshadow rg")) + .stdout(predicate::str::contains( + "pathshadow python npm --shell powershell", + )) + .stdout(predicate::str::contains( + "pathshadow --all-duplicates --high-signal --summary --json", + )) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} + +#[test] +fn reports_alias_targets_for_powershell_resolution() { + let mut command = cargo_command(); + command + .arg("where") + .arg("--shell") + .arg("powershell") + .arg("--summary") + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"shell_winner_type\":\"alias\"")) + .stdout(predicate::str::contains( + "\"shell_winner_target\":\"Where-Object\"", + )) + .stdout(predicate::str::contains("\"shell_disagrees\":true")); +} diff --git a/crates/petools/Cargo.toml b/crates/petools/Cargo.toml new file mode 100644 index 0000000..9333276 --- /dev/null +++ b/crates/petools/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "petools" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "PowerShell-first PE import, export, callsite, string, driver, and IOCTL analysis." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "peexports" +path = "src/main_peexports.rs" + +[[bin]] +name = "peimports" +path = "src/main_peimports.rs" + +[[bin]] +name = "pecalls" +path = "src/main_pecalls.rs" + +[[bin]] +name = "pesig" +path = "src/main_pesig.rs" + +[[bin]] +name = "pestrrefs" +path = "src/main_pestrrefs.rs" + +[[bin]] +name = "drvshape" +path = "src/main_drvshape.rs" + +[[bin]] +name = "ioctlscan" +path = "src/main_ioctlscan.rs" + +[dependencies] +common = { path = "../common", default-features = false } +goblin.workspace = true +lexopt.workspace = true +llvmtools = { path = "../llvmtools" } +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/petools/src/lib.rs b/crates/petools/src/lib.rs new file mode 100644 index 0000000..cd5ab45 --- /dev/null +++ b/crates/petools/src/lib.rs @@ -0,0 +1,3865 @@ +//! Native PE/Windows binary diagnostics for Mercury Toolbox. +#![allow( + clippy::missing_const_for_fn, + clippy::module_name_repetitions, + clippy::similar_names, + clippy::struct_field_names, + clippy::too_many_lines, + reason = "the crate exposes a compact command family where repeated PE terms and parser state machines are intentional" +)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + parse_input_format, print_quick_help_error, print_structured, should_read_stdin, +}; +use goblin::pe::{PE, export::Reexport, header::machine_to_str, subsystem}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use llvmtools::backend::{RawBlock, run_llvm_binary}; +use regex_lite::Regex; +use serde::Serialize; + +const DEFAULT_RAW_OUTPUT_LIMIT: usize = 262_144; +const DEFAULT_MIN_STRING_LEN: usize = 5; + +/// Selects one PE analysis command in the `petools` crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolKind { + /// `peexports`. + Exports, + /// `peimports`. + Imports, + /// `pecalls`. + Calls, + /// `pesig`. + Signatures, + /// `pestrrefs`. + StringRefs, + /// `drvshape`. + DriverShape, + /// `ioctlscan`. + IoctlScan, +} + +impl ToolKind { + /// Returns the Mercury command name. + #[must_use] + pub const fn command_name(self) -> &'static str { + match self { + Self::Exports => "peexports", + Self::Imports => "peimports", + Self::Calls => "pecalls", + Self::Signatures => "pesig", + Self::StringRefs => "pestrrefs", + Self::DriverShape => "drvshape", + Self::IoctlScan => "ioctlscan", + } + } + + const fn help(self) -> &'static str { + match self { + Self::Exports => PEEXPORTS_HELP, + Self::Imports => PEIMPORTS_HELP, + Self::Calls => PECALLS_HELP, + Self::Signatures => PESIG_HELP, + Self::StringRefs => PESTRREFS_HELP, + Self::DriverShape => DRVSHAPE_HELP, + Self::IoctlScan => IOCTLSCAN_HELP, + } + } + + const fn needs_objdump(self) -> bool { + matches!(self, Self::Calls | Self::Signatures | Self::StringRefs) + } +} + +const SHARED_OPTIONS: &str = " + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --llvm-bin-dir Directory containing LLVM tools + --llvm-arg Append a raw LLVM argument to objdump-backed tools + --raw-output-limit Maximum captured LLVM output bytes (default: 262144) +"; + +const PEEXPORTS_HELP: &str = "\ +Inspect PE exports with Mercury-friendly output. + +Usage: + peexports [OPTIONS] [PATH...] + +Options: + --name Keep exports whose name contains text + --ordinal Keep one ordinal + --forwarders-only Keep only forwarded exports + --undecorate Keep an undecorated display hint when possible + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --llvm-bin-dir Directory containing LLVM tools + --llvm-arg Append a raw LLVM argument to objdump-backed tools + --raw-output-limit Maximum captured LLVM output bytes (default: 262144) + -h, --help Show this help text + -V, --version Show the command version + +Examples: + peexports C:\\Windows\\System32\\kernel32.dll --json | ConvertFrom-Json + fd -e dll C:\\Windows\\System32 | peexports --input-format lines --toon +"; + +const PEIMPORTS_HELP: &str = "\ +Inspect and categorize PE imports with Mercury-friendly output. + +Usage: + peimports [OPTIONS] [PATH...] + +Options: + --category Keep one API category + --library Keep imports from a library + --api Keep imports whose API name contains text + --suspicious Keep high-signal diagnostic categories only +"; + +const PECALLS_HELP: &str = "\ +Extract imported API callsites from LLVM disassembly. + +Usage: + pecalls [OPTIONS] [PATH...] + +Options: + --api Imported API name to match (repeatable) + --category Imported API category to match (repeatable) + --include-jumps Include import thunks reached by jmp + --context Preserve N raw instruction lines around matches +"; + +const PESIG_HELP: &str = "\ +Infer PE function boundaries and calling conventions. + +Usage: + pesig [OPTIONS] [PATH...] + +Options: + --min-confidence Keep functions at low, medium, or high confidence + --include-thunks Include tiny thunk-like functions + --function Keep functions whose name contains text +"; + +const PESTRREFS_HELP: &str = "\ +Scan PE strings and direct disassembly references. + +Usage: + pestrrefs [OPTIONS] [PATH...] + +Options: + --min-len Minimum string length (default: 5) + --contains Keep strings containing text + --regex Keep strings matching a regex-lite pattern + --encoding String encoding: ascii, utf16le, both + --max-refs Maximum xrefs retained per string +"; + +const DRVSHAPE_HELP: &str = "\ +Summarize Windows driver shape and driver-relevant hints. + +Usage: + drvshape [OPTIONS] [PATH...] + +Options: +"; + +const IOCTLSCAN_HELP: &str = "\ +Scan PE bytes for probable Windows IOCTL constants. + +Usage: + ioctlscan [OPTIONS] [PATH...] + +Options: +"; + +/// High-level category for imported Windows APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiCategory { + /// File and path APIs. + Filesystem, + /// Registry APIs. + Registry, + /// Process, thread, and loader APIs. + ProcessThread, + /// Virtual memory and heap APIs. + Memory, + /// Cryptography APIs. + Crypto, + /// Network APIs. + Network, + /// Windows service APIs. + Service, + /// Synchronization APIs. + Sync, + /// Console APIs. + Console, + /// UI and windowing APIs. + Ui, + /// Device I/O and driver-facing APIs. + DeviceIo, + /// Native NT APIs. + NativeNt, + /// Security and token APIs. + Security, + /// Runtime/CRT support APIs. + RuntimeCrt, + /// Debugging APIs. + Debug, + /// Unknown or uncategorized APIs. + Unknown, +} + +impl ApiCategory { + const fn label(self) -> &'static str { + match self { + Self::Filesystem => "filesystem", + Self::Registry => "registry", + Self::ProcessThread => "process_thread", + Self::Memory => "memory", + Self::Crypto => "crypto", + Self::Network => "network", + Self::Service => "service", + Self::Sync => "sync", + Self::Console => "console", + Self::Ui => "ui", + Self::DeviceIo => "device_io", + Self::NativeNt => "native_nt", + Self::Security => "security", + Self::RuntimeCrt => "runtime_crt", + Self::Debug => "debug", + Self::Unknown => "unknown", + } + } + + fn parse(value: &str) -> Result { + match normalize_name(value).as_str() { + "filesystem" | "file" | "fs" => Ok(Self::Filesystem), + "registry" | "reg" => Ok(Self::Registry), + "process_thread" | "process" | "thread" | "loader" => Ok(Self::ProcessThread), + "memory" | "heap" => Ok(Self::Memory), + "crypto" | "cryptography" => Ok(Self::Crypto), + "network" | "net" | "winsock" => Ok(Self::Network), + "service" | "scm" => Ok(Self::Service), + "sync" | "synchronization" => Ok(Self::Sync), + "console" => Ok(Self::Console), + "ui" | "window" | "gdi" => Ok(Self::Ui), + "device_io" | "deviceio" | "ioctl" | "driver" => Ok(Self::DeviceIo), + "native_nt" | "nt" | "native" => Ok(Self::NativeNt), + "security" | "token" | "acl" => Ok(Self::Security), + "runtime_crt" | "runtime" | "crt" => Ok(Self::RuntimeCrt), + "debug" | "diagnostic" => Ok(Self::Debug), + "unknown" => Ok(Self::Unknown), + other => Err(CliError::usage(format!( + "invalid category '{other}'; expected filesystem, registry, process_thread, memory, crypto, network, service, sync, console, ui, device_io, native_nt, security, runtime_crt, debug, or unknown" + ))), + } + } + + const fn is_suspicious(self) -> bool { + matches!( + self, + Self::DeviceIo + | Self::NativeNt + | Self::Network + | Self::Memory + | Self::ProcessThread + | Self::Crypto + | Self::Service + | Self::Security + | Self::Debug + ) + } +} + +/// Categorizes an imported PE API by library and symbol name. +#[must_use] +pub fn categorize_import(library: &str, api: &str) -> ApiCategory { + let library = library.to_ascii_lowercase(); + let api_norm = normalize_name(api); + + if (library == "ntdll.dll" && api_norm.starts_with("nt")) || api_norm.starts_with("zw") { + return if api_norm.contains("deviceiocontrol") || api_norm.contains("file") { + ApiCategory::DeviceIo + } else { + ApiCategory::NativeNt + }; + } + if api_norm.contains("deviceiocontrol") + || api_norm.contains("iocreate") + || api_norm.contains("iocomplet") + || api_norm.contains("irp") + || api_norm.contains("ctlcode") + || library.contains("wdf") + || library.contains("ndis") + || library.contains("fltmgr") + { + return ApiCategory::DeviceIo; + } + if api_norm.starts_with("reg") || api_norm.contains("registry") { + return ApiCategory::Registry; + } + if api_norm.contains("createfile") + || api_norm.contains("readfile") + || api_norm.contains("writefile") + || api_norm.contains("findfirstfile") + || api_norm.contains("getfile") + || api_norm.contains("setfile") + || api_norm.contains("path") + || api_norm.contains("directory") + { + return ApiCategory::Filesystem; + } + if api_norm.contains("socket") + || api_norm.contains("connect") + || api_norm.contains("send") + || api_norm.contains("recv") + || api_norm.contains("internet") + || api_norm.contains("winhttp") + || api_norm.contains("dns") + || matches!( + library.as_str(), + "ws2_32.dll" | "wininet.dll" | "winhttp.dll" + ) + { + return ApiCategory::Network; + } + if api_norm.contains("crypt") + || api_norm.contains("bcrypt") + || api_norm.contains("ncrypt") + || library.contains("crypt") + || library == "bcrypt.dll" + || library == "ncrypt.dll" + { + return ApiCategory::Crypto; + } + if api_norm.contains("openprocess") + || api_norm.contains("createprocess") + || api_norm.contains("createthread") + || api_norm.contains("loadlibrary") + || api_norm.contains("getprocaddress") + || api_norm.contains("thread") + || api_norm.contains("process") + { + return ApiCategory::ProcessThread; + } + if api_norm.contains("virtualalloc") + || api_norm.contains("virtualprotect") + || api_norm.contains("heap") + || api_norm.contains("mapview") + || api_norm.contains("mmmap") + { + return ApiCategory::Memory; + } + if api_norm.contains("service") + || api_norm.starts_with("opensc") + || api_norm.contains("controlservice") + { + return ApiCategory::Service; + } + if api_norm.contains("mutex") + || api_norm.contains("event") + || api_norm.contains("semaphore") + || api_norm.contains("criticalsection") + || api_norm.contains("waitforsingleobject") + { + return ApiCategory::Sync; + } + if api_norm.contains("console") || api_norm.contains("stdhandle") { + return ApiCategory::Console; + } + if api_norm.contains("window") + || api_norm.contains("messagebox") + || api_norm.contains("dialog") + || library == "user32.dll" + || library == "gdi32.dll" + { + return ApiCategory::Ui; + } + if api_norm.contains("token") + || api_norm.contains("acl") + || api_norm.contains("security") + || api_norm.contains("sid") + || api_norm.contains("privilege") + { + return ApiCategory::Security; + } + if api_norm.contains("debug") + || api_norm.contains("outputdebugstring") + || api_norm.contains("isdebuggerpresent") + { + return ApiCategory::Debug; + } + if library.contains("msvcr") + || library.contains("ucrt") + || library.contains("vcruntime") + || api_norm.starts_with('_') + { + return ApiCategory::RuntimeCrt; + } + ApiCategory::Unknown +} + +/// Query for imported API callsite extraction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallsiteQuery { + apis: Vec, + categories: Vec, + include_jumps: bool, + context: usize, +} + +impl CallsiteQuery { + /// Builds a query that matches one or more API names. + #[must_use] + pub fn apis(apis: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + apis: apis + .into_iter() + .map(|api| api.as_ref().to_ascii_lowercase()) + .collect(), + categories: Vec::new(), + include_jumps: false, + context: 0, + } + } + + fn with_categories(categories: Vec) -> Self { + Self { + apis: Vec::new(), + categories, + include_jumps: false, + context: 0, + } + } + + fn matches(&self, api: &str, category: ApiCategory) -> bool { + (self.apis.is_empty() + || self + .apis + .iter() + .any(|needle| api.to_ascii_lowercase().contains(needle))) + && (self.categories.is_empty() || self.categories.contains(&category)) + } +} + +/// One imported API callsite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Callsite { + /// Instruction address. + pub address: u64, + /// IAT target virtual address from the objdump comment. + pub target: u64, + /// Imported DLL name. + pub library: String, + /// Imported API name. + pub api: String, + /// API category. + pub category: ApiCategory, + /// Instruction kind, usually `call` or `jmp`. + pub kind: String, + /// Raw instruction line. + pub raw: String, + /// Optional raw context around the match. + pub context: Vec, +} + +/// One unresolved direct or indirect call/jump row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UnresolvedCall { + /// Instruction address. + pub address: u64, + /// Instruction kind. + pub kind: String, + /// Raw instruction line. + pub raw: String, +} + +/// Result of imported API callsite analysis. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CallsiteReport { + /// Resolved callsites matching the query. + pub callsites: Vec, + /// Calls/jumps that were visible but could not be mapped to an import. + pub unresolved_calls: Vec, +} + +/// Extracts imported API callsites from LLVM objdump text. +#[must_use] +pub fn analyze_callsites( + disassembly: &str, + imports: &[(&str, &str, &str)], + query: &CallsiteQuery, +) -> CallsiteReport { + let import_targets = imports + .iter() + .filter_map(|(target, library, api)| { + parse_hex_u64(target).map(|target| ImportTarget { + target, + library: (*library).to_owned(), + api: (*api).to_owned(), + category: categorize_import(library, api), + }) + }) + .collect::>(); + analyze_callsites_with_targets(disassembly, &import_targets, query) +} + +/// Method bits decoded from a Windows `CTL_CODE`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IoctlMethod { + /// `METHOD_BUFFERED`. + Buffered, + /// `METHOD_IN_DIRECT`. + InDirect, + /// `METHOD_OUT_DIRECT`. + OutDirect, + /// `METHOD_NEITHER`. + Neither, +} + +impl IoctlMethod { + const fn label(self) -> &'static str { + match self { + Self::Buffered => "METHOD_BUFFERED", + Self::InDirect => "METHOD_IN_DIRECT", + Self::OutDirect => "METHOD_OUT_DIRECT", + Self::Neither => "METHOD_NEITHER", + } + } +} + +/// Decoded Windows IOCTL constant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DecodedIoctl { + /// Raw IOCTL constant. + pub code: u32, + /// Hex display form. + pub hex: String, + /// `DeviceType` field. + pub device_type: u16, + /// Function field. + pub function: u16, + /// Method field. + pub method: IoctlMethod, + /// Method label. + pub method_name: String, + /// Access field. + pub access: u8, + /// Access label. + pub access_name: String, + /// Common device type label when recognized. + pub common_device_name: Option, + /// Confidence label for heuristic scans. + pub confidence: String, + /// Evidence strings and source hints. + pub evidence: Vec, + /// Locations where the constant was observed. + pub locations: Vec, +} + +/// Location for an IOCTL constant candidate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IoctlLocation { + /// File offset when observed in raw bytes. + pub offset: Option, + /// RVA when the offset maps to a PE section. + pub rva: Option, + /// VA when the offset maps to a PE section. + pub va: Option, + /// Section name when known. + pub section: Option, + /// Evidence source label. + pub source: String, +} + +/// Options controlling raw IOCTL candidate filtering. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IoctlScanOptions { + min_function: u16, +} + +impl Default for IoctlScanOptions { + fn default() -> Self { + Self { + min_function: 0x800, + } + } +} + +impl IoctlScanOptions { + /// Returns true when a raw `u32` looks like a plausible IOCTL code. + #[must_use] + pub fn is_probable_code(&self, code: u32) -> bool { + decode_ioctl(code).is_some_and(|decoded| { + decoded.device_type != 0 + && decoded.function >= self.min_function + && decoded.code & 0xffff_0000 != 0 + && is_driver_relevant_device_type(decoded.device_type) + }) + } +} + +/// Decodes a Windows `CTL_CODE` value. +#[must_use] +pub fn decode_ioctl(code: u32) -> Option { + let device_type = u16::try_from((code >> 16) & 0xffff).ok()?; + let access = u8::try_from((code >> 14) & 0x3).ok()?; + let function = u16::try_from((code >> 2) & 0x0fff).ok()?; + let method_bits = code & 0x3; + let method = match method_bits { + 0 => IoctlMethod::Buffered, + 1 => IoctlMethod::InDirect, + 2 => IoctlMethod::OutDirect, + 3 => IoctlMethod::Neither, + _ => return None, + }; + if device_type == 0 && function == 0 { + return None; + } + Some(DecodedIoctl { + code, + hex: format!("0x{code:08X}"), + device_type, + function, + method, + method_name: method.label().to_owned(), + access, + access_name: access_label(access).to_owned(), + common_device_name: common_device_name(device_type).map(str::to_owned), + confidence: "medium".to_owned(), + evidence: Vec::new(), + locations: Vec::new(), + }) +} + +/// String found in raw bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StringHit { + /// Decoded string value. + pub value: String, + /// Encoding label: `ascii` or `utf16le`. + pub encoding: String, + /// File offset. + pub offset: usize, + /// RVA when mapped through PE sections. + pub rva: Option, + /// VA when mapped through PE sections. + pub va: Option, + /// Section name when mapped. + pub section: Option, +} + +/// Scans raw bytes for ASCII and UTF-16LE strings. +#[must_use] +pub fn scan_strings(bytes: &[u8], min_len: usize) -> Vec { + let mut hits = scan_ascii_strings(bytes, min_len); + hits.extend(scan_utf16le_strings(bytes, min_len)); + hits.sort_by_key(|hit| (hit.offset, hit.encoding.clone())); + hits +} + +/// Parses CLI arguments, runs a PE analysis command, and returns a process exit code. +#[must_use] +pub fn main_entry(tool: ToolKind) -> i32 { + match parse_cli_from(std::env::args_os(), tool) { + Ok((ParseOutcome::Help, _)) => { + print!("{}", full_help(tool)); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("{} {}", tool.command_name(), env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(tool, &cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, &full_help(tool)); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, &full_help(tool)); + error.exit_code().as_i32() + } + } +} + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + paths: Vec, + llvm_bin_dir: Option, + llvm_args: Vec, + raw_output_limit: usize, + flags: ToolFlags, +} + +#[derive(Debug, Clone, Default)] +struct ToolFlags { + exports: ExportFlags, + imports: ImportFlags, + calls: CallFlags, + signatures: SignatureFlags, + strings: StringFlags, +} + +#[derive(Debug, Clone, Default)] +struct ExportFlags { + name: Option, + ordinal: Option, + forwarders_only: bool, + undecorate: bool, +} + +#[derive(Debug, Clone, Default)] +struct ImportFlags { + categories: Vec, + library: Option, + api: Option, + suspicious: bool, +} + +#[derive(Debug, Clone, Default)] +struct CallFlags { + apis: Vec, + categories: Vec, + include_jumps: bool, + context: usize, +} + +#[derive(Debug, Clone)] +struct SignatureFlags { + min_confidence: Confidence, + include_thunks: bool, + function: Option, +} + +impl Default for SignatureFlags { + fn default() -> Self { + Self { + min_confidence: Confidence::Low, + include_thunks: false, + function: None, + } + } +} + +#[derive(Debug, Clone)] +struct StringFlags { + min_len: usize, + contains: Option, + regex: Option, + encoding: EncodingMode, + max_refs: usize, +} + +impl Default for StringFlags { + fn default() -> Self { + Self { + min_len: DEFAULT_MIN_STRING_LEN, + contains: None, + regex: None, + encoding: EncodingMode::Both, + max_refs: 12, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EncodingMode { + Ascii, + Utf16Le, + Both, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +enum Confidence { + Low, + Medium, + High, +} + +impl Confidence { + const fn label(self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } +} + +impl ReportCompleteness { + const fn label(self) -> &'static str { + match self { + Self::Complete => "complete", + Self::Partial => "partial", + Self::Limited => "limited", + } + } +} + +#[derive(Debug, Serialize)] +struct ToolReport { + tool: &'static str, + inputs: Vec, + summary: Summary, + report_quality: ReportQuality, + next_actions: Vec, + files: Vec, + parse_warnings: Vec, + raw_blocks: Vec, +} + +#[derive(Debug, Serialize)] +struct ReportQuality { + confidence: Confidence, + completeness: ReportCompleteness, + evidence: Vec, + limitations: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ReportCompleteness { + Complete, + Partial, + Limited, +} + +#[derive(Debug, Serialize)] +struct NextAction { + label: String, + command: String, + why: String, +} + +#[derive(Debug, Default, Serialize)] +struct Summary { + file_count: usize, + result_count: usize, + export_count: usize, + import_count: usize, + category_count: usize, + callsite_count: usize, + function_count: usize, + string_count: usize, + driver_count: usize, + ioctl_count: usize, + warning_count: usize, +} + +#[derive(Debug, Serialize)] +struct FileReport { + path: PathBuf, + kind: String, + machine: String, + arch: String, + bitness: u8, + image_base: u64, + entry_point: u64, + subsystem: String, + sections: Vec, + warnings: Vec, + exports: Vec, + imports: Vec, + categories: Vec, + callsites: Vec, + functions: Vec, + strings: Vec, + drivers: Option, + ioctls: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PeSection { + name: String, + virtual_address: u32, + virtual_size: u32, + raw_offset: u32, + raw_size: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PeImport { + library: String, + name: String, + ordinal: Option, + hint_name_rva: Option, + iat_rva: u64, + iat_va: u64, + slot_size: usize, + section: Option, + category: ApiCategory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PeExport { + name: Option, + display_name: Option, + ordinal: Option, + rva: u64, + va: u64, + file_offset: Option, + forwarder: Option, + section: Option, + is_entrypoint: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CategorySummary { + category: ApiCategory, + count: usize, + libraries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct FunctionSummary { + name: Option, + start_va: u64, + end_va: Option, + size: Option, + source: String, + calling_convention: String, + confidence: Confidence, + evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct StringRefHit { + value: String, + encoding: String, + offset: usize, + rva: Option, + va: Option, + section: Option, + references: Vec, + unresolved_reference_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct StringReference { + address: u64, + raw: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct DriverShape { + roles: Vec, + subsystem: String, + machine: String, + timestamp: u32, + import_categories: Vec, + exports: Vec, + pdb_hints: Vec, + driver_entry_hints: Vec, + device_creation_hints: Vec, + ioctl_summary: IoctlSummary, + hints: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +struct IoctlSummary { + count: usize, + method_neither_count: usize, + custom_device_type_count: usize, +} + +#[derive(Debug, Clone)] +struct PeSnapshot { + path: PathBuf, + bytes: Vec, + kind: String, + machine: String, + arch: String, + bitness: u8, + image_base: u64, + entry_point: u64, + entry_rva: u64, + export_ordinal_base: u32, + subsystem: String, + subsystem_raw: u16, + timestamp: u32, + sections: Vec, + imports: Vec, + exports: Vec, + warnings: Vec, +} + +#[derive(Debug, Clone)] +struct ObjdumpText { + stdout: String, + raw_blocks: Vec, + warnings: Vec, +} + +#[derive(Debug, Clone)] +struct ImportTarget { + target: u64, + library: String, + api: String, + category: ApiCategory, +} + +#[derive(Debug, Clone)] +struct InstructionRow { + address: u64, + raw: String, + kind: Option, + comment_target: Option, +} + +#[allow( + clippy::too_many_lines, + reason = "CLI flags are kept in one match so command-specific validation is visible" +)] +fn parse_cli_from(args: I, tool: ToolKind) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + paths: Vec::new(), + llvm_bin_dir: None, + llvm_args: Vec::new(), + raw_output_limit: DEFAULT_RAW_OUTPUT_LIMIT, + flags: ToolFlags::default(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("llvm-bin-dir") => { + cli.llvm_bin_dir = Some(PathBuf::from(parser_value_string( + &mut parser, + "--llvm-bin-dir", + )?)); + } + Long("llvm-arg") => cli + .llvm_args + .push(parser_value_string(&mut parser, "--llvm-arg")?), + Long("raw-output-limit") => { + let value = parser_value_string(&mut parser, "--raw-output-limit")?; + cli.raw_output_limit = parse_positive_usize_flag("--raw-output-limit", &value)?; + } + Long("name") => { + require_tool(tool, ToolKind::Exports, "--name")?; + cli.flags.exports.name = + Some(parser_value_string(&mut parser, "--name")?.to_ascii_lowercase()); + } + Long("ordinal") => { + require_tool(tool, ToolKind::Exports, "--ordinal")?; + let value = parser_value_string(&mut parser, "--ordinal")?; + cli.flags.exports.ordinal = Some(parse_u64_value("--ordinal", &value)?); + } + Long("forwarders-only") => { + require_tool(tool, ToolKind::Exports, "--forwarders-only")?; + cli.flags.exports.forwarders_only = true; + } + Long("undecorate") => { + require_tool(tool, ToolKind::Exports, "--undecorate")?; + cli.flags.exports.undecorate = true; + } + Long("category") => { + let category = + ApiCategory::parse(&parser_value_string(&mut parser, "--category")?)?; + match tool { + ToolKind::Imports => cli.flags.imports.categories.push(category), + ToolKind::Calls => cli.flags.calls.categories.push(category), + other => { + return Err(CliError::usage(format!( + "--category is not supported by {}", + other.command_name() + ))); + } + } + } + Long("library") => { + require_tool(tool, ToolKind::Imports, "--library")?; + cli.flags.imports.library = + Some(parser_value_string(&mut parser, "--library")?.to_ascii_lowercase()); + } + Long("api") => { + let api = parser_value_string(&mut parser, "--api")?.to_ascii_lowercase(); + match tool { + ToolKind::Imports => cli.flags.imports.api = Some(api), + ToolKind::Calls => cli.flags.calls.apis.push(api), + other => { + return Err(CliError::usage(format!( + "--api is not supported by {}", + other.command_name() + ))); + } + } + } + Long("suspicious") => { + require_tool(tool, ToolKind::Imports, "--suspicious")?; + cli.flags.imports.suspicious = true; + } + Long("include-jumps") => { + require_tool(tool, ToolKind::Calls, "--include-jumps")?; + cli.flags.calls.include_jumps = true; + } + Long("context") => { + require_tool(tool, ToolKind::Calls, "--context")?; + let value = parser_value_string(&mut parser, "--context")?; + cli.flags.calls.context = value + .parse::() + .map_err(|error| CliError::usage(format!("--context expects N: {error}")))?; + } + Long("min-confidence") => { + require_tool(tool, ToolKind::Signatures, "--min-confidence")?; + cli.flags.signatures.min_confidence = + parse_confidence(&parser_value_string(&mut parser, "--min-confidence")?)?; + } + Long("include-thunks") => { + require_tool(tool, ToolKind::Signatures, "--include-thunks")?; + cli.flags.signatures.include_thunks = true; + } + Long("function") => { + require_tool(tool, ToolKind::Signatures, "--function")?; + cli.flags.signatures.function = + Some(parser_value_string(&mut parser, "--function")?.to_ascii_lowercase()); + } + Long("min-len") => { + require_tool(tool, ToolKind::StringRefs, "--min-len")?; + let value = parser_value_string(&mut parser, "--min-len")?; + cli.flags.strings.min_len = parse_positive_usize_flag("--min-len", &value)?; + } + Long("contains") => { + require_tool(tool, ToolKind::StringRefs, "--contains")?; + cli.flags.strings.contains = + Some(parser_value_string(&mut parser, "--contains")?.to_ascii_lowercase()); + } + Long("regex") => { + require_tool(tool, ToolKind::StringRefs, "--regex")?; + let value = parser_value_string(&mut parser, "--regex")?; + cli.flags.strings.regex = Some( + Regex::new(&value) + .map_err(|error| CliError::usage(format!("invalid --regex: {error}")))?, + ); + } + Long("encoding") => { + require_tool(tool, ToolKind::StringRefs, "--encoding")?; + cli.flags.strings.encoding = + parse_encoding(&parser_value_string(&mut parser, "--encoding")?)?; + } + Long("max-refs") => { + require_tool(tool, ToolKind::StringRefs, "--max-refs")?; + let value = parser_value_string(&mut parser, "--max-refs")?; + cli.flags.strings.max_refs = value + .parse::() + .map_err(|error| CliError::usage(format!("--max-refs expects N: {error}")))?; + } + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + Ok((ParseOutcome::Run, cli)) +} + +fn full_help(tool: ToolKind) -> String { + match tool { + ToolKind::Exports => tool.help().to_owned(), + ToolKind::Imports + | ToolKind::Calls + | ToolKind::Signatures + | ToolKind::StringRefs + | ToolKind::DriverShape + | ToolKind::IoctlScan => { + let examples = match tool { + ToolKind::Imports => { + "Examples:\n peimports .\\target\\release-fast\\portping.exe --category device_io --json | ConvertFrom-Json\n fd -e exe .\\target\\release-fast | peimports --input-format lines --toon\n" + } + ToolKind::Calls => { + "Examples:\n pecalls .\\target\\release-fast\\portping.exe --api WSAStartup --json | ConvertFrom-Json\n fd -e exe .\\target\\release-fast | pecalls --input-format lines --api WSAStartup --toon\n" + } + ToolKind::Signatures => { + "Examples:\n pesig .\\target\\release-fast\\portping.exe --min-confidence medium --json | ConvertFrom-Json\n fd -e exe .\\target\\release-fast | pesig --input-format lines --function StartProject --toon\n" + } + ToolKind::StringRefs => { + "Examples:\n pestrrefs .\\target\\release-fast\\portping.exe --contains DeviceIoControl --json | ConvertFrom-Json\n fd -e exe .\\target\\release-fast | pestrrefs --input-format lines --contains DeviceIoControl --toon\n" + } + ToolKind::DriverShape => { + "Examples:\n drvshape C:\\Windows\\System32\\drivers\\ndis.sys --json | ConvertFrom-Json\n fd -e sys C:\\Windows\\System32\\drivers | drvshape --input-format lines --toon\n" + } + ToolKind::IoctlScan => { + "Examples:\n ioctlscan C:\\Windows\\System32\\drivers\\ndis.sys --json | ConvertFrom-Json\n fd -e sys C:\\Windows\\System32\\drivers | ioctlscan --input-format lines --toon\n" + } + ToolKind::Exports => unreachable!("handled above"), + }; + format!( + "{}\n{}\n -h, --help Show this help text\n -V, --version Show the command version\n\n{examples}", + tool.help().trim_end(), + SHARED_OPTIONS.trim_start_matches('\n') + ) + } + } +} + +fn require_tool(actual: ToolKind, expected: ToolKind, flag: &str) -> Result<(), CliError> { + if actual == expected { + Ok(()) + } else { + Err(CliError::usage(format!( + "{flag} is not supported by {}", + actual.command_name() + ))) + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_u64_value(flag: &str, value: &str) -> Result { + value + .parse::() + .ok() + .or_else(|| parse_hex_u64(value)) + .ok_or_else(|| CliError::usage(format!("{flag} expects a decimal or hex integer"))) +} + +fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { + let parsed = value + .parse::() + .map_err(|error| CliError::usage(format!("{flag} expects N: {error}")))?; + if parsed == 0 { + return Err(CliError::usage(format!( + "{flag} expects N greater than zero" + ))); + } + Ok(parsed) +} + +fn parse_confidence(value: &str) -> Result { + match value { + "low" => Ok(Confidence::Low), + "medium" => Ok(Confidence::Medium), + "high" => Ok(Confidence::High), + other => Err(CliError::usage(format!( + "invalid --min-confidence value '{other}'; expected low, medium, or high" + ))), + } +} + +fn parse_encoding(value: &str) -> Result { + match value { + "ascii" => Ok(EncodingMode::Ascii), + "utf16le" | "utf-16le" => Ok(EncodingMode::Utf16Le), + "both" => Ok(EncodingMode::Both), + other => Err(CliError::usage(format!( + "invalid --encoding value '{other}'; expected ascii, utf16le, or both" + ))), + } +} + +fn run(tool: ToolKind, cli: &Cli) -> Result { + if tool == ToolKind::Calls + && cli.flags.calls.apis.is_empty() + && cli.flags.calls.categories.is_empty() + { + return Err(CliError::usage( + "pecalls requires at least one --api or --category filter", + )); + } + + let paths = collect_paths(cli, tool.command_name())?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let mut files = Vec::new(); + let mut parse_warnings = Vec::new(); + let mut raw_blocks = Vec::new(); + for path in &paths { + let snapshot = load_pe_snapshot(path)?; + let objdump = if tool.needs_objdump() { + Some(run_objdump(cli, path)?) + } else { + None + }; + if let Some(objdump) = &objdump { + parse_warnings.extend(objdump.warnings.clone()); + raw_blocks.extend(objdump.raw_blocks.clone()); + } + files.push(analyze_file(tool, cli, &snapshot, objdump.as_ref())); + } + + let summary = summarize_report(tool, &files); + let report_quality = + assess_report_quality(tool, &summary, &files, &parse_warnings, &raw_blocks); + let next_actions = suggest_next_actions(tool, &paths, &summary, &files); + let report = ToolReport { + tool: tool.command_name(), + inputs: paths, + summary, + report_quality, + next_actions, + files, + parse_warnings, + raw_blocks, + }; + + match cli.common.render_mode() { + RenderMode::Json | RenderMode::Toon => print_structured(&report, cli.common.render_mode())?, + RenderMode::Text => print!("{}", render_text_report(&report)), + } + Ok(if report.summary.result_count == 0 { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn collect_paths(cli: &Cli, command_name: &str) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if let Some(paths) = common::read_existing_stdin_path_records( + &buffer, + cli.common.input_format, + command_name, + )? { + return Ok(paths); + } + } + common::expand_input_patterns(&cli.paths, command_name) +} + +fn run_objdump(cli: &Cli, path: &Path) -> Result { + let mut args = vec![ + "--file-headers".to_owned(), + "--section-headers".to_owned(), + "--disassemble".to_owned(), + "--demangle".to_owned(), + ]; + args.extend(cli.llvm_args.iter().cloned()); + args.push(path.display().to_string()); + let backend = run_llvm_binary( + "llvm-objdump", + cli.llvm_bin_dir.as_deref(), + &args, + cli.raw_output_limit, + )?; + if backend.exit_code != 0 { + return Err(CliError::runtime(format!( + "llvm-objdump failed for {} with exit code {}: {}", + path.display(), + backend.exit_code, + tail(&backend.stderr, 2000).unwrap_or_default() + ))); + } + let mut warnings = Vec::new(); + let mut raw_blocks = Vec::new(); + if backend.stdout_truncated { + warnings.push(format!( + "llvm-objdump stdout for {} was truncated", + path.display() + )); + raw_blocks.push(RawBlock { + path: Some(path.to_path_buf()), + label: "llvm-objdump-truncated".to_owned(), + text: backend.stdout.clone(), + truncated: true, + }); + } + if backend.stderr_truncated || !backend.stderr.trim().is_empty() { + raw_blocks.push(RawBlock { + path: Some(path.to_path_buf()), + label: "llvm-objdump-stderr".to_owned(), + text: backend.stderr.clone(), + truncated: backend.stderr_truncated, + }); + } + Ok(ObjdumpText { + stdout: backend.stdout, + raw_blocks, + warnings, + }) +} + +fn load_pe_snapshot(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + let pe = PE::parse(&bytes) + .map_err(|error| CliError::runtime(format!("{} is not a PE: {error}", path.display())))?; + + let machine_raw = pe.header.coff_header.machine; + let machine = machine_to_str(machine_raw).to_owned(); + let arch = architecture_label(machine_raw).to_owned(); + let bitness = if pe.is_64 { 64 } else { 32 }; + let image_base = pe.image_base; + let entry_rva = u64::from(pe.entry); + let entry_point = image_base.saturating_add(entry_rva); + let subsystem_raw = pe + .header + .optional_header + .as_ref() + .map_or(0, |header| header.windows_fields.subsystem); + let subsystem = subsystem_label(subsystem_raw).to_owned(); + let export_ordinal_base = pe + .export_data + .as_ref() + .map_or(1, |data| data.export_directory_table.ordinal_base); + let sections = pe + .sections + .iter() + .map(|section| PeSection { + name: section + .name() + .map_or_else(|_| "".to_owned(), ToOwned::to_owned), + virtual_address: section.virtual_address, + virtual_size: section.virtual_size, + raw_offset: section.pointer_to_raw_data, + raw_size: section.size_of_raw_data, + }) + .collect::>(); + + let mut snapshot = PeSnapshot { + path: path.to_path_buf(), + bytes: bytes.clone(), + kind: pe_kind(path, pe.is_lib, subsystem_raw).to_owned(), + machine, + arch, + bitness, + image_base, + entry_point, + entry_rva, + export_ordinal_base, + subsystem, + subsystem_raw, + timestamp: pe.header.coff_header.time_date_stamp, + sections, + imports: Vec::new(), + exports: Vec::new(), + warnings: Vec::new(), + }; + + snapshot.imports = pe + .imports + .iter() + .map(|import| snapshot.import_from_goblin(import)) + .collect(); + snapshot.exports = pe + .exports + .iter() + .enumerate() + .map(|(index, export)| { + let ordinal = pe + .export_data + .as_ref() + .and_then(|data| data.export_ordinal_table.get(index).copied()) + .map(|ordinal| u64::from(snapshot.export_ordinal_base) + u64::from(ordinal)); + snapshot.export_from_goblin(ordinal, export) + }) + .collect(); + Ok(snapshot) +} + +impl PeSnapshot { + fn import_from_goblin(&self, import: &goblin::pe::import::Import<'_>) -> PeImport { + let name = import.name.to_string(); + let library = import.dll.to_ascii_lowercase(); + let iat_rva = u64::try_from(import.offset).unwrap_or(0); + let hint_name_rva = (import.rva != 0).then(|| u64::try_from(import.rva).unwrap_or(0)); + PeImport { + library: library.clone(), + name: name.clone(), + ordinal: (import.ordinal != 0).then_some(import.ordinal), + hint_name_rva, + iat_rva, + iat_va: self.image_base.saturating_add(iat_rva), + slot_size: import.size, + section: self + .section_for_rva(iat_rva) + .map(|section| section.name.clone()), + category: categorize_import(&library, &name), + } + } + + fn export_from_goblin( + &self, + ordinal: Option, + export: &goblin::pe::export::Export<'_>, + ) -> PeExport { + let rva = u64::try_from(export.rva).unwrap_or(0); + let forwarder = export.reexport.as_ref().map(format_reexport); + PeExport { + name: export.name.map(str::to_owned), + display_name: export.name.map(undecorate_name), + ordinal, + rva, + va: self.image_base.saturating_add(rva), + file_offset: export.offset, + forwarder, + section: self + .section_for_rva(rva) + .map(|section| section.name.clone()), + is_entrypoint: rva == self.entry_rva, + } + } + + fn section_for_rva(&self, rva: u64) -> Option<&PeSection> { + self.sections.iter().find(|section| { + let start = u64::from(section.virtual_address); + let len = u64::from(section.virtual_size.max(section.raw_size)).max(1); + rva >= start && rva < start.saturating_add(len) + }) + } + + fn offset_to_rva(&self, offset: usize) -> Option { + let offset = u64::try_from(offset).ok()?; + self.sections.iter().find_map(|section| { + let start = u64::from(section.raw_offset); + let len = u64::from(section.raw_size).max(1); + (offset >= start && offset < start.saturating_add(len)) + .then(|| u64::from(section.virtual_address).saturating_add(offset - start)) + }) + } + + fn map_string_hit(&self, mut hit: StringHit) -> StringHit { + if let Some(rva) = self.offset_to_rva(hit.offset) { + hit.rva = Some(rva); + hit.va = Some(self.image_base.saturating_add(rva)); + hit.section = self + .section_for_rva(rva) + .map(|section| section.name.clone()); + } + hit + } +} + +fn analyze_file( + tool: ToolKind, + cli: &Cli, + snapshot: &PeSnapshot, + objdump: Option<&ObjdumpText>, +) -> FileReport { + let mut exports = Vec::new(); + let mut imports = Vec::new(); + let mut categories = Vec::new(); + let mut callsites = Vec::new(); + let mut functions = Vec::new(); + let mut strings = Vec::new(); + let mut drivers = None; + let mut ioctls = Vec::new(); + let mut warnings = snapshot.warnings.clone(); + + match tool { + ToolKind::Exports => { + exports = filter_exports(&snapshot.exports, &cli.flags.exports); + } + ToolKind::Imports => { + imports = filter_imports(&snapshot.imports, &cli.flags.imports); + categories = summarize_import_categories(&imports); + } + ToolKind::Calls => { + if let Some(objdump) = objdump { + let query = callsite_query(&cli.flags.calls); + let import_targets = snapshot + .imports + .iter() + .map(|import| ImportTarget { + target: import.iat_va, + library: import.library.clone(), + api: import.name.clone(), + category: import.category, + }) + .collect::>(); + let report = + analyze_callsites_with_targets(&objdump.stdout, &import_targets, &query); + if !report.unresolved_calls.is_empty() { + warnings.push(format!( + "{} unresolved callsites were skipped", + report.unresolved_calls.len() + )); + } + callsites = report.callsites; + } + } + ToolKind::Signatures => { + if let Some(objdump) = objdump { + functions = infer_functions(snapshot, &objdump.stdout, &cli.flags.signatures); + } + } + ToolKind::StringRefs => { + let disassembly = objdump.map(|text| text.stdout.as_str()).unwrap_or_default(); + strings = string_references(snapshot, disassembly, &cli.flags.strings); + } + ToolKind::DriverShape => { + let discovered_ioctls = if has_ioctl_scan_context(snapshot) { + scan_ioctls(snapshot, &IoctlScanOptions::default()) + } else { + Vec::new() + }; + drivers = Some(shape_driver(snapshot, &discovered_ioctls)); + } + ToolKind::IoctlScan => { + if has_ioctl_scan_context(snapshot) { + ioctls = scan_ioctls(snapshot, &IoctlScanOptions::default()); + } else { + warnings.push( + "ioctl scan skipped: no driver, DeviceIoControl, or driver framework import evidence" + .to_owned(), + ); + } + } + } + + FileReport { + path: snapshot.path.clone(), + kind: snapshot.kind.clone(), + machine: snapshot.machine.clone(), + arch: snapshot.arch.clone(), + bitness: snapshot.bitness, + image_base: snapshot.image_base, + entry_point: snapshot.entry_point, + subsystem: snapshot.subsystem.clone(), + sections: snapshot.sections.clone(), + warnings, + exports, + imports, + categories, + callsites, + functions, + strings, + drivers, + ioctls, + } +} + +fn summarize_report(tool: ToolKind, files: &[FileReport]) -> Summary { + let mut summary = Summary { + file_count: files.len(), + ..Summary::default() + }; + for file in files { + summary.export_count += file.exports.len(); + summary.import_count += file.imports.len(); + summary.category_count += file.categories.len(); + summary.callsite_count += file.callsites.len(); + summary.function_count += file.functions.len(); + summary.string_count += file.strings.len(); + summary.driver_count += file + .drivers + .as_ref() + .and_then(|driver| { + driver + .roles + .iter() + .any(|role| role != "unknown") + .then_some(1) + }) + .unwrap_or(0); + summary.ioctl_count += file + .drivers + .as_ref() + .map_or(file.ioctls.len(), |driver| driver.ioctl_summary.count); + summary.warning_count += file.warnings.len(); + } + summary.result_count = match tool { + ToolKind::Exports => summary.export_count, + ToolKind::Imports => summary.import_count, + ToolKind::Calls => summary.callsite_count, + ToolKind::Signatures => summary.function_count, + ToolKind::StringRefs => summary.string_count, + ToolKind::DriverShape => summary.driver_count, + ToolKind::IoctlScan => summary.ioctl_count, + }; + summary +} + +fn assess_report_quality( + tool: ToolKind, + summary: &Summary, + files: &[FileReport], + parse_warnings: &[String], + raw_blocks: &[RawBlock], +) -> ReportQuality { + let warning_count = summary.warning_count + parse_warnings.len(); + let has_truncated_raw = raw_blocks.iter().any(|block| block.truncated); + let confidence = if has_truncated_raw || warning_count > 3 { + Confidence::Low + } else if warning_count > 0 || summary.result_count == 0 { + Confidence::Medium + } else { + Confidence::High + }; + let completeness = if has_truncated_raw { + ReportCompleteness::Limited + } else if warning_count > 0 { + ReportCompleteness::Partial + } else { + ReportCompleteness::Complete + }; + + let mut evidence = vec![tool_primary_evidence(tool).to_owned()]; + if tool.needs_objdump() { + evidence.push("LLVM objdump completed for code-reference evidence".to_owned()); + } + if files.iter().any(|file| !file.sections.is_empty()) { + evidence.push("PE section map is available for VA/RVA context".to_owned()); + } + + let mut limitations = Vec::new(); + if summary.result_count == 0 { + limitations.push("No matching rows survived the active filters".to_owned()); + } + if !parse_warnings.is_empty() { + limitations.push(format!( + "{} parser warning(s) should be reviewed before treating the report as exhaustive", + parse_warnings.len() + )); + } + if has_truncated_raw { + limitations + .push("At least one LLVM raw block was truncated by --raw-output-limit".to_owned()); + } + if tool.needs_objdump() { + limitations + .push("Disassembly-derived matches are direct-address best-effort signals".to_owned()); + } + + ReportQuality { + confidence, + completeness, + evidence, + limitations, + } +} + +fn tool_primary_evidence(tool: ToolKind) -> &'static str { + match tool { + ToolKind::Exports => "PE headers and export table parsed", + ToolKind::Imports => "PE headers and import table parsed", + ToolKind::Calls => "PE imports and LLVM disassembly parsed", + ToolKind::Signatures => "PE metadata and function-boundary hints parsed", + ToolKind::StringRefs => "PE bytes, strings, and direct disassembly references scanned", + ToolKind::DriverShape => "PE driver metadata, imports, exports, and hints parsed", + ToolKind::IoctlScan => "PE bytes and disassembly-adjacent IOCTL candidates scanned", + } +} + +fn suggest_next_actions( + tool: ToolKind, + paths: &[PathBuf], + summary: &Summary, + files: &[FileReport], +) -> Vec { + let Some(first_path) = paths.first() else { + return Vec::new(); + }; + let path_arg = powershell_path_arg(first_path); + let mut actions = Vec::new(); + + match tool { + ToolKind::Exports => { + actions.push(next_action( + "Inspect PE structure with LLVM", + format!("llvmreadobj {path_arg} --exports --json | ConvertFrom-Json"), + "Correlates exports with headers, sections, and debug hints.", + )); + actions.push(next_action( + "Categorize imported dependencies", + format!("peimports {path_arg} --json | ConvertFrom-Json"), + "Shows what this export surface depends on at load time.", + )); + actions.push(next_action( + "Infer callable entrypoints", + format!("pesig {path_arg} --min-confidence medium --json | ConvertFrom-Json"), + "Adds likely function boundaries and calling-convention hints.", + )); + } + ToolKind::Imports => { + let category = first_interesting_category(files).unwrap_or(ApiCategory::DeviceIo); + actions.push(next_action( + "Hunt imported API callsites", + format!( + "pecalls {path_arg} --category {} --json | ConvertFrom-Json", + category.label() + ), + "Turns an import family into concrete callsite addresses.", + )); + actions.push(next_action( + "Read raw PE import details", + format!("llvmreadobj {path_arg} --imports --json | ConvertFrom-Json"), + "Cross-checks import descriptors with the LLVM backend.", + )); + actions.push(next_action( + "Profile the binary role", + format!("drvshape {path_arg} --json | ConvertFrom-Json"), + "Useful when native or driver-like imports deserve a role summary.", + )); + } + ToolKind::Calls => { + actions.push(next_action( + "Review import categories", + format!("peimports {path_arg} --json | ConvertFrom-Json"), + "Finds other API families worth turning into callsite filters.", + )); + actions.push(next_action( + "Search strings around the same binary", + format!("pestrrefs {path_arg} --json | ConvertFrom-Json"), + "Pairs callsites with nearby human-readable evidence.", + )); + actions.push(next_action( + "Inspect function boundaries", + format!("pesig {path_arg} --min-confidence medium --json | ConvertFrom-Json"), + "Places callsites inside likely functions.", + )); + } + ToolKind::Signatures => { + actions.push(next_action( + "Categorize imported APIs", + format!("peimports {path_arg} --json | ConvertFrom-Json"), + "Connects inferred functions to dependency families.", + )); + actions.push(next_action( + "Hunt device I/O calls", + format!("pecalls {path_arg} --category device_io --json | ConvertFrom-Json"), + "Checks whether high-value functions reach IOCTL or NT device APIs.", + )); + actions.push(next_action( + "Inspect disassembly context", + format!("llvmobjdump {path_arg} --json | ConvertFrom-Json"), + "Shows the LLVM parse and raw disassembly blocks behind the hints.", + )); + } + ToolKind::StringRefs => { + actions.push(next_action( + "Profile imports next", + format!("peimports {path_arg} --json | ConvertFrom-Json"), + "Connects string evidence to API families.", + )); + actions.push(next_action( + "Find device I/O callsites", + format!("pecalls {path_arg} --category device_io --json | ConvertFrom-Json"), + "Pairs strings with confirmed imported API callsites.", + )); + actions.push(next_action( + "Decode probable IOCTL constants", + format!("ioctlscan {path_arg} --json | ConvertFrom-Json"), + "Looks for CTL_CODE values referenced by the same binary.", + )); + } + ToolKind::DriverShape => { + actions.push(next_action( + "Decode IOCTL constants", + format!("ioctlscan {path_arg} --json | ConvertFrom-Json"), + "Expands driver-control hints into decoded CTL_CODE candidates.", + )); + actions.push(next_action( + "Categorize driver imports", + format!("peimports {path_arg} --json | ConvertFrom-Json"), + "Shows the exact API families behind each driver role hint.", + )); + actions.push(next_action( + "Find device-control callsites", + format!("pecalls {path_arg} --category device_io --json | ConvertFrom-Json"), + "Moves from role hints to direct code locations.", + )); + } + ToolKind::IoctlScan => { + actions.push(next_action( + "Profile driver shape", + format!("drvshape {path_arg} --json | ConvertFrom-Json"), + "Explains whether decoded constants appear in a likely driver context.", + )); + actions.push(next_action( + "Find DeviceIoControl callsites", + format!("pecalls {path_arg} --category device_io --json | ConvertFrom-Json"), + "Connects IOCTL constants to imported device-control APIs when present.", + )); + actions.push(next_action( + "Search string evidence", + format!("pestrrefs {path_arg} --json | ConvertFrom-Json"), + "Looks for symbolic names, device paths, and nearby diagnostic strings.", + )); + } + } + + if summary.warning_count > 0 { + actions.push(next_action( + "Inspect warnings in structured output", + format!( + "{} {path_arg} --json | ConvertFrom-Json", + tool.command_name() + ), + "Structured output keeps per-file warnings separate from result rows.", + )); + } + actions.truncate(3); + actions +} + +fn first_interesting_category(files: &[FileReport]) -> Option { + let preferred = [ + ApiCategory::DeviceIo, + ApiCategory::Network, + ApiCategory::Registry, + ApiCategory::Filesystem, + ApiCategory::NativeNt, + ApiCategory::ProcessThread, + ]; + preferred.into_iter().find(|candidate| { + files.iter().any(|file| { + file.categories + .iter() + .any(|category| category.category == *candidate && category.count > 0) + }) + }) +} + +fn next_action(label: &str, command: String, why: &str) -> NextAction { + NextAction { + label: label.to_owned(), + command, + why: why.to_owned(), + } +} + +fn powershell_path_arg(path: &Path) -> String { + let rendered = path.display().to_string(); + if rendered.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '\\' | '/' | ':' | '_' | '-') + }) { + rendered + } else { + format!("'{}'", rendered.replace('\'', "''")) + } +} + +fn filter_exports(exports: &[PeExport], flags: &ExportFlags) -> Vec { + exports + .iter() + .filter(|export| { + if flags.forwarders_only && export.forwarder.is_none() { + return false; + } + if let Some(ordinal) = flags.ordinal { + if export.ordinal != Some(ordinal) { + return false; + } + } + if let Some(needle) = &flags.name { + let name = export + .name + .as_deref() + .or(export.display_name.as_deref()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !name.contains(needle) { + return false; + } + } + true + }) + .map(|export| { + let mut export = export.clone(); + if !flags.undecorate { + export.display_name = export.name.clone(); + } + export + }) + .collect() +} + +fn filter_imports(imports: &[PeImport], flags: &ImportFlags) -> Vec { + imports + .iter() + .filter(|import| { + if flags.suspicious && !import.category.is_suspicious() { + return false; + } + if !flags.categories.is_empty() && !flags.categories.contains(&import.category) { + return false; + } + if let Some(library) = &flags.library { + if !import.library.to_ascii_lowercase().contains(library) { + return false; + } + } + if let Some(api) = &flags.api { + if !import.name.to_ascii_lowercase().contains(api) { + return false; + } + } + true + }) + .cloned() + .collect() +} + +fn summarize_import_categories(imports: &[PeImport]) -> Vec { + let mut categories = BTreeMap::)>::new(); + for import in imports { + let (count, libraries) = categories.entry(import.category).or_default(); + *count += 1; + libraries.insert(import.library.clone()); + } + categories + .into_iter() + .map(|(category, (count, libraries))| CategorySummary { + category, + count, + libraries: libraries.into_iter().collect(), + }) + .collect() +} + +fn callsite_query(flags: &CallFlags) -> CallsiteQuery { + let mut query = if flags.apis.is_empty() { + CallsiteQuery::with_categories(flags.categories.clone()) + } else { + CallsiteQuery::apis(flags.apis.iter().map(String::as_str)) + }; + query.categories.clone_from(&flags.categories); + query.include_jumps = flags.include_jumps; + query.context = flags.context; + query +} + +fn analyze_callsites_with_targets( + disassembly: &str, + imports: &[ImportTarget], + query: &CallsiteQuery, +) -> CallsiteReport { + let rows = instruction_rows(disassembly); + let by_target = imports + .iter() + .map(|target| (target.target, target)) + .collect::>(); + let mut report = CallsiteReport::default(); + + for (index, row) in rows.iter().enumerate() { + let Some(kind) = &row.kind else { + continue; + }; + if kind == "jmp" && !query.include_jumps { + continue; + } + let Some(target) = row.comment_target else { + if kind == "jmp" { + continue; + } + report.unresolved_calls.push(UnresolvedCall { + address: row.address, + kind: kind.clone(), + raw: row.raw.clone(), + }); + continue; + }; + let Some(import) = by_target.get(&target) else { + report.unresolved_calls.push(UnresolvedCall { + address: row.address, + kind: kind.clone(), + raw: row.raw.clone(), + }); + continue; + }; + if !query.matches(&import.api, import.category) { + continue; + } + report.callsites.push(Callsite { + address: row.address, + target, + library: import.library.clone(), + api: import.api.clone(), + category: import.category, + kind: kind.clone(), + raw: row.raw.clone(), + context: context_lines(&rows, index, query.context), + }); + } + report +} + +fn instruction_rows(disassembly: &str) -> Vec { + disassembly + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + let (address, rest) = trimmed.split_once(':')?; + let address = parse_hex_u64(address)?; + let text = strip_instruction_bytes(rest.trim()); + let lower = text.to_ascii_lowercase(); + let kind = if lower.starts_with("call") { + Some("call".to_owned()) + } else if lower.starts_with("jmp") { + Some("jmp".to_owned()) + } else { + None + }; + Some(InstructionRow { + address, + raw: line.to_owned(), + kind, + comment_target: parse_comment_target(text), + }) + }) + .collect() +} + +fn strip_instruction_bytes(rest: &str) -> &str { + let mut cursor = 0; + let bytes = rest.as_bytes(); + loop { + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + let start = cursor; + while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if start == cursor { + return rest[cursor..].trim(); + } + let token = &rest[start..cursor]; + if token.len() != 2 || !token.chars().all(|ch| ch.is_ascii_hexdigit()) { + return rest[start..].trim(); + } + } +} + +fn parse_comment_target(text: &str) -> Option { + let (_, comment) = text.split_once('#')?; + comment + .split(|character: char| character.is_whitespace() || character == '<' || character == '>') + .find_map(parse_hex_u64) +} + +fn context_lines(rows: &[InstructionRow], index: usize, radius: usize) -> Vec { + if radius == 0 { + return Vec::new(); + } + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(rows.len()); + rows[start..end].iter().map(|row| row.raw.clone()).collect() +} + +fn infer_functions( + snapshot: &PeSnapshot, + disassembly: &str, + flags: &SignatureFlags, +) -> Vec { + let mut functions = parse_symbol_functions(snapshot, disassembly); + if functions.is_empty() { + functions.push(FunctionSummary { + name: Some("entrypoint".to_owned()), + start_va: snapshot.entry_point, + end_va: None, + size: None, + source: "entrypoint".to_owned(), + calling_convention: calling_convention_for_name(snapshot.bitness, "entrypoint").0, + confidence: Confidence::Low, + evidence: vec!["PE optional header entrypoint".to_owned()], + }); + } + functions.extend(snapshot.exports.iter().filter_map(|export| { + export.name.as_ref().map(|name| { + let (calling_convention, confidence) = + calling_convention_for_name(snapshot.bitness, name); + FunctionSummary { + name: Some(name.clone()), + start_va: export.va, + end_va: None, + size: None, + source: "export".to_owned(), + calling_convention, + confidence, + evidence: vec!["PE export table".to_owned()], + } + }) + })); + + functions.sort_by_key(|function| function.start_va); + functions.dedup_by(|left, right| left.start_va == right.start_va && left.name == right.name); + functions + .into_iter() + .filter(|function| function.confidence >= flags.min_confidence) + .filter(|function| flags.include_thunks || function.size.unwrap_or(8) > 6) + .filter(|function| { + flags.function.as_ref().is_none_or(|needle| { + function + .name + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .contains(needle) + }) + }) + .collect() +} + +fn parse_symbol_functions(snapshot: &PeSnapshot, disassembly: &str) -> Vec { + let mut symbols = disassembly + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if !(trimmed.ends_with(">:") && trimmed.contains('<')) { + return None; + } + let (address, rest) = trimmed.split_once('<')?; + let start_va = parse_hex_u64(address.trim())?; + let name = rest.trim_end_matches(">:").trim().to_owned(); + Some((start_va, name)) + }) + .collect::>(); + symbols.sort_by_key(|(start_va, _name)| *start_va); + let mut functions = Vec::new(); + for (index, (start_va, name)) in symbols.iter().enumerate() { + let end_va = symbols.get(index + 1).map(|(address, _name)| *address); + let size = end_va.map(|end| end.saturating_sub(*start_va)); + let (calling_convention, confidence) = calling_convention_for_name(snapshot.bitness, name); + functions.push(FunctionSummary { + name: Some(name.clone()), + start_va: *start_va, + end_va, + size, + source: "llvm_symbol".to_owned(), + calling_convention, + confidence, + evidence: vec!["llvm-objdump symbol label".to_owned()], + }); + } + functions +} + +fn calling_convention_for_name(bitness: u8, name: &str) -> (String, Confidence) { + if bitness == 64 { + return ("windows_x64".to_owned(), Confidence::High); + } + if name.starts_with('@') && name.rsplit_once('@').is_some() { + return ("fastcall".to_owned(), Confidence::High); + } + if name.starts_with('_') + && name + .rsplit_once('@') + .is_some_and(|(_head, bytes)| bytes.chars().all(|ch| ch.is_ascii_digit())) + { + return ("stdcall".to_owned(), Confidence::High); + } + if name.starts_with('?') { + return ("thiscall_or_msvc_cxx".to_owned(), Confidence::Medium); + } + ("cdecl_or_unknown".to_owned(), Confidence::Low) +} + +fn string_references( + snapshot: &PeSnapshot, + disassembly: &str, + flags: &StringFlags, +) -> Vec { + let mut hits = match flags.encoding { + EncodingMode::Ascii => scan_ascii_strings(&snapshot.bytes, flags.min_len), + EncodingMode::Utf16Le => scan_utf16le_strings(&snapshot.bytes, flags.min_len), + EncodingMode::Both => scan_strings(&snapshot.bytes, flags.min_len), + }; + hits = hits + .into_iter() + .filter(|hit| hit.value.len() <= 128) + .map(|hit| snapshot.map_string_hit(hit)) + .filter(|hit| { + flags + .contains + .as_ref() + .is_none_or(|needle| hit.value.to_ascii_lowercase().contains(needle)) + && flags + .regex + .as_ref() + .is_none_or(|regex| regex.is_match(&hit.value)) + }) + .collect(); + hits.into_iter() + .map(|hit| { + let references = find_string_references(disassembly, hit.va, flags.max_refs); + StringRefHit { + value: hit.value, + encoding: hit.encoding, + offset: hit.offset, + rva: hit.rva, + va: hit.va, + section: hit.section, + references, + unresolved_reference_count: 0, + } + }) + .collect() +} + +fn find_string_references( + disassembly: &str, + va: Option, + max_refs: usize, +) -> Vec { + let Some(va) = va else { + return Vec::new(); + }; + let lower_hex = format!("0x{va:x}"); + let upper_hex = format!("0x{va:X}"); + disassembly + .lines() + .filter(|line| line.contains(&lower_hex) || line.contains(&upper_hex)) + .filter_map(|line| { + let (address, _rest) = line.trim().split_once(':')?; + Some(StringReference { + address: parse_hex_u64(address)?, + raw: line.to_owned(), + }) + }) + .take(max_refs) + .collect() +} + +fn shape_driver(snapshot: &PeSnapshot, ioctls: &[DecodedIoctl]) -> DriverShape { + let import_names = snapshot + .imports + .iter() + .map(|import| import.name.to_ascii_lowercase()) + .collect::>(); + let import_libraries = snapshot + .imports + .iter() + .map(|import| import.library.to_ascii_lowercase()) + .collect::>(); + + let mut roles = BTreeSet::new(); + if snapshot.kind == "driver" || snapshot.subsystem_raw == subsystem::IMAGE_SUBSYSTEM_NATIVE { + roles.insert("wdm_or_native_driver".to_owned()); + } + if import_names.iter().any(|name| name.starts_with("wdf")) + || any_library(&import_libraries, "wdf") + { + roles.insert("kmdf".to_owned()); + } + if any_library(&import_libraries, "ndis") + || import_names.iter().any(|name| name.starts_with("ndis")) + { + roles.insert("ndis".to_owned()); + } + if any_library(&import_libraries, "fltmgr") + || import_names + .iter() + .any(|name| name.contains("fltregisterfilter")) + { + roles.insert("minifilter".to_owned()); + } + if import_names.iter().any(|name| name.contains("fsrtl")) + || import_names + .iter() + .any(|name| name.contains("ioregisterfilesystem")) + { + roles.insert("filesystem".to_owned()); + } + if roles.is_empty() + && import_names + .iter() + .any(|name| name.contains("deviceiocontrol")) + { + roles.insert("user_mode_driver_helper".to_owned()); + } + let has_kernel_driver_evidence = roles.iter().any(|role| { + matches!( + role.as_str(), + "wdm_or_native_driver" | "kmdf" | "ndis" | "minifilter" | "filesystem" + ) + }); + if roles.is_empty() { + roles.insert("unknown".to_owned()); + } + + let mut hints = Vec::new(); + if ioctls + .iter() + .any(|ioctl| ioctl.method == IoctlMethod::Neither) + { + hints.push("method_neither_seen".to_owned()); + } + if import_names + .iter() + .any(|name| name.contains("mmmapiospace")) + { + hints.push("mm_map_io_space_seen".to_owned()); + } + if has_kernel_driver_evidence + && import_names + .iter() + .any(|name| name.starts_with("zw") || name.starts_with("nt")) + { + hints.push("zw_calls_seen".to_owned()); + } + if import_names + .iter() + .any(|name| name.contains("iocreatesymboliclink")) + { + hints.push("symbolic_link_seen".to_owned()); + } + if import_names + .iter() + .any(|name| name.contains("deviceiocontrol") || name.contains("iodevicecontrol")) + || !ioctls.is_empty() + { + hints.push("device_control_dispatch_seen".to_owned()); + } + + DriverShape { + roles: roles.into_iter().collect(), + subsystem: snapshot.subsystem.clone(), + machine: snapshot.machine.clone(), + timestamp: snapshot.timestamp, + import_categories: summarize_import_categories(&snapshot.imports), + exports: snapshot + .exports + .iter() + .filter_map(|export| export.name.clone()) + .collect(), + pdb_hints: pdb_hints(snapshot), + driver_entry_hints: driver_entry_hints(snapshot), + device_creation_hints: device_creation_hints(snapshot), + ioctl_summary: ioctl_summary(ioctls), + hints, + } +} + +fn scan_ioctls(snapshot: &PeSnapshot, options: &IoctlScanOptions) -> Vec { + let mut by_code = BTreeMap::::new(); + for offset in ioctl_candidate_offsets(snapshot) { + let Some(window) = snapshot.bytes.get(offset..offset.saturating_add(4)) else { + continue; + }; + let code = u32::from_le_bytes([window[0], window[1], window[2], window[3]]); + push_ioctl_candidate(snapshot, options, offset, code, &mut by_code); + } + by_code.into_values().collect() +} + +fn has_ioctl_scan_context(snapshot: &PeSnapshot) -> bool { + snapshot.kind == "driver" + || snapshot.imports.iter().any(|import| { + let library = import.library.to_ascii_lowercase(); + let name = import.name.to_ascii_lowercase(); + name.contains("deviceiocontrol") + || name.contains("iodevicecontrol") + || name.contains("ioctl") + || name.starts_with("wdf") + || name.starts_with("ndis") + || name.contains("fltregisterfilter") + || library.contains("wdf") + || library.contains("ndis") + || library.contains("fltmgr") + }) +} + +fn ioctl_candidate_offsets(snapshot: &PeSnapshot) -> Vec { + let mut offsets = Vec::new(); + for section in &snapshot.sections { + if !is_raw_constant_section(section) { + continue; + } + let Ok(start) = usize::try_from(section.raw_offset) else { + continue; + }; + let Ok(size) = usize::try_from(section.raw_size) else { + continue; + }; + let end = start.saturating_add(size).min(snapshot.bytes.len()); + let mut offset = align_up(start, 4); + while offset.saturating_add(4) <= end { + offsets.push(offset); + offset = offset.saturating_add(4); + } + } + + if offsets.is_empty() { + let mut offset = 0usize; + while offset.saturating_add(4) <= snapshot.bytes.len() { + offsets.push(offset); + offset = offset.saturating_add(4); + } + } + offsets +} + +fn is_raw_constant_section(section: &PeSection) -> bool { + let name = section.name.to_ascii_lowercase(); + name == ".rdata" || name == ".data" || name.contains("const") +} + +const fn align_up(value: usize, alignment: usize) -> usize { + if alignment == 0 { + value + } else { + let remainder = value % alignment; + if remainder == 0 { + value + } else { + match value.checked_add(alignment - remainder) { + Some(aligned) => aligned, + None => usize::MAX, + } + } + } +} + +fn push_ioctl_candidate( + snapshot: &PeSnapshot, + options: &IoctlScanOptions, + offset: usize, + code: u32, + by_code: &mut BTreeMap, +) { + if !options.is_probable_code(code) { + return; + } + let Some(mut decoded) = decode_ioctl(code) else { + return; + }; + let rva = snapshot.offset_to_rva(offset); + let section = rva + .and_then(|rva| snapshot.section_for_rva(rva)) + .map(|section| section.name.clone()); + "medium".clone_into(&mut decoded.confidence); + decoded.evidence.push("aligned_data_dword".to_owned()); + decoded.locations.push(IoctlLocation { + offset: Some(offset), + rva, + va: rva.map(|rva| snapshot.image_base.saturating_add(rva)), + section, + source: "aligned_data_dword".to_owned(), + }); + by_code + .entry(code) + .and_modify(|existing| { + existing.locations.extend(decoded.locations.clone()); + existing.evidence.extend(decoded.evidence.clone()); + }) + .or_insert(decoded); +} + +fn scan_ascii_strings(bytes: &[u8], min_len: usize) -> Vec { + let mut hits = Vec::new(); + let mut start = None::; + for (index, byte) in bytes.iter().copied().enumerate() { + if is_printable_ascii(byte) { + start.get_or_insert(index); + continue; + } + if let Some(run_start) = start.take() { + push_ascii_string(bytes, run_start, index, min_len, &mut hits); + } + } + if let Some(run_start) = start { + push_ascii_string(bytes, run_start, bytes.len(), min_len, &mut hits); + } + hits +} + +fn push_ascii_string( + bytes: &[u8], + start: usize, + end: usize, + min_len: usize, + hits: &mut Vec, +) { + if end.saturating_sub(start) < min_len { + return; + } + if let Ok(value) = std::str::from_utf8(&bytes[start..end]) { + hits.push(StringHit { + value: value.to_owned(), + encoding: "ascii".to_owned(), + offset: start, + rva: None, + va: None, + section: None, + }); + } +} + +fn scan_utf16le_strings(bytes: &[u8], min_len: usize) -> Vec { + let mut hits = Vec::new(); + scan_utf16le_aligned_strings(bytes, min_len, 0, &mut hits); + scan_utf16le_aligned_strings(bytes, min_len, 1, &mut hits); + hits +} + +fn scan_utf16le_aligned_strings( + bytes: &[u8], + min_len: usize, + alignment: usize, + hits: &mut Vec, +) { + let mut run_start = None::; + let mut units = Vec::new(); + let mut cursor = alignment; + + while cursor + 1 < bytes.len() { + let unit = u16::from_le_bytes([bytes[cursor], bytes[cursor + 1]]); + if is_printable_utf16(unit) { + run_start.get_or_insert(cursor); + units.push(unit); + } else if let Some(start) = run_start.take() { + push_utf16le_string(start, &units, min_len, hits); + units.clear(); + } + cursor += 2; + } + + if let Some(start) = run_start { + push_utf16le_string(start, &units, min_len, hits); + } +} + +fn push_utf16le_string(start: usize, units: &[u16], min_len: usize, hits: &mut Vec) { + if units.len() < min_len { + return; + } + + hits.push(StringHit { + value: String::from_utf16_lossy(units), + encoding: "utf16le".to_owned(), + offset: start, + rva: None, + va: None, + section: None, + }); +} + +fn is_printable_ascii(byte: u8) -> bool { + matches!(byte, 0x20..=0x7e) +} + +fn is_printable_utf16(unit: u16) -> bool { + matches!(unit, 0x20..=0x7e) +} + +fn normalize_name(value: &str) -> String { + value + .trim() + .trim_start_matches('_') + .trim_start_matches('@') + .trim_end_matches('A') + .trim_end_matches('W') + .chars() + .filter(|character| character.is_ascii_alphanumeric() || *character == '_') + .flat_map(char::to_lowercase) + .collect() +} + +fn parse_hex_u64(text: &str) -> Option { + let token = text + .trim() + .trim_matches(|character| character == '(' || character == ')' || character == ','); + let digits = token + .strip_prefix("0x") + .or_else(|| token.strip_prefix("0X")) + .unwrap_or(token); + if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + u64::from_str_radix(digits, 16).ok() +} + +fn access_label(access: u8) -> &'static str { + match access { + 0 => "FILE_ANY_ACCESS", + 1 => "FILE_READ_ACCESS", + 2 => "FILE_WRITE_ACCESS", + 3 => "FILE_READ_WRITE_ACCESS", + _ => "UNKNOWN_ACCESS", + } +} + +fn common_device_name(device_type: u16) -> Option<&'static str> { + match device_type { + 0x0001 => Some("FILE_DEVICE_BEEP"), + 0x0002 => Some("FILE_DEVICE_CD_ROM"), + 0x0003 => Some("FILE_DEVICE_CD_ROM_FILE_SYSTEM"), + 0x0004 => Some("FILE_DEVICE_CONTROLLER"), + 0x0005 => Some("FILE_DEVICE_DATALINK"), + 0x0006 => Some("FILE_DEVICE_DFS"), + 0x0007 => Some("FILE_DEVICE_DISK"), + 0x0008 => Some("FILE_DEVICE_DISK_FILE_SYSTEM"), + 0x0009 => Some("FILE_DEVICE_FILE_SYSTEM"), + 0x000A => Some("FILE_DEVICE_INPORT_PORT"), + 0x000B => Some("FILE_DEVICE_KEYBOARD"), + 0x000C => Some("FILE_DEVICE_MAILSLOT"), + 0x000D => Some("FILE_DEVICE_MIDI_IN"), + 0x000E => Some("FILE_DEVICE_MIDI_OUT"), + 0x000F => Some("FILE_DEVICE_MOUSE"), + 0x0010 => Some("FILE_DEVICE_MULTI_UNC_PROVIDER"), + 0x0011 => Some("FILE_DEVICE_NAMED_PIPE"), + 0x0012 => Some("FILE_DEVICE_NETWORK"), + 0x0013 => Some("FILE_DEVICE_NETWORK_BROWSER"), + 0x0014 => Some("FILE_DEVICE_NETWORK_FILE_SYSTEM"), + 0x0015 => Some("FILE_DEVICE_NULL"), + 0x0016 => Some("FILE_DEVICE_PARALLEL_PORT"), + 0x0017 => Some("FILE_DEVICE_PHYSICAL_NETCARD"), + 0x0018 => Some("FILE_DEVICE_PRINTER"), + 0x0019 => Some("FILE_DEVICE_SCANNER"), + 0x001A => Some("FILE_DEVICE_SERIAL_MOUSE_PORT"), + 0x001B => Some("FILE_DEVICE_SERIAL_PORT"), + 0x001C => Some("FILE_DEVICE_SCREEN"), + 0x001D => Some("FILE_DEVICE_SOUND"), + 0x001E => Some("FILE_DEVICE_STREAMS"), + 0x001F => Some("FILE_DEVICE_TAPE"), + 0x0020 => Some("FILE_DEVICE_TAPE_FILE_SYSTEM"), + 0x0021 => Some("FILE_DEVICE_TRANSPORT"), + 0x0022 => Some("FILE_DEVICE_UNKNOWN"), + 0x0023 => Some("FILE_DEVICE_VIDEO"), + 0x0024 => Some("FILE_DEVICE_VIRTUAL_DISK"), + 0x0025 => Some("FILE_DEVICE_WAVE_IN"), + 0x0026 => Some("FILE_DEVICE_WAVE_OUT"), + 0x0027 => Some("FILE_DEVICE_8042_PORT"), + 0x0028 => Some("FILE_DEVICE_NETWORK_REDIRECTOR"), + 0x0029 => Some("FILE_DEVICE_BATTERY"), + 0x002A => Some("FILE_DEVICE_BUS_EXTENDER"), + 0x002B => Some("FILE_DEVICE_MODEM"), + 0x002C => Some("FILE_DEVICE_VDM"), + 0x002D => Some("FILE_DEVICE_MASS_STORAGE"), + 0x002E => Some("FILE_DEVICE_SMB"), + 0x002F => Some("FILE_DEVICE_KS"), + 0x0030 => Some("FILE_DEVICE_CHANGER"), + 0x0031 => Some("FILE_DEVICE_SMARTCARD"), + 0x0032 => Some("FILE_DEVICE_ACPI"), + 0x0033 => Some("FILE_DEVICE_DVD"), + 0x0034 => Some("FILE_DEVICE_FULLSCREEN_VIDEO"), + 0x0035 => Some("FILE_DEVICE_DFS_FILE_SYSTEM"), + 0x0036 => Some("FILE_DEVICE_DFS_VOLUME"), + 0x0037 => Some("FILE_DEVICE_SERENUM"), + 0x0038 => Some("FILE_DEVICE_TERMSRV"), + 0x0039 => Some("FILE_DEVICE_KSEC"), + 0x003A => Some("FILE_DEVICE_FIPS"), + 0x003B => Some("FILE_DEVICE_INFINIBAND"), + 0x003E => Some("FILE_DEVICE_VMBUS"), + 0x003F => Some("FILE_DEVICE_CRYPT_PROVIDER"), + 0x0040 => Some("FILE_DEVICE_WPD"), + 0x0041 => Some("FILE_DEVICE_BLUETOOTH"), + 0x0042 => Some("FILE_DEVICE_MT_COMPOSITE"), + 0x0043 => Some("FILE_DEVICE_MT_TRANSPORT"), + 0x0044 => Some("FILE_DEVICE_BIOMETRIC"), + 0x0045 => Some("FILE_DEVICE_PMI"), + 0x0047 => Some("FILE_DEVICE_GPIO"), + 0x0048 => Some("FILE_DEVICE_USBEX"), + 0x0049 => Some("FILE_DEVICE_CONSOLE"), + 0x0050 => Some("FILE_DEVICE_NFP"), + 0x0051 => Some("FILE_DEVICE_SYSENV"), + 0x0052 => Some("FILE_DEVICE_VIRTUAL_BLOCK"), + 0x0053 => Some("FILE_DEVICE_POINT_OF_SERVICE"), + _ => None, + } +} + +fn is_driver_relevant_device_type(device_type: u16) -> bool { + matches!( + device_type, + 0x0007 | 0x0008 | 0x0009 | 0x0012 | 0x0014 | 0x0022 | 0x002D | 0x003E + ) || device_type >= 0x8000 +} + +fn architecture_label(machine_raw: u16) -> &'static str { + match machine_raw { + 0x014c => "x86", + 0x8664 => "x86_64", + 0xaa64 => "aarch64", + 0x01c0 | 0x01c4 => "arm", + _ => "unknown", + } +} + +fn subsystem_label(subsystem_raw: u16) -> &'static str { + match subsystem_raw { + subsystem::IMAGE_SUBSYSTEM_NATIVE => "native", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI => "windows_gui", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI => "windows_cui", + subsystem::IMAGE_SUBSYSTEM_NATIVE_WINDOWS => "native_windows", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_CE_GUI => "windows_ce_gui", + subsystem::IMAGE_SUBSYSTEM_EFI_APPLICATION => "efi_application", + subsystem::IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER => "efi_boot_service_driver", + subsystem::IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER => "efi_runtime_driver", + subsystem::IMAGE_SUBSYSTEM_EFI_ROM => "efi_rom", + subsystem::IMAGE_SUBSYSTEM_XBOX => "xbox", + subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION => "windows_boot_application", + _ => "unknown", + } +} + +fn pe_kind(path: &Path, is_lib: bool, subsystem_raw: u16) -> &'static str { + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("sys")) + || subsystem_raw == subsystem::IMAGE_SUBSYSTEM_NATIVE + { + "driver" + } else if is_lib { + "dll" + } else { + "exe" + } +} + +fn format_reexport(reexport: &Reexport<'_>) -> String { + match reexport { + Reexport::DLLName { export, lib } => format!("{lib}.{export}"), + Reexport::DLLOrdinal { ordinal, lib } => format!("{lib}.#{ordinal}"), + } +} + +fn undecorate_name(name: &str) -> String { + name.trim_start_matches('_') + .trim_start_matches('@') + .rsplit_once('@') + .map_or_else( + || name.to_owned(), + |(head, tail)| { + if tail.chars().all(|character| character.is_ascii_digit()) { + head.to_owned() + } else { + name.to_owned() + } + }, + ) +} + +fn any_library(libraries: &[String], needle: &str) -> bool { + libraries.iter().any(|library| library.contains(needle)) +} + +fn pdb_hints(snapshot: &PeSnapshot) -> Vec { + scan_ascii_strings(&snapshot.bytes, 6) + .into_iter() + .filter(|hit| hit.value.to_ascii_lowercase().contains(".pdb")) + .take(8) + .map(|hit| hit.value) + .collect() +} + +fn driver_entry_hints(snapshot: &PeSnapshot) -> Vec { + let mut hints = Vec::new(); + if snapshot.kind == "driver" && snapshot.entry_point != snapshot.image_base { + hints.push(format!("entrypoint=0x{:x}", snapshot.entry_point)); + } + for export in &snapshot.exports { + if export + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case("DriverEntry")) + { + hints.push("exported_driver_entry".to_owned()); + } + } + hints +} + +fn device_creation_hints(snapshot: &PeSnapshot) -> Vec { + snapshot + .imports + .iter() + .filter(|import| { + let name = import.name.to_ascii_lowercase(); + name.contains("iocreatedevice") + || name.contains("iocreatesymboliclink") + || name.contains("wdfdevicecreate") + }) + .map(|import| format!("{}!{}", import.library, import.name)) + .collect() +} + +fn ioctl_summary(ioctls: &[DecodedIoctl]) -> IoctlSummary { + IoctlSummary { + count: ioctls.len(), + method_neither_count: ioctls + .iter() + .filter(|ioctl| ioctl.method == IoctlMethod::Neither) + .count(), + custom_device_type_count: ioctls + .iter() + .filter(|ioctl| ioctl.device_type >= 0x8000) + .count(), + } +} + +fn render_text_report(report: &ToolReport) -> String { + let mut output = String::new(); + let _ = writeln!(output, "answer={}", answer_summary(report)); + let _ = writeln!( + output, + "trust confidence={} completeness={} evidence=\"{}\" limitations=\"{}\"", + report.report_quality.confidence.label(), + report.report_quality.completeness.label(), + text_join(&report.report_quality.evidence), + text_join(&report.report_quality.limitations) + ); + for action in &report.next_actions { + let _ = writeln!( + output, + "next_action label=\"{}\" command=\"{}\" why=\"{}\"", + text_field(&action.label), + text_field(&action.command), + text_field(&action.why) + ); + } + let _ = writeln!( + output, + "{} files={} results={} exports={} imports={} callsites={} functions={} strings={} ioctls={} warnings={}", + report.tool, + report.summary.file_count, + report.summary.result_count, + report.summary.export_count, + report.summary.import_count, + report.summary.callsite_count, + report.summary.function_count, + report.summary.string_count, + report.summary.ioctl_count, + report.summary.warning_count + ); + for file in &report.files { + let _ = writeln!( + output, + "file={} kind={} arch={} bits={} subsystem={} image_base=0x{:x}", + file.path.display(), + file.kind, + file.arch, + file.bitness, + file.subsystem, + file.image_base + ); + render_file_items(report.tool, file, &mut output); + for warning in &file.warnings { + let _ = writeln!(output, "warning={warning}"); + } + } + for warning in &report.parse_warnings { + let _ = writeln!(output, "parse_warning={warning}"); + } + output +} + +fn answer_summary(report: &ToolReport) -> String { + let summary = &report.summary; + match report.tool { + "peexports" => format!( + "found {} export(s) in {} PE file(s)", + summary.export_count, summary.file_count + ), + "peimports" => format!( + "found {} import(s) across {} API categor{} in {} PE file(s)", + summary.import_count, + summary.category_count, + if summary.category_count == 1 { + "y" + } else { + "ies" + }, + summary.file_count + ), + "pecalls" => format!( + "matched {} imported API callsite(s) in {} PE file(s)", + summary.callsite_count, summary.file_count + ), + "pesig" => format!( + "inferred {} function boundary/signature hint(s) in {} PE file(s)", + summary.function_count, summary.file_count + ), + "pestrrefs" => format!( + "found {} string reference candidate(s) in {} PE file(s)", + summary.string_count, summary.file_count + ), + "drvshape" => format!( + "classified {} driver-shaped PE file(s) and found {} IOCTL candidate(s)", + summary.driver_count, summary.ioctl_count + ), + "ioctlscan" => format!( + "decoded {} probable IOCTL constant(s) in {} PE file(s)", + summary.ioctl_count, summary.file_count + ), + _ => format!( + "found {} result(s) in {} file(s)", + summary.result_count, summary.file_count + ), + } +} + +fn text_join(items: &[String]) -> String { + if items.is_empty() { + "none".to_owned() + } else { + text_field(&items.join("; ")) + } +} + +fn text_field(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn render_file_items(tool: &str, file: &FileReport, output: &mut String) { + match tool { + "peexports" => { + for export in &file.exports { + let _ = writeln!( + output, + "export ordinal={} name={} va=0x{:x} forwarder={}", + export + .ordinal + .map_or_else(|| "unknown".to_owned(), |ordinal| ordinal.to_string()), + export + .name + .as_deref() + .or(export.display_name.as_deref()) + .unwrap_or(""), + export.va, + export.forwarder.as_deref().unwrap_or("none") + ); + } + } + "peimports" => { + for category in &file.categories { + let _ = writeln!( + output, + "category={} count={} libraries={}", + category.category.label(), + category.count, + category.libraries.join(",") + ); + } + } + "pecalls" => { + for callsite in &file.callsites { + let _ = writeln!( + output, + "callsite address=0x{:x} api={} library={} target=0x{:x}", + callsite.address, callsite.api, callsite.library, callsite.target + ); + } + } + "pesig" => { + for function in &file.functions { + let _ = writeln!( + output, + "function start=0x{:x} name={} convention={} confidence={:?}", + function.start_va, + function.name.as_deref().unwrap_or(""), + function.calling_convention, + function.confidence + ); + } + } + "pestrrefs" => { + for string in &file.strings { + let _ = writeln!( + output, + "string offset=0x{:x} encoding={} refs={} value={}", + string.offset, + string.encoding, + string.references.len(), + string.value + ); + } + } + "drvshape" => { + if let Some(driver) = &file.drivers { + let _ = writeln!( + output, + "driver roles={} hints={} ioctls={}", + driver.roles.join(","), + driver.hints.join(","), + driver.ioctl_summary.count + ); + } + } + "ioctlscan" => { + for ioctl in &file.ioctls { + let _ = writeln!( + output, + "ioctl code={} device_type=0x{:x} function=0x{:x} method={} access={}", + ioctl.hex, + ioctl.device_type, + ioctl.function, + ioctl.method_name, + ioctl.access_name + ); + } + } + _ => {} + } +} + +fn tail(text: &str, max_bytes: usize) -> Option { + if text.trim().is_empty() { + return None; + } + if text.len() <= max_bytes { + return Some(text.trim().to_owned()); + } + let mut start = text.len() - max_bytes; + while !text.is_char_boundary(start) { + start += 1; + } + Some(text[start..].trim().to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn malformed_pe_missing_section_table() -> Vec { + let pe_offset = 0x80_usize; + let optional_size = 0xF0_u16; + let mut bytes = vec![0_u8; pe_offset + 4 + 20 + usize::from(optional_size)]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + + let coff = pe_offset + 4; + bytes[pe_offset..coff].copy_from_slice(b"PE\0\0"); + bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes()); + bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes()); + bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes()); + bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes()); + + let optional = coff + 20; + bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes()); + bytes[optional + 68..optional + 70].copy_from_slice(&3_u16.to_le_bytes()); + bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes()); + bytes + } + + fn malformed_pe_with_data_directory_rva( + directory_index: usize, + rva: u32, + size: u32, + ) -> Vec { + let (mut bytes, _) = minimal_pe_with_one_section(); + let optional = 0x80_usize + 4 + 20; + let directory = optional + 112 + (directory_index * 8); + bytes[directory..directory + 4].copy_from_slice(&rva.to_le_bytes()); + bytes[directory + 4..directory + 8].copy_from_slice(&size.to_le_bytes()); + bytes + } + + fn malformed_pe_export_directory_with_bad_tables() -> Vec { + let (mut bytes, export_offset) = minimal_pe_with_one_section(); + let optional = 0x80_usize + 4 + 20; + bytes[optional + 112..optional + 116].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 116..optional + 120].copy_from_slice(&0x28_u32.to_le_bytes()); + bytes[export_offset + 12..export_offset + 16].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 16..export_offset + 20].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 20..export_offset + 24].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 24..export_offset + 28].copy_from_slice(&1_u32.to_le_bytes()); + bytes[export_offset + 28..export_offset + 32].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 32..export_offset + 36].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes[export_offset + 36..export_offset + 40].copy_from_slice(&0x9000_u32.to_le_bytes()); + bytes + } + + fn minimal_pe_with_one_section() -> (Vec, usize) { + let pe_offset = 0x80_usize; + let optional_size = 0xF0_u16; + let section_size = 40_usize; + let section_raw_offset = pe_offset + 4 + 20 + usize::from(optional_size) + section_size; + let mut bytes = vec![0_u8; section_raw_offset + 0x200]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + + let coff = pe_offset + 4; + bytes[pe_offset..coff].copy_from_slice(b"PE\0\0"); + bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes()); + bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes()); + bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes()); + bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes()); + + let optional = coff + 20; + bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes()); + bytes[optional + 16..optional + 20].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 24..optional + 32].copy_from_slice(&0x1400_0000_u64.to_le_bytes()); + bytes[optional + 32..optional + 36].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[optional + 36..optional + 40].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[optional + 68..optional + 70].copy_from_slice(&3_u16.to_le_bytes()); + bytes[optional + 80..optional + 84].copy_from_slice(&0x2000_u32.to_le_bytes()); + bytes[optional + 84..optional + 88].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes()); + + let section = optional + usize::from(optional_size); + bytes[section..section + 8].copy_from_slice(b".rdata\0\0"); + bytes[section + 8..section + 12].copy_from_slice(&0x200_u32.to_le_bytes()); + bytes[section + 12..section + 16].copy_from_slice(&0x1000_u32.to_le_bytes()); + bytes[section + 16..section + 20].copy_from_slice(&0x200_u32.to_le_bytes()); + let section_raw_offset_u32 = + u32::try_from(section_raw_offset).expect("fixture raw offset fits u32"); + bytes[section + 20..section + 24].copy_from_slice(§ion_raw_offset_u32.to_le_bytes()); + (bytes, section_raw_offset) + } + + fn sample_import(library: &str, name: &str, category: ApiCategory, iat_va: u64) -> PeImport { + PeImport { + library: library.to_owned(), + name: name.to_owned(), + ordinal: None, + hint_name_rva: Some(0x2040), + iat_rva: iat_va.saturating_sub(0x1400_00000), + iat_va, + slot_size: 8, + section: Some(".idata".to_owned()), + category, + } + } + + fn sample_export(name: &str, ordinal: u64, va: u64) -> PeExport { + PeExport { + name: Some(name.to_owned()), + display_name: Some(undecorate_name(name)), + ordinal: Some(ordinal), + rva: va.saturating_sub(0x1400_00000), + va, + file_offset: Some(0x80), + forwarder: None, + section: Some(".text".to_owned()), + is_entrypoint: false, + } + } + + fn sample_snapshot() -> PeSnapshot { + let mut bytes = vec![0, 1, 2, 3, 0x03, 0x24, 0x22, 0x00]; + bytes.extend_from_slice(b"DeviceIoControl"); + bytes.push(0); + bytes.extend_from_slice(b"DriverPath.pdb"); + PeSnapshot { + path: PathBuf::from("sample.sys"), + bytes, + kind: "driver".to_owned(), + machine: "IMAGE_FILE_MACHINE_AMD64".to_owned(), + arch: "x64".to_owned(), + bitness: 64, + image_base: 0x1400_00000, + entry_point: 0x1400_01000, + entry_rva: 0x1000, + export_ordinal_base: 1, + subsystem: "native".to_owned(), + subsystem_raw: subsystem::IMAGE_SUBSYSTEM_NATIVE, + timestamp: 1234, + sections: vec![ + PeSection { + name: ".text".to_owned(), + virtual_address: 0x1000, + virtual_size: 0x1000, + raw_offset: 0, + raw_size: 4, + }, + PeSection { + name: ".rdata".to_owned(), + virtual_address: 0x2000, + virtual_size: 0x1000, + raw_offset: 4, + raw_size: 128, + }, + ], + imports: vec![ + sample_import( + "ntoskrnl.exe", + "IoCreateDevice", + ApiCategory::DeviceIo, + 0x1400_03000, + ), + sample_import( + "ntoskrnl.exe", + "IoCreateSymbolicLink", + ApiCategory::DeviceIo, + 0x1400_03008, + ), + sample_import( + "ntoskrnl.exe", + "ZwCreateFile", + ApiCategory::NativeNt, + 0x1400_03010, + ), + sample_import( + "wdf01000.sys", + "WdfDeviceCreate", + ApiCategory::DeviceIo, + 0x1400_03018, + ), + sample_import( + "ndis.sys", + "NdisRegisterProtocolDriver", + ApiCategory::DeviceIo, + 0x1400_03020, + ), + ], + exports: vec![ + sample_export("DriverEntry", 1, 0x1400_01000), + sample_export("_Dispatch@8", 2, 0x1400_01100), + ], + warnings: vec!["sample warning".to_owned()], + } + } + + fn sample_file_report() -> FileReport { + let snapshot = sample_snapshot(); + let ioctls = scan_ioctls(&snapshot, &IoctlScanOptions::default()); + FileReport { + path: snapshot.path.clone(), + kind: snapshot.kind.clone(), + machine: snapshot.machine.clone(), + arch: snapshot.arch.clone(), + bitness: snapshot.bitness, + image_base: snapshot.image_base, + entry_point: snapshot.entry_point, + subsystem: snapshot.subsystem.clone(), + sections: snapshot.sections.clone(), + warnings: snapshot.warnings.clone(), + exports: snapshot.exports.clone(), + imports: snapshot.imports.clone(), + categories: summarize_import_categories(&snapshot.imports), + callsites: vec![Callsite { + address: 0x1400_01020, + target: 0x1400_03000, + library: "ntoskrnl.exe".to_owned(), + api: "IoCreateDevice".to_owned(), + category: ApiCategory::DeviceIo, + kind: "call".to_owned(), + raw: "callq *0x10(%rip) # 0x140003000".to_owned(), + context: Vec::new(), + }], + functions: infer_functions( + &snapshot, + "140001000 :\n140001020 <_Dispatch@8>:\n", + &SignatureFlags::default(), + ), + strings: string_references( + &snapshot, + "140001030: lea rcx, [rip] # 0x140002004\n", + &StringFlags { + min_len: 5, + contains: Some("device".to_owned()), + regex: None, + encoding: EncodingMode::Ascii, + max_refs: 4, + }, + ), + drivers: Some(shape_driver(&snapshot, &ioctls)), + ioctls, + } + } + + #[test] + fn instruction_parser_handles_bytes_and_comments() { + let rows = instruction_rows("140001000: e8 00 00 00 00 callq *0x10(%rip) # 0x140002000\n"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].address, 0x1400_01000); + assert_eq!(rows[0].kind.as_deref(), Some("call")); + assert_eq!(rows[0].comment_target, Some(0x1400_02000)); + } + + #[test] + fn undecorates_x86_stdcall_names() { + assert_eq!(undecorate_name("_CreateFileW@28"), "CreateFileW"); + assert_eq!(undecorate_name("PlainName"), "PlainName"); + } + + #[test] + fn ordinal_parser_prefers_decimal_but_still_accepts_hex() { + assert_eq!( + parse_u64_value("--ordinal", "126").expect("decimal ordinal"), + 126 + ); + assert_eq!( + parse_u64_value("--ordinal", "0x7e").expect("hex ordinal"), + 126 + ); + } + + #[test] + fn callsite_analysis_ignores_internal_jumps_without_import_targets() { + let disassembly = "\ +140001000: e8 00 00 00 00 callq *0x10(%rip) # 0x140002000\n\ +140001005: eb 05 jmpq 14000100c <.Lfoo>\n"; + let imports = [ImportTarget { + target: 0x1400_02000, + library: "kernel32.dll".to_owned(), + api: "CreateFileA".to_owned(), + category: ApiCategory::Filesystem, + }]; + let mut query = CallsiteQuery::apis(["CreateFileA"]); + query.include_jumps = true; + + let report = analyze_callsites_with_targets(disassembly, &imports, &query); + + assert_eq!(report.callsites.len(), 1); + assert!(report.unresolved_calls.is_empty()); + } + + #[test] + fn cli_parser_applies_tool_specific_flags_and_rejects_cross_tool_flags() { + let (outcome, cli) = parse_cli_from( + [ + "peimports", + "--json", + "--category", + "device_io", + "--library", + "NTOS", + "--api", + "IoCreate", + "--suspicious", + "--llvm-arg", + "--no-show-raw-insn", + "--raw-output-limit", + "4096", + "sample.sys", + ], + ToolKind::Imports, + ) + .expect("imports flags parse"); + + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.common.render_mode(), RenderMode::Json); + assert_eq!(cli.paths, vec![PathBuf::from("sample.sys")]); + assert_eq!(cli.llvm_args, vec!["--no-show-raw-insn"]); + assert_eq!(cli.raw_output_limit, 4096); + assert_eq!(cli.flags.imports.categories, vec![ApiCategory::DeviceIo]); + assert_eq!(cli.flags.imports.library.as_deref(), Some("ntos")); + assert_eq!(cli.flags.imports.api.as_deref(), Some("iocreate")); + assert!(cli.flags.imports.suspicious); + + let (_outcome, cli) = parse_cli_from( + [ + "pestrrefs", + "--min-len", + "3", + "--contains", + "Device", + "--regex", + "Device.*", + "--encoding", + "utf-16le", + "--max-refs", + "2", + "sample.sys", + ], + ToolKind::StringRefs, + ) + .expect("string flags parse"); + assert_eq!(cli.flags.strings.min_len, 3); + assert_eq!(cli.flags.strings.contains.as_deref(), Some("device")); + assert!(matches!(cli.flags.strings.encoding, EncodingMode::Utf16Le)); + assert_eq!(cli.flags.strings.max_refs, 2); + + let error = parse_cli_from(["peexports", "--category", "device_io"], ToolKind::Exports) + .expect_err("exports must reject import category filter"); + assert!(error.to_string().contains("not supported")); + + let error = parse_cli_from(["pestrrefs", "--encoding", "wide"], ToolKind::StringRefs) + .expect_err("invalid encoding rejected"); + assert!(error.to_string().contains("invalid --encoding")); + + let error = parse_cli_from( + ["pesig", "--min-confidence", "certain"], + ToolKind::Signatures, + ) + .expect_err("invalid confidence rejected"); + assert!(error.to_string().contains("invalid --min-confidence")); + + let error = parse_cli_from(["peimports", "--raw-output-limit", "0"], ToolKind::Imports) + .expect_err("zero raw output limit rejected"); + assert!(error.to_string().contains("greater than zero")); + + let error = parse_cli_from(["pestrrefs", "--min-len", "0"], ToolKind::StringRefs) + .expect_err("zero string length rejected"); + assert!(error.to_string().contains("greater than zero")); + } + + #[test] + fn filters_summaries_quality_and_text_rendering_are_consistent() { + let mut files = vec![sample_file_report()]; + let export_flags = ExportFlags { + name: Some("dispatch".to_owned()), + ordinal: Some(2), + forwarders_only: false, + undecorate: true, + }; + let exports = filter_exports(&files[0].exports, &export_flags); + assert_eq!(exports.len(), 1); + assert_eq!(exports[0].display_name.as_deref(), Some("Dispatch")); + + let import_flags = ImportFlags { + categories: vec![ApiCategory::DeviceIo], + library: Some("ntos".to_owned()), + api: Some("iocreate".to_owned()), + suspicious: true, + }; + let imports = filter_imports(&files[0].imports, &import_flags); + assert_eq!(imports.len(), 2); + assert!( + imports + .iter() + .all(|import| import.category == ApiCategory::DeviceIo) + ); + + let categories = summarize_import_categories(&files[0].imports); + assert!(categories.iter().any(|category| { + category.category == ApiCategory::DeviceIo + && category.libraries.contains(&"ntoskrnl.exe".to_owned()) + })); + + let summary = summarize_report(ToolKind::DriverShape, &files); + assert_eq!(summary.file_count, 1); + assert_eq!(summary.driver_count, 1); + assert_eq!(summary.result_count, 1); + assert!(summary.ioctl_count >= 1); + + let raw_blocks = vec![RawBlock { + path: Some(PathBuf::from("sample.sys")), + label: "llvm-objdump-truncated".to_owned(), + text: "truncated".to_owned(), + truncated: true, + }]; + let quality = assess_report_quality( + ToolKind::Calls, + &summary, + &files, + &["parse warning".to_owned()], + &raw_blocks, + ); + assert_eq!(quality.confidence, Confidence::Low); + assert_eq!(quality.completeness, ReportCompleteness::Limited); + assert!( + quality + .limitations + .iter() + .any(|item| item.contains("truncated")) + ); + + let actions = suggest_next_actions( + ToolKind::Imports, + &[PathBuf::from("C:\\Program Files\\sample tool.exe")], + &summary, + &files, + ); + assert!( + actions + .iter() + .any(|action| action.command.contains("pecalls")) + ); + assert!( + actions[0] + .command + .contains("'C:\\Program Files\\sample tool.exe'") + ); + + files[0].categories = categories; + let report = ToolReport { + tool: "peimports", + inputs: vec![PathBuf::from("sample.sys")], + summary: summarize_report(ToolKind::Imports, &files), + report_quality: assess_report_quality( + ToolKind::Imports, + &summarize_report(ToolKind::Imports, &files), + &files, + &[], + &[], + ), + next_actions: actions, + files, + parse_warnings: vec!["parser said \"quote\"".to_owned()], + raw_blocks: Vec::new(), + }; + let rendered = render_text_report(&report); + assert!(rendered.contains("answer=found")); + assert!(rendered.contains("trust confidence=")); + assert!(rendered.contains("category=device_io")); + assert!(rendered.contains("parse_warning=parser said \"quote\"")); + } + + #[test] + fn snapshot_mapping_ioctl_scan_and_driver_shape_use_section_context() { + let snapshot = sample_snapshot(); + + assert_eq!( + snapshot + .section_for_rva(0x2004) + .map(|section| section.name.as_str()), + Some(".rdata") + ); + assert_eq!(snapshot.offset_to_rva(8), Some(0x2004)); + assert_eq!(align_up(5, 4), 8); + assert!(has_ioctl_scan_context(&snapshot)); + + let offsets = ioctl_candidate_offsets(&snapshot); + assert!(offsets.contains(&4)); + + let ioctls = scan_ioctls(&snapshot, &IoctlScanOptions::default()); + assert_eq!(ioctls.len(), 1); + assert_eq!(ioctls[0].hex, "0x00222403"); + assert_eq!(ioctls[0].method, IoctlMethod::Neither); + assert_eq!(ioctls[0].locations[0].rva, Some(0x2000)); + assert_eq!(ioctls[0].locations[0].section.as_deref(), Some(".rdata")); + + let shape = shape_driver(&snapshot, &ioctls); + assert!(shape.roles.contains(&"wdm_or_native_driver".to_owned())); + assert!(shape.roles.contains(&"kmdf".to_owned())); + assert!(shape.roles.contains(&"ndis".to_owned())); + assert!(shape.hints.contains(&"method_neither_seen".to_owned())); + assert!(shape.hints.contains(&"zw_calls_seen".to_owned())); + assert!(shape.hints.contains(&"symbolic_link_seen".to_owned())); + assert!( + shape + .hints + .contains(&"device_control_dispatch_seen".to_owned()) + ); + assert!(shape.pdb_hints.iter().any(|hint| hint.contains(".pdb"))); + assert!( + shape + .driver_entry_hints + .contains(&"exported_driver_entry".to_owned()) + ); + assert!( + shape + .device_creation_hints + .iter() + .any(|hint| hint.contains("IoCreateDevice")) + ); + } + + #[test] + fn ioctl_offset_alignment_does_not_overflow_near_usize_limit() { + assert_eq!(align_up(usize::MAX - 1, 4), usize::MAX); + assert_eq!(align_up(usize::MAX, 4), usize::MAX); + } + + #[test] + fn load_pe_snapshot_reports_truncated_pe_without_panicking() { + let temp = tempfile::tempdir().expect("tempdir"); + let truncated = temp.path().join("truncated.sys"); + let mut bytes = vec![0_u8; 0x40]; + bytes[0] = b'M'; + bytes[1] = b'Z'; + bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes()); + std::fs::write(&truncated, bytes).expect("truncated fixture"); + + let error = load_pe_snapshot(&truncated).expect_err("truncated PE should fail"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("not a PE")), + "unexpected truncated PE error: {error}" + ); + } + + #[test] + fn load_pe_snapshot_reports_missing_section_table_without_panicking() { + let temp = tempfile::tempdir().expect("tempdir"); + let malformed = temp.path().join("missing-sections.sys"); + std::fs::write(&malformed, malformed_pe_missing_section_table()) + .expect("malformed fixture"); + + let error = load_pe_snapshot(&malformed).expect_err("malformed PE should fail"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("not a PE")), + "unexpected malformed PE error: {error}" + ); + } + + #[test] + fn load_pe_snapshot_reports_malformed_import_or_resource_directory_without_panicking() { + for (name, directory_index, expected_error) in [ + ("bad-import.sys", 1, "import"), + ("bad-resource.sys", 2, "Resource"), + ] { + let temp = tempfile::tempdir().expect("tempdir"); + let malformed = temp.path().join(name); + std::fs::write( + &malformed, + malformed_pe_with_data_directory_rva(directory_index, 0x9000, 0x100), + ) + .expect("malformed fixture"); + + let error = load_pe_snapshot(&malformed).expect_err("malformed PE should fail"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("not a PE") && message.contains(expected_error)), + "unexpected malformed PE error for {name}: {error}" + ); + } + } + + #[test] + fn load_pe_snapshot_bounds_malformed_export_directory_without_fake_exports() { + let temp = tempfile::tempdir().expect("tempdir"); + let malformed = temp.path().join("bad-export.sys"); + std::fs::write(&malformed, malformed_pe_export_directory_with_bad_tables()) + .expect("malformed fixture"); + + let snapshot = load_pe_snapshot(&malformed).expect("malformed export snapshot"); + + assert_eq!(snapshot.sections.len(), 1); + assert!(snapshot.exports.is_empty()); + assert!(snapshot.imports.is_empty()); + } + + #[test] + fn function_and_string_reference_analysis_respects_filters_and_limits() { + let snapshot = sample_snapshot(); + let functions = infer_functions( + &snapshot, + "140001000 :\n140001004: retq\n140001010 <_Dispatch@8>:\n140001014: retq\n", + &SignatureFlags { + min_confidence: Confidence::High, + include_thunks: true, + function: Some("dispatch".to_owned()), + }, + ); + assert!( + functions + .iter() + .any(|function| function.name.as_deref() == Some("_Dispatch@8") + && function.calling_convention == "windows_x64") + ); + + let flags = StringFlags { + min_len: 5, + contains: Some("device".to_owned()), + regex: Some(Regex::new("Device.*Control").expect("regex compiles")), + encoding: EncodingMode::Ascii, + max_refs: 1, + }; + let strings = string_references( + &snapshot, + "140001030: lea rcx, [rip] # 0x140002004\n140001038: mov rax, 0x140002004\n", + &flags, + ); + assert_eq!(strings.len(), 1); + assert_eq!(strings[0].value, "DeviceIoControl"); + assert_eq!(strings[0].rva, Some(0x2004)); + assert_eq!(strings[0].va, Some(0x1400_02004)); + assert_eq!(strings[0].section.as_deref(), Some(".rdata")); + assert_eq!(strings[0].references.len(), 1); + assert_eq!(strings[0].references[0].address, 0x1400_01030); + + assert!(find_string_references("", None, 4).is_empty()); + } + + #[test] + fn analyze_file_routes_each_tool_to_the_expected_result_family() { + let snapshot = sample_snapshot(); + let objdump = ObjdumpText { + stdout: "\ +140001000 :\n\ +140001010: e8 00 00 00 00 callq *0x10(%rip) # 0x140003000\n\ +140001030: lea rcx, [rip] # 0x140002004\n" + .to_owned(), + raw_blocks: Vec::new(), + warnings: Vec::new(), + }; + + let mut cli = Cli { + common: CommonArgs::default(), + paths: vec![PathBuf::from("sample.sys")], + llvm_bin_dir: None, + llvm_args: Vec::new(), + raw_output_limit: DEFAULT_RAW_OUTPUT_LIMIT, + flags: ToolFlags::default(), + }; + + let imports = analyze_file(ToolKind::Imports, &cli, &snapshot, None); + assert_eq!(imports.imports.len(), snapshot.imports.len()); + assert!(!imports.categories.is_empty()); + + cli.flags.calls.apis = vec!["iocreate".to_owned()]; + let calls = analyze_file(ToolKind::Calls, &cli, &snapshot, Some(&objdump)); + assert_eq!(calls.callsites.len(), 1); + assert_eq!(calls.callsites[0].api, "IoCreateDevice"); + + cli.flags.signatures.function = Some("driver".to_owned()); + let signatures = analyze_file(ToolKind::Signatures, &cli, &snapshot, Some(&objdump)); + assert!( + signatures + .functions + .iter() + .any(|function| function.name.as_deref() == Some("DriverEntry")) + ); + + cli.flags.strings.contains = Some("device".to_owned()); + cli.flags.strings.encoding = EncodingMode::Ascii; + let strings = analyze_file(ToolKind::StringRefs, &cli, &snapshot, Some(&objdump)); + assert_eq!(strings.strings.len(), 1); + + let driver = analyze_file(ToolKind::DriverShape, &cli, &snapshot, None); + assert!(driver.drivers.is_some()); + + let ioctls = analyze_file(ToolKind::IoctlScan, &cli, &snapshot, None); + assert_eq!(ioctls.ioctls.len(), 1); + } +} diff --git a/crates/petools/src/main_drvshape.rs b/crates/petools/src/main_drvshape.rs new file mode 100644 index 0000000..b4759dc --- /dev/null +++ b/crates/petools/src/main_drvshape.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `drvshape`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::DriverShape)); +} diff --git a/crates/petools/src/main_ioctlscan.rs b/crates/petools/src/main_ioctlscan.rs new file mode 100644 index 0000000..288f2e9 --- /dev/null +++ b/crates/petools/src/main_ioctlscan.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `ioctlscan`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::IoctlScan)); +} diff --git a/crates/petools/src/main_pecalls.rs b/crates/petools/src/main_pecalls.rs new file mode 100644 index 0000000..dd295be --- /dev/null +++ b/crates/petools/src/main_pecalls.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `pecalls`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::Calls)); +} diff --git a/crates/petools/src/main_peexports.rs b/crates/petools/src/main_peexports.rs new file mode 100644 index 0000000..0b98199 --- /dev/null +++ b/crates/petools/src/main_peexports.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `peexports`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::Exports)); +} diff --git a/crates/petools/src/main_peimports.rs b/crates/petools/src/main_peimports.rs new file mode 100644 index 0000000..e5a5287 --- /dev/null +++ b/crates/petools/src/main_peimports.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `peimports`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::Imports)); +} diff --git a/crates/petools/src/main_pesig.rs b/crates/petools/src/main_pesig.rs new file mode 100644 index 0000000..046c0f9 --- /dev/null +++ b/crates/petools/src/main_pesig.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `pesig`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::Signatures)); +} diff --git a/crates/petools/src/main_pestrrefs.rs b/crates/petools/src/main_pestrrefs.rs new file mode 100644 index 0000000..b1582de --- /dev/null +++ b/crates/petools/src/main_pestrrefs.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `pestrrefs`. + +fn main() { + std::process::exit(petools::main_entry(petools::ToolKind::StringRefs)); +} diff --git a/crates/petools/tests/petools_cli.rs b/crates/petools/tests/petools_cli.rs new file mode 100644 index 0000000..d91eb0b --- /dev/null +++ b/crates/petools/tests/petools_cli.rs @@ -0,0 +1,124 @@ +//! CLI contract tests for PE analysis commands. + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; + +const COMMANDS: &[&str] = &[ + "peexports", + "peimports", + "pecalls", + "pesig", + "pestrrefs", + "drvshape", + "ioctlscan", +]; + +fn peimports_command() -> Command { + Command::cargo_bin("peimports").expect("binary") +} + +fn peexports_command() -> Command { + Command::cargo_bin("peexports").expect("binary") +} + +#[test] +fn help_mentions_shared_flags_and_examples() { + for command_name in COMMANDS { + let mut command = Command::cargo_bin(command_name).expect("binary"); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Examples")) + .stdout(predicate::str::contains("--json")) + .stdout(predicate::str::contains("--toon")) + .stdout(predicate::str::contains("--input-format")); + } +} + +#[test] +fn non_pe_input_is_runtime_error() { + let temp = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(temp.path(), b"not a pe").expect("write fixture"); + + let mut command = peimports_command(); + command + .arg(temp.path()) + .arg("--json") + .assert() + .code(3) + .stderr(predicate::str::contains("not a PE")); +} + +#[test] +fn imports_json_includes_guided_triage_metadata() { + let sample = assert_cmd::cargo::cargo_bin("peimports"); + let mut command = peimports_command(); + let output = command + .arg(sample) + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json = serde_json::from_slice::(&output).expect("json output"); + assert_eq!(json["report_quality"]["confidence"], "high"); + assert_eq!(json["report_quality"]["completeness"], "complete"); + assert!( + json["report_quality"]["evidence"] + .as_array() + .is_some_and(|items| { + items + .iter() + .any(|item| item == "PE headers and import table parsed") + }) + ); + assert!(json["next_actions"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["label"] == "Hunt imported API callsites" + && item["command"].as_str().is_some_and(|command| { + command.contains("pecalls") && command.contains("--category") + }) + }) + })); +} + +#[test] +fn imports_text_is_answer_first_and_actionable() { + let sample = assert_cmd::cargo::cargo_bin("peimports"); + let mut command = peimports_command(); + command + .arg(sample) + .assert() + .success() + .stdout(predicate::str::contains("answer=")) + .stdout(predicate::str::contains( + "trust confidence=high completeness=complete", + )) + .stdout(predicate::str::contains( + "next_action label=\"Hunt imported API callsites\" command=\"pecalls", + )); +} + +#[test] +fn exports_ordinal_filter_matches_the_real_export_ordinal() { + let mut command = peexports_command(); + let output = command + .arg(r"C:\Windows\System32\kernel32.dll") + .arg("--ordinal") + .arg("126") + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json = serde_json::from_slice::(&output).expect("json output"); + let export = json["files"][0]["exports"][0].clone(); + assert_eq!(export["ordinal"], 126); + assert_eq!(export["name"], "BuildIoRingCancelRequest"); +} diff --git a/crates/petools/tests/petools_core.rs b/crates/petools/tests/petools_core.rs new file mode 100644 index 0000000..2ccbdb4 --- /dev/null +++ b/crates/petools/tests/petools_core.rs @@ -0,0 +1,75 @@ +//! Core behavior tests for PE analysis helpers. + +use petools::{ + ApiCategory, CallsiteQuery, IoctlMethod, IoctlScanOptions, analyze_callsites, + categorize_import, decode_ioctl, scan_strings, +}; + +#[test] +fn categorizes_common_windows_imports() { + assert_eq!( + categorize_import("kernel32.dll", "CreateFileW"), + ApiCategory::Filesystem + ); + assert_eq!( + categorize_import("advapi32.dll", "RegOpenKeyExW"), + ApiCategory::Registry + ); + assert_eq!( + categorize_import("ntdll.dll", "NtDeviceIoControlFile"), + ApiCategory::DeviceIo + ); +} + +#[test] +fn resolves_iat_call_targets_from_objdump_comments() { + let query = CallsiteQuery::apis(["CreateFileW"]); + let imports = [("0x140072010", "kernel32.dll", "CreateFileW")]; + let disassembly = "140001098:\tcallq\t*0x70f72(%rip) # 0x140072010\n"; + let report = analyze_callsites(disassembly, &imports, &query); + assert_eq!(report.callsites.len(), 1); + assert_eq!(report.callsites[0].api, "CreateFileW"); + assert_eq!(report.callsites[0].address, 0x1400_01098); +} + +#[test] +fn decodes_ctl_code_fields() { + let decoded = decode_ioctl(0x0022_2003).expect("ioctl"); + assert_eq!(decoded.device_type, 0x22); + assert_eq!(decoded.function, 0x800); + assert_eq!(decoded.method, IoctlMethod::Neither); + assert_eq!(decoded.access_name, "FILE_ANY_ACCESS"); +} + +#[test] +fn scans_ascii_and_utf16_strings() { + let mut bytes = b"\0DeviceIoControl\0\0".to_vec(); + bytes.extend("IoctlName".encode_utf16().flat_map(u16::to_le_bytes)); + let strings = scan_strings(&bytes, 5); + assert!(strings.iter().any(|item| item.value == "DeviceIoControl")); + assert!(strings.iter().any(|item| item.value == "IoctlName")); +} + +#[test] +fn utf16le_scan_reports_only_maximal_non_overlapping_runs() { + let bytes: Vec = "LongDeviceName" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + let strings = scan_strings(&bytes, 5); + let utf16_hits: Vec<_> = strings + .iter() + .filter(|item| item.encoding == "utf16le") + .collect(); + + assert_eq!(utf16_hits.len(), 1); + assert_eq!(utf16_hits[0].offset, 0); + assert_eq!(utf16_hits[0].value, "LongDeviceName"); +} + +#[test] +fn filters_probable_ioctl_candidates() { + let options = IoctlScanOptions::default(); + assert!(options.is_probable_code(0x0022_2000)); + assert!(!options.is_probable_code(0x0000_0001)); +} diff --git a/crates/portping/Cargo.toml b/crates/portping/Cargo.toml new file mode 100644 index 0000000..28825fa --- /dev/null +++ b/crates/portping/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "portping" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Probe TCP and HTTP endpoints with compact timing output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +native-tls.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/portping/src/lib.rs b/crates/portping/src/lib.rs new file mode 100644 index 0000000..79212e9 --- /dev/null +++ b/crates/portping/src/lib.rs @@ -0,0 +1,1419 @@ +//! The `portping` command probes TCP and HTTP endpoints. +#![allow( + clippy::multiple_crate_versions, + reason = "native-tls and Windows TLS support currently pull duplicate transitive crates; cargo deny remains the dependency audit gate" +)] + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read, Write}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::thread; +use std::time::{Duration, Instant}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + parse_input_format, print_json, print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use native_tls::{TlsConnector, TlsStream}; +use serde::Serialize; + +const MAX_HTTP_STATUS_BYTES: usize = 8 * 1024; + +/// Parsed network probe target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// A raw TCP connection target. + Tcp { + /// Host name or IP address to probe. + host: String, + /// TCP port to probe. + port: u16, + }, + /// An HTTP or HTTPS endpoint. + Http { + /// The transport protocol to use. + scheme: HttpScheme, + /// Host name or IP address to probe. + host: String, + /// TCP port to probe. + port: u16, + /// Path and query string sent in the request line. + path_and_query: String, + }, +} + +/// Supported HTTP transport schemes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpScheme { + /// Plain HTTP over TCP. + Http, + /// HTTPS with a TLS handshake. + Https, +} + +/// Supported HTTP request methods. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum HttpMethod { + /// Issue a `GET` request. + Get, + /// Issue a `HEAD` request. + Head, +} + +/// CLI arguments for the `portping` binary. +#[derive(Debug, Clone)] +pub struct Cli { + /// Shared output and stdin policy flags. + pub common: CommonArgs, + /// Connection and read timeout in milliseconds. + pub timeout_ms: u64, + /// Whether the caller explicitly overrode the timeout. + pub timeout_explicit: bool, + /// HTTP request method for HTTP and HTTPS probes. + pub method: HttpMethod, + /// Require HTTP probes to return this exact status code. + pub expect_status: Option, + /// Keep diagnostic reporting successful even when probes fail. + pub report: bool, + /// Explicit targets to probe. + pub targets: Vec, +} + +const HELP: &str = "\ +Probe TCP and HTTP endpoints with compact timing output. + +Usage: + portping [OPTIONS] [TARGET...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --timeout-ms Connection and read timeout in milliseconds + --method HTTP method for HTTP and HTTPS probes: GET, HEAD + --expect-status Require HTTP probes to return this exact status code + --report Always exit 0 after rendering probe results + -h, --help Show this help text + -V, --version Show the command version + +Examples: + portping tcp://127.0.0.1:80 + portping https://example.com/health --method HEAD --expect-status 200 + 'https://example.com/health' | portping --json | ConvertFrom-Json + 'tcp://127.0.0.1:9' | portping --report --toon + +Notes: + Default timeouts stay conservative for network targets, but loopback tcp:// probes retry within a 250ms budget unless --timeout-ms is set explicitly + Exit code is 0 when any probe succeeds and 1 when all probes fail; use --report for diagnostic sweeps where failures are expected +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Structured result for a single network probe. +#[derive(Debug, Clone, Serialize)] +pub struct ProbeResult { + /// Original target string supplied by the caller. + pub target: String, + /// Whether the probe completed successfully. + pub ok: bool, + /// HTTP method used for HTTP-based probes. + pub method: Option, + /// HTTP status code when the target is HTTP-based. + pub status_code: Option, + /// Exact expected status code when the caller requested one. + pub expected_status: Option, + /// Whether the observed HTTP status matched the requested expectation. + pub status_matches_expectation: Option, + /// Time spent establishing the TCP connection. + pub connect_ms: Option, + /// Time spent performing the TLS handshake for HTTPS probes. + pub tls_handshake_ms: Option, + /// Total wall-clock duration for the probe. + pub total_ms: u128, + /// Locale-neutral failure token when one is available. + pub error_code: Option, + /// Compact interpretation hint for common success or failure cases. + pub hint: Option, + /// Optional failure detail for unsuccessful probes. + pub error: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("portping {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + timeout_ms: 5000, + timeout_explicit: false, + method: HttpMethod::Get, + expect_status: None, + report: false, + targets: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("timeout-ms") => { + cli.timeout_ms = parse_positive_u64_flag( + "--timeout-ms", + &parser_value_string(&mut parser, "--timeout-ms")?, + )?; + cli.timeout_explicit = true; + } + Long("method") => { + cli.method = parse_http_method(&parser_value_string(&mut parser, "--method")?)?; + } + Long("expect-status") => { + cli.expect_status = Some(parse_u16_flag( + "--expect-status", + &parser_value_string(&mut parser, "--expect-status")?, + )?); + } + Long("report") => cli.report = true, + ArgValue(value) => cli.targets.push(os_string_to_string(value, "target")?), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_string_to_string(value, flag) +} + +fn os_string_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_u64_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_positive_u64_flag(flag: &str, value: &str) -> Result { + let parsed = parse_u64_flag(flag, value)?; + if parsed == 0 { + return Err(CliError::usage(format!( + "{flag} expects a value greater than zero" + ))); + } + Ok(parsed) +} + +fn parse_u16_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_http_method(value: &str) -> Result { + match value.to_ascii_uppercase().as_str() { + "GET" => Ok(HttpMethod::Get), + "HEAD" => Ok(HttpMethod::Head), + other => Err(CliError::usage(format!( + "invalid --method value '{other}'; expected GET or HEAD" + ))), + } +} + +/// Parses a user-facing target string into an executable probe target. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the target string is malformed or uses an unsupported scheme. +pub fn parse_target(raw: &str) -> Result { + if let Some(rest) = raw.strip_prefix("tcp://") { + let (host, port) = parse_host_and_port( + rest, + None, + "tcp target must be in the form tcp://host:port", + "invalid tcp port", + )?; + return Ok(Target::Tcp { host, port }); + } + + let (scheme_name, rest) = raw + .split_once("://") + .ok_or_else(|| CliError::usage("invalid target: missing scheme"))?; + let (scheme, default_port) = match scheme_name { + "http" => (HttpScheme::Http, 80), + "https" => (HttpScheme::Https, 443), + other => return Err(CliError::usage(format!("unsupported scheme '{other}'"))), + }; + let split_index = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..split_index]; + if authority.is_empty() { + return Err(CliError::usage( + "invalid target: target must include a host", + )); + } + let (host, port) = parse_host_and_port( + authority, + Some(default_port), + "invalid target: target must include a host", + "invalid target port", + )?; + let path_and_query = normalize_path_and_query(&rest[split_index..]); + + Ok(Target::Http { + scheme, + host, + port, + path_and_query, + }) +} + +fn parse_host_and_port( + authority: &str, + default_port: Option, + missing_port_message: &str, + invalid_port_context: &str, +) -> Result<(String, u16), CliError> { + if let Some(bracketed) = authority.strip_prefix('[') { + let Some((host, remainder)) = bracketed.split_once(']') else { + return Err(CliError::usage(missing_port_message)); + }; + if host.is_empty() || (!remainder.is_empty() && !remainder.starts_with(':')) { + return Err(CliError::usage(missing_port_message)); + } + let port = if let Some(rest) = remainder.strip_prefix(':') { + parse_u16_flag(invalid_port_context, rest)? + } else { + default_port.ok_or_else(|| CliError::usage(missing_port_message))? + }; + return Ok((host.to_string(), port)); + } + + if let Some((host, port)) = authority.rsplit_once(':') { + if !host.contains(':') { + if host.is_empty() { + return Err(CliError::usage(missing_port_message)); + } + return Ok(( + host.to_string(), + parse_u16_flag(invalid_port_context, port)?, + )); + } + } + + let port = default_port.ok_or_else(|| CliError::usage(missing_port_message))?; + Ok((authority.to_string(), port)) +} + +fn normalize_path_and_query(remainder: &str) -> String { + let without_fragment = remainder.split('#').next().unwrap_or_default(); + if without_fragment.is_empty() { + "/".to_string() + } else if without_fragment.starts_with('?') { + format!("/{without_fragment}") + } else { + without_fragment.to_string() + } +} + +/// Executes the `portping` command with the provided arguments. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] for invalid targets and [`CliError::Runtime`] for stdin failures. +pub fn run(cli: &Cli) -> Result { + let target_strings = collect_targets(cli)?; + if target_strings.is_empty() { + return Err(CliError::usage( + "provide at least one target or pipe targets into stdin", + )); + } + + let mut results = Vec::new(); + for raw in target_strings { + let parsed = parse_target(&raw)?; + let timeout = effective_timeout( + &parsed, + Duration::from_millis(cli.timeout_ms), + cli.timeout_explicit, + ); + results.push(probe_target( + &raw, + &parsed, + timeout, + cli.method, + cli.expect_status, + )); + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&results)?, + RenderMode::Toon => print_structured(&results, RenderMode::Toon)?, + RenderMode::Text => { + for result in &results { + println!("{}", render_result(result)); + } + } + } + + Ok(if results.is_empty() { + ExitCode::NoResults + } else if cli.report || results.iter().any(|result| result.ok) { + ExitCode::Success + } else { + ExitCode::NoResults + }) +} + +fn effective_timeout(target: &Target, timeout: Duration, timeout_explicit: bool) -> Duration { + if timeout_explicit { + return timeout; + } + + if !target_loopback(target) { + return timeout; + } + + match target { + Target::Tcp { .. } => timeout.min(Duration::from_millis(250)), + Target::Http { .. } => timeout, + } +} + +fn target_loopback(target: &Target) -> bool { + match target { + Target::Tcp { host, .. } | Target::Http { host, .. } => is_loopback_host(host), + } +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host == "127.0.0.1" + || host == "::1" + || host == "0:0:0:0:0:0:0:1" +} + +fn collect_targets(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.targets.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let targets = parse_targets_from_string(&buffer); + if !targets.is_empty() { + return Ok(targets); + } + } + + Ok(cli.targets.clone()) +} + +fn parse_targets_from_string(buffer: &str) -> Vec { + buffer + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect::>() +} + +fn probe_target( + raw: &str, + target: &Target, + timeout: Duration, + method: HttpMethod, + expected_status: Option, +) -> ProbeResult { + let started = Instant::now(); + match target { + Target::Tcp { host, port } => probe_tcp(raw, host, *port, timeout, started), + Target::Http { + scheme, + host, + port, + path_and_query, + } => probe_http( + raw, + *scheme, + host, + *port, + path_and_query, + timeout, + started, + method, + expected_status, + ), + } +} + +fn probe_tcp(raw: &str, host: &str, port: u16, timeout: Duration, started: Instant) -> ProbeResult { + match connect_socket_with_budget(host, port, timeout) { + Ok((_, connect_ms)) => ProbeResult { + target: raw.to_string(), + ok: true, + method: None, + status_code: None, + expected_status: None, + status_matches_expectation: None, + connect_ms: Some(connect_ms), + tls_handshake_ms: None, + total_ms: started.elapsed().as_millis(), + error_code: None, + hint: None, + error: None, + }, + Err(error) => { + let (error_code, error_message) = describe_io_error("connect failed", &error); + ProbeResult { + target: raw.to_string(), + ok: false, + method: None, + status_code: None, + expected_status: None, + status_matches_expectation: None, + connect_ms: None, + tls_handshake_ms: None, + total_ms: started.elapsed().as_millis(), + error_code: Some(error_code), + hint: diagnostic_hint_for_target_error(host, &error), + error: Some(error_message), + } + } + } +} + +#[allow( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "HTTP probes need target identity, transport settings, timers, and expectation state; a builder would add more surface than value here" +)] +fn probe_http( + raw: &str, + scheme: HttpScheme, + host: &str, + port: u16, + path_and_query: &str, + timeout: Duration, + started: Instant, + method: HttpMethod, + expected_status: Option, +) -> ProbeResult { + let (stream, connect_ms) = match connect_socket(host, port, timeout) { + Ok(values) => values, + Err(error) => { + return ProbeResult { + target: raw.to_string(), + ok: false, + method: Some(method), + status_code: None, + expected_status, + status_matches_expectation: expected_status.map(|_| false), + connect_ms: None, + tls_handshake_ms: None, + total_ms: started.elapsed().as_millis(), + error_code: Some(describe_io_error_code(&error)), + hint: diagnostic_hint_for_target_error(host, &error), + error: Some(format!("failed to connect to {host}:{port}: {error}")), + }; + } + }; + + let mut transport = match build_transport(stream, scheme, host) { + Ok((transport, tls_ms)) => (transport, tls_ms), + Err(error) => { + return ProbeResult { + target: raw.to_string(), + ok: false, + method: Some(method), + status_code: None, + expected_status, + status_matches_expectation: expected_status.map(|_| false), + connect_ms: Some(connect_ms), + tls_handshake_ms: None, + total_ms: started.elapsed().as_millis(), + error_code: None, + hint: None, + error: Some(error), + }; + } + }; + + let request = format!( + "{} {path_and_query} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: portping/0.1\r\n\r\n", + method_label(method) + ); + if let Err(error) = transport.0.write_all(request.as_bytes()) { + return ProbeResult { + target: raw.to_string(), + ok: false, + method: Some(method), + status_code: None, + expected_status, + status_matches_expectation: expected_status.map(|_| false), + connect_ms: Some(connect_ms), + tls_handshake_ms: transport.1, + total_ms: started.elapsed().as_millis(), + error_code: Some(describe_io_error_code(&error)), + hint: diagnostic_hint_for_target_error(host, &error), + error: Some(format!("failed to write request: {error}")), + }; + } + + let response = match read_http_status_bytes(&mut transport.0) { + Ok(response) => response, + Err(error) => { + return ProbeResult { + target: raw.to_string(), + ok: false, + method: Some(method), + status_code: None, + expected_status, + status_matches_expectation: expected_status.map(|_| false), + connect_ms: Some(connect_ms), + tls_handshake_ms: transport.1, + total_ms: started.elapsed().as_millis(), + error_code: Some(describe_io_error_code(&error)), + hint: diagnostic_hint_for_target_error(host, &error), + error: Some(format!("failed to read response: {error}")), + }; + } + }; + + if response.len() >= MAX_HTTP_STATUS_BYTES && !response.contains(&b'\n') { + return ProbeResult { + target: raw.to_string(), + ok: false, + method: Some(method), + status_code: None, + expected_status, + status_matches_expectation: expected_status.map(|_| false), + connect_ms: Some(connect_ms), + tls_handshake_ms: transport.1, + total_ms: started.elapsed().as_millis(), + error_code: None, + hint: None, + error: Some(format!( + "HTTP status line exceeded {MAX_HTTP_STATUS_BYTES} bytes" + )), + }; + } + + let status_code = parse_status_code(&response); + let status_matches_expectation = expected_status.map(|expected| status_code == Some(expected)); + let (ok, error) = evaluate_http_status(status_code, expected_status); + ProbeResult { + target: raw.to_string(), + ok, + method: Some(method), + status_code, + expected_status, + status_matches_expectation, + connect_ms: Some(connect_ms), + tls_handshake_ms: transport.1, + total_ms: started.elapsed().as_millis(), + error_code: None, + hint: None, + error, + } +} + +fn evaluate_http_status( + status_code: Option, + expected_status: Option, +) -> (bool, Option) { + match (status_code, expected_status) { + (Some(actual), Some(expected)) if actual == expected => (true, None), + (Some(actual), Some(expected)) => ( + false, + Some(format!("expected status {expected}, got {actual}")), + ), + (None, Some(expected)) => ( + false, + Some(format!( + "expected status {expected}, got no HTTP status line" + )), + ), + (Some(actual), None) if (200..400).contains(&actual) => (true, None), + (Some(actual), None) => (false, Some(format!("unexpected HTTP status {actual}"))), + (None, None) => (false, Some("missing HTTP status line".to_string())), + } +} + +fn connect_socket( + host: &str, + port: u16, + timeout: Duration, +) -> Result<(TcpStream, u128), io::Error> { + let addresses = resolve_socket_addresses(host, port)?; + let started = Instant::now(); + let stream = connect_socket_once(&addresses, timeout)?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + Ok((stream, started.elapsed().as_millis())) +} + +fn resolve_socket_addresses(host: &str, port: u16) -> Result, io::Error> { + let addresses = (host, port).to_socket_addrs()?.collect::>(); + if addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::AddrNotAvailable, + "target resolved to no addresses", + )); + } + Ok(addresses) +} + +fn connect_socket_once( + addresses: &[SocketAddr], + timeout: Duration, +) -> Result { + let mut last_error = None; + for address in addresses { + match TcpStream::connect_timeout(address, timeout) { + Ok(stream) => return Ok(stream), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + "target resolved to no usable addresses", + ) + })) +} + +fn connect_socket_with_budget( + host: &str, + port: u16, + timeout: Duration, +) -> Result<(TcpStream, u128), io::Error> { + if !is_loopback_host(host) { + return connect_socket(host, port, timeout); + } + + let addresses = resolve_socket_addresses(host, port)?; + let started = Instant::now(); + let mut last_error = None; + + loop { + let elapsed = started.elapsed(); + if elapsed >= timeout { + break; + } + + let remaining = timeout.saturating_sub(elapsed); + let attempt_timeout = remaining.min(Duration::from_millis(50)); + match connect_socket_once(&addresses, attempt_timeout) { + Ok(stream) => { + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + return Ok((stream, started.elapsed().as_millis())); + } + Err(error) => { + if !matches!( + error.kind(), + io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionReset + ) { + return Err(error); + } + last_error = Some(error); + } + } + + let remaining = timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + break; + } + thread::sleep(remaining.min(Duration::from_millis(25))); + } + + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "connection timed out before listener became ready", + ) + })) +} + +fn build_transport( + stream: TcpStream, + scheme: HttpScheme, + host: &str, +) -> Result<(Transport, Option), String> { + match scheme { + HttpScheme::Http => Ok((Transport::Plain(stream), None)), + HttpScheme::Https => { + let connector = TlsConnector::new() + .map_err(|error| format!("failed to create TLS connector: {error}"))?; + let started = Instant::now(); + let tls_stream = connector + .connect(host, stream) + .map_err(|error| format!("TLS handshake failed: {error}"))?; + Ok(( + Transport::Tls(Box::new(tls_stream)), + Some(started.elapsed().as_millis()), + )) + } + } +} + +fn parse_status_code(response: &[u8]) -> Option { + let response = String::from_utf8_lossy(response); + let status_line = response.lines().next()?; + status_line + .split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) +} + +fn read_http_status_bytes(reader: &mut R) -> io::Result> +where + R: Read, +{ + let mut response = Vec::with_capacity(256); + let mut chunk = [0_u8; 256]; + while response.len() < MAX_HTTP_STATUS_BYTES { + let remaining = MAX_HTTP_STATUS_BYTES - response.len(); + let limit = remaining.min(chunk.len()); + let read = reader.read(&mut chunk[..limit])?; + if read == 0 { + break; + } + response.extend_from_slice(&chunk[..read]); + if response.contains(&b'\n') { + break; + } + } + Ok(response) +} + +fn render_result(result: &ProbeResult) -> String { + let mut text = String::new(); + text.push_str(if result.ok { "ok" } else { "fail" }); + if let Some(method) = result.method { + let _ = write!(text, " {}", method_label(method)); + } + let _ = write!(text, " {}", result.target); + if let Some(code) = result.status_code { + let _ = write!(text, " status={code}"); + } + if let Some(code) = result.expected_status { + let _ = write!(text, " expected={code}"); + } + if let Some(ms) = result.connect_ms { + let _ = write!(text, " connect={ms}ms"); + } + let _ = write!(text, " total={}ms", result.total_ms); + if let Some(ms) = result.tls_handshake_ms { + let _ = write!(text, " tls={ms}ms"); + } + if let Some(code) = result.error_code.as_ref() { + let _ = write!(text, " error_code={code}"); + } + if let Some(value) = result.hint.as_ref() { + let _ = write!(text, " hint={value}"); + } + if let Some(message) = result.error.as_ref() { + let _ = write!(text, " error={message}"); + } + text +} + +fn describe_io_error(context: &str, error: &io::Error) -> (String, String) { + let error_code = describe_io_error_code(error); + (error_code, format!("{context}: {error}")) +} + +fn describe_io_error_code(error: &io::Error) -> String { + let kind = match error.kind() { + io::ErrorKind::NotFound => "not_found", + io::ErrorKind::PermissionDenied => "permission_denied", + io::ErrorKind::ConnectionRefused => "connection_refused", + io::ErrorKind::ConnectionReset => "connection_reset", + io::ErrorKind::ConnectionAborted => "connection_aborted", + io::ErrorKind::NotConnected => "not_connected", + io::ErrorKind::AddrInUse => "address_in_use", + io::ErrorKind::AddrNotAvailable => "address_not_available", + io::ErrorKind::BrokenPipe => "broken_pipe", + io::ErrorKind::AlreadyExists => "already_exists", + io::ErrorKind::WouldBlock => "would_block", + io::ErrorKind::InvalidInput => "invalid_input", + io::ErrorKind::InvalidData => "invalid_data", + io::ErrorKind::TimedOut => "timed_out", + io::ErrorKind::WriteZero => "write_zero", + io::ErrorKind::Interrupted => "interrupted", + io::ErrorKind::Unsupported => "unsupported", + io::ErrorKind::UnexpectedEof => "unexpected_eof", + io::ErrorKind::OutOfMemory => "out_of_memory", + _ => "other", + }; + error + .raw_os_error() + .map_or_else(|| kind.to_string(), |code| format!("{kind}@{code}")) +} + +fn diagnostic_hint_from_error(error: &io::Error) -> Option { + match error.kind() { + io::ErrorKind::ConnectionRefused => Some("host_reachable_port_closed".to_string()), + io::ErrorKind::TimedOut => Some("no_response_before_timeout".to_string()), + io::ErrorKind::AddrNotAvailable => Some("unresolved_or_unusable_address".to_string()), + io::ErrorKind::ConnectionReset => Some("remote_endpoint_reset_connection".to_string()), + _ => None, + } +} + +fn diagnostic_hint_for_target_error(_host: &str, error: &io::Error) -> Option { + diagnostic_hint_from_error(error) +} + +const fn method_label(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Get => "GET", + HttpMethod::Head => "HEAD", + } +} + +#[derive(Debug)] +enum Transport { + Plain(TcpStream), + Tls(Box>), +} + +impl Read for Transport { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Plain(stream) => stream.read(buf), + Self::Tls(stream) => stream.read(buf), + } + } +} + +impl Write for Transport { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Plain(stream) => stream.write(buf), + Self::Tls(stream) => stream.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Plain(stream) => stream.flush(), + Self::Tls(stream) => stream.flush(), + } + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + use std::thread::{self, JoinHandle}; + + use super::*; + + fn common_args(json: bool) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format: common::InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + } + } + + fn spawn_server(response: &'static [u8]) -> (u16, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + let handle = thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let _ = stream.write_all(response); + } + }); + (port, handle) + } + + fn unused_local_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + drop(listener); + port + } + + #[test] + fn parse_target_rejects_invalid_inputs() { + assert_eq!( + parse_targets_from_string(" tcp://127.0.0.1:80 \n\nhttp://127.0.0.1/\n"), + vec!["tcp://127.0.0.1:80", "http://127.0.0.1/"] + ); + + let zero_timeout = parse_cli_from(["portping", "--timeout-ms", "0"]) + .expect_err("zero timeout should fail"); + assert!(matches!( + zero_timeout, + CliError::Usage(message) if message.contains("greater than zero") + )); + + let missing_tcp_port = + parse_target("tcp://127.0.0.1").expect_err("missing tcp port should fail"); + assert!(matches!( + missing_tcp_port, + CliError::Usage(message) + if message.contains("tcp target must be in the form") + )); + + let invalid_tcp_port = + parse_target("tcp://127.0.0.1:notaport").expect_err("invalid port should fail"); + assert!(matches!( + invalid_tcp_port, + CliError::Usage(message) + if message.contains("invalid tcp port") + )); + + let unsupported = + parse_target("udp://127.0.0.1:53").expect_err("unsupported scheme should fail"); + assert!(matches!( + unsupported, + CliError::Usage(message) + if message.contains("unsupported scheme 'udp'") + )); + + let invalid_target = parse_target("http://").expect_err("invalid target should fail"); + assert!(matches!( + invalid_target, + CliError::Usage(message) + if message.contains("invalid target") + )); + + for raw in ["http://:80", "tcp://[]:80", "http://[::1]junk"] { + let error = parse_target(raw).expect_err("malformed authority should fail"); + assert!( + matches!(&error, CliError::Usage(message) if message.contains("invalid target") || message.contains("tcp target")), + "{raw} returned {error:?}" + ); + } + + let default_path = parse_target("http://127.0.0.1").expect("default path target"); + assert!(matches!( + default_path, + Target::Http { path_and_query, .. } if path_and_query == "/" + )); + } + + #[test] + fn status_code_parsing_and_result_rendering_cover_success_and_failure() { + assert_eq!( + parse_status_code(b"HTTP/1.1 204 No Content\r\n\r\n"), + Some(204) + ); + assert_eq!(parse_status_code(b"not-http\r\n"), None); + assert_eq!(method_label(HttpMethod::Get), "GET"); + assert_eq!(method_label(HttpMethod::Head), "HEAD"); + + let success = render_result(&ProbeResult { + target: "http://127.0.0.1:8080/health".into(), + ok: true, + method: Some(HttpMethod::Head), + status_code: Some(200), + expected_status: Some(200), + status_matches_expectation: Some(true), + connect_ms: Some(3), + tls_handshake_ms: Some(4), + total_ms: 12, + error_code: None, + hint: None, + error: None, + }); + assert_eq!( + success, + "ok HEAD http://127.0.0.1:8080/health status=200 expected=200 connect=3ms total=12ms tls=4ms" + ); + + let failure = render_result(&ProbeResult { + target: "tcp://127.0.0.1:9999".into(), + ok: false, + method: None, + status_code: None, + expected_status: None, + status_matches_expectation: None, + connect_ms: None, + tls_handshake_ms: None, + total_ms: 7, + error_code: Some("connection_refused@10061".into()), + hint: Some("host_reachable_port_closed".into()), + error: Some("connection refused".into()), + }); + assert_eq!( + failure, + "fail tcp://127.0.0.1:9999 total=7ms error_code=connection_refused@10061 hint=host_reachable_port_closed error=connection refused" + ); + } + + #[test] + fn evaluate_http_status_covers_defaults_and_expectations() { + assert_eq!(evaluate_http_status(Some(204), None), (true, None)); + assert_eq!( + evaluate_http_status(Some(500), None), + (false, Some("unexpected HTTP status 500".into())) + ); + assert_eq!(evaluate_http_status(Some(204), Some(204)), (true, None)); + assert_eq!( + evaluate_http_status(Some(200), Some(204)), + (false, Some("expected status 204, got 200".into())) + ); + assert_eq!( + evaluate_http_status(None, Some(204)), + ( + false, + Some("expected status 204, got no HTTP status line".into()) + ) + ); + } + + #[test] + fn http_status_reader_stops_after_status_line_or_cap() { + let mut normal = + io::Cursor::new(b"HTTP/1.1 204 No Content\r\nHeader: value\r\n\r\nlarge body"); + let status = read_http_status_bytes(&mut normal).expect("status bytes"); + assert_eq!(parse_status_code(&status), Some(204)); + assert!(status.len() <= MAX_HTTP_STATUS_BYTES); + + let mut oversized = io::Cursor::new(vec![b'x'; MAX_HTTP_STATUS_BYTES + 128]); + let status = read_http_status_bytes(&mut oversized).expect("capped bytes"); + assert_eq!(status.len(), MAX_HTTP_STATUS_BYTES); + assert!(!status.contains(&b'\n')); + } + + #[test] + fn connect_socket_reports_success_and_failure() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + let handle = thread::spawn(move || { + let _ = listener.accept(); + }); + + let (_, connect_ms) = + connect_socket("127.0.0.1", port, Duration::from_secs(1)).expect("connect"); + assert!(connect_ms <= 1_000); + handle.join().expect("join accept thread"); + + let error = connect_socket("127.0.0.1", unused_local_port(), Duration::from_millis(50)) + .expect_err("closed port should fail"); + assert!(matches!( + error.kind(), + io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::AddrNotAvailable + )); + } + + #[test] + fn connect_socket_with_budget_retries_until_loopback_listener_is_ready() { + let port = unused_local_port(); + let handle = thread::spawn(move || { + thread::sleep(Duration::from_millis(150)); + let listener = TcpListener::bind(("127.0.0.1", port)).expect("listener"); + let _ = listener.accept(); + }); + + let (_, connect_ms) = connect_socket_with_budget("127.0.0.1", port, Duration::from_secs(1)) + .expect("retrying connect"); + assert!(connect_ms >= 100); + handle.join().expect("join delayed listener"); + } + + #[test] + fn probe_tcp_success_records_connect_timing() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + let handle = thread::spawn(move || { + let _ = listener.accept(); + }); + + let result = probe_tcp( + &format!("tcp://127.0.0.1:{port}"), + "127.0.0.1", + port, + Duration::from_secs(1), + Instant::now(), + ); + assert!(result.ok); + assert_eq!(result.method, None); + assert!(result.connect_ms.is_some()); + assert_eq!(result.status_code, None); + assert_eq!(result.expected_status, None); + assert_eq!(result.status_matches_expectation, None); + assert_eq!(result.error, None); + + handle.join().expect("join tcp thread"); + } + + #[test] + fn build_transport_handles_http_and_https_failure_paths() { + let http_listener = TcpListener::bind("127.0.0.1:0").expect("http listener"); + let http_port = http_listener.local_addr().expect("address").port(); + let http_handle = thread::spawn(move || { + let _ = http_listener.accept(); + }); + let (http_stream, _) = + connect_socket("127.0.0.1", http_port, Duration::from_secs(1)).expect("connect"); + let (http_transport, tls_ms) = + build_transport(http_stream, HttpScheme::Http, "127.0.0.1").expect("plain transport"); + assert!(matches!(http_transport, Transport::Plain(_))); + assert_eq!(tls_ms, None); + http_handle.join().expect("join http thread"); + + let (tls_port, tls_server) = spawn_server(b"plain-text"); + let (tls_stream, _) = + connect_socket("127.0.0.1", tls_port, Duration::from_secs(1)).expect("connect"); + let tls_error = build_transport(tls_stream, HttpScheme::Https, "127.0.0.1") + .expect_err("plain server should fail TLS"); + assert!(tls_error.contains("TLS handshake failed")); + tls_server.join().expect("join https thread"); + } + + #[test] + fn probe_helpers_cover_success_bad_http_and_connect_failure() { + let (http_port, http_handle) = + spawn_server(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n"); + let success = probe_http( + &format!("http://127.0.0.1:{http_port}/health"), + HttpScheme::Http, + "127.0.0.1", + http_port, + "/health", + Duration::from_secs(1), + Instant::now(), + HttpMethod::Head, + Some(204), + ); + assert!(success.ok); + assert_eq!(success.method, Some(HttpMethod::Head)); + assert_eq!(success.status_code, Some(204)); + assert_eq!(success.expected_status, Some(204)); + assert_eq!(success.status_matches_expectation, Some(true)); + assert!(success.connect_ms.is_some()); + assert_eq!(success.tls_handshake_ms, None); + http_handle.join().expect("join http thread"); + + let (bad_port, bad_handle) = spawn_server(b"garbage-response"); + let malformed = probe_http( + &format!("http://127.0.0.1:{bad_port}/health"), + HttpScheme::Http, + "127.0.0.1", + bad_port, + "/health", + Duration::from_secs(1), + Instant::now(), + HttpMethod::Get, + None, + ); + assert!(!malformed.ok); + assert_eq!(malformed.status_code, None); + assert_eq!(malformed.error.as_deref(), Some("missing HTTP status line")); + bad_handle.join().expect("join bad http thread"); + + let closed_tcp_port = unused_local_port(); + let tcp_failure = probe_target( + &format!("tcp://127.0.0.1:{closed_tcp_port}"), + &Target::Tcp { + host: "127.0.0.1".into(), + port: closed_tcp_port, + }, + Duration::from_millis(50), + HttpMethod::Get, + None, + ); + assert!(!tcp_failure.ok); + assert!(tcp_failure.error.is_some()); + + let closed_port = unused_local_port(); + let connect_failure = probe_target( + &format!("http://127.0.0.1:{closed_port}/health"), + &Target::Http { + scheme: HttpScheme::Http, + host: "127.0.0.1".into(), + port: closed_port, + path_and_query: "/health".into(), + }, + Duration::from_millis(50), + HttpMethod::Get, + Some(200), + ); + assert!(!connect_failure.ok); + assert_eq!(connect_failure.method, Some(HttpMethod::Get)); + assert_eq!(connect_failure.expected_status, Some(200)); + assert_eq!(connect_failure.status_matches_expectation, Some(false)); + assert!(connect_failure.error.as_deref().is_some_and(|message| { + message.starts_with(&format!("failed to connect to 127.0.0.1:{closed_port}:")) + })); + assert!(connect_failure.error_code.is_some()); + } + + #[test] + fn run_requires_targets_and_reports_success_for_probes() { + let usage_error = run(&Cli { + common: common_args(false), + timeout_ms: 50, + timeout_explicit: true, + method: HttpMethod::Get, + expect_status: None, + report: false, + targets: Vec::new(), + }) + .expect_err("missing targets should fail"); + assert!(matches!( + usage_error, + CliError::Usage(message) + if message.contains("provide at least one target") + )); + + let (port, handle) = spawn_server(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + let success = run(&Cli { + common: common_args(true), + timeout_ms: 1_000, + timeout_explicit: true, + method: HttpMethod::Get, + expect_status: Some(200), + report: false, + targets: vec![format!("http://127.0.0.1:{port}/health")], + }) + .expect("portping run"); + assert_eq!(success, ExitCode::Success); + handle.join().expect("join run thread"); + + let (text_port, text_handle) = + spawn_server(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + let text_success = run(&Cli { + common: common_args(false), + timeout_ms: 1_000, + timeout_explicit: true, + method: HttpMethod::Head, + expect_status: Some(200), + report: false, + targets: vec![format!("http://127.0.0.1:{text_port}/health")], + }) + .expect("text run"); + assert_eq!(text_success, ExitCode::Success); + text_handle.join().expect("join text thread"); + + let closed_listener = TcpListener::bind("127.0.0.1:0").expect("closed listener"); + let closed_port = closed_listener.local_addr().expect("closed addr").port(); + drop(closed_listener); + + let closed = run(&Cli { + common: common_args(true), + timeout_ms: 50, + timeout_explicit: true, + method: HttpMethod::Get, + expect_status: None, + report: false, + targets: vec![format!("tcp://127.0.0.1:{closed_port}")], + }) + .expect("closed run"); + assert_eq!(closed, ExitCode::NoResults); + } + + #[test] + fn default_loopback_timeout_is_clamped_but_explicit_timeout_is_preserved() { + let target = Target::Tcp { + host: "127.0.0.1".to_string(), + port: 65535, + }; + assert_eq!( + effective_timeout(&target, Duration::from_secs(5), false), + Duration::from_millis(250) + ); + assert_eq!( + effective_timeout(&target, Duration::from_millis(50), false), + Duration::from_millis(50) + ); + assert_eq!( + effective_timeout(&target, Duration::from_secs(5), true), + Duration::from_secs(5) + ); + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("::1")); + assert!(!is_loopback_host("example.com")); + assert_eq!( + diagnostic_hint_for_target_error( + "127.0.0.1", + &io::Error::new(io::ErrorKind::TimedOut, "slow"), + ) + .as_deref(), + Some("no_response_before_timeout") + ); + } +} diff --git a/crates/portping/src/main.rs b/crates/portping/src/main.rs new file mode 100644 index 0000000..2b0d063 --- /dev/null +++ b/crates/portping/src/main.rs @@ -0,0 +1,9 @@ +//! Binary entry point for `portping`. +#![allow( + clippy::multiple_crate_versions, + reason = "native-tls and Windows TLS support currently pull duplicate transitive crates; cargo deny remains the dependency audit gate" +)] + +fn main() { + std::process::exit(portping::main_entry()); +} diff --git a/crates/portping/tests/portping_cli.rs b/crates/portping/tests/portping_cli.rs new file mode 100644 index 0000000..84f8307 --- /dev/null +++ b/crates/portping/tests/portping_cli.rs @@ -0,0 +1,258 @@ +//! Integration tests for the `portping` command. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command as StdCommand, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use assert_cmd::Command; +use portping::{Target, parse_target}; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("portping").expect("binary") +} + +fn spawn_http_server() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let _ = stream.write_all(response); + } + }); + + port +} + +fn spawn_capturing_http_server(status_line: &'static str) -> (u16, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + let (sender, receiver) = mpsc::channel(); + + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0_u8; 1024]; + let read = stream.read(&mut buffer).expect("read request"); + let request = String::from_utf8_lossy(&buffer[..read]).to_string(); + sender.send(request).expect("send request"); + let response = + format!("{status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } + }); + + (port, receiver) +} + +fn unused_local_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("address").port(); + drop(listener); + port +} + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn spawn_passive_listener_child(port: u16) -> ChildGuard { + let mut child = StdCommand::new("pwsh") + .arg("-NoProfile") + .arg("-Command") + .arg(format!( + "$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, {port}); \ + $listener.Start(); \ + Write-Output 'ready'; \ + try {{ Start-Sleep -Seconds 30 }} finally {{ $listener.Stop() }}" + )) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn passive listener child"); + let stdout = child.stdout.take().expect("passive listener stdout"); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let mut line = String::new(); + let _ = BufReader::new(stdout).read_line(&mut line); + let _ = sender.send(line); + }); + + let ready_line = receiver + .recv_timeout(Duration::from_secs(15)) + .expect("passive listener child did not report readiness"); + assert!( + ready_line.trim() == "ready", + "unexpected passive listener readiness line: {ready_line:?}" + ); + + ChildGuard(child) +} + +fn wait_for_tcp_listener(port: u16) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("passive listener on port {port} did not become ready"); +} + +#[test] +fn parses_targets() { + assert!(matches!( + parse_target("tcp://127.0.0.1:9000").expect("tcp"), + Target::Tcp { port, .. } if port == 9000 + )); + assert!(matches!( + parse_target("http://127.0.0.1:9000/health").expect("http"), + Target::Http { port, .. } if port == 9000 + )); +} + +#[test] +fn probes_http_endpoint_as_json() { + let port = spawn_http_server(); + let mut command = cargo_command(); + command + .arg(format!("http://127.0.0.1:{port}/health")) + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"status_code\":200")) + .stdout(predicate::str::contains("\"ok\":true")); +} + +#[test] +fn reports_closed_tcp_port() { + let port = unused_local_port(); + + let mut command = cargo_command(); + command + .arg(format!("tcp://127.0.0.1:{port}")) + .arg("--json") + .assert() + .code(1) + .stdout(predicate::str::contains("\"ok\":false")) + .stdout(predicate::str::contains("\"error\"")); +} + +#[test] +fn report_mode_keeps_diagnostic_sweeps_successful_when_probe_fails() { + let port = unused_local_port(); + + let mut command = cargo_command(); + command + .arg(format!("tcp://127.0.0.1:{port}")) + .arg("--report") + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":false")) + .stdout(predicate::str::contains("\"error_code\"")); +} + +#[test] +fn supports_powershell_pipeline() { + let port = spawn_http_server(); + let binary = assert_cmd::cargo::cargo_bin("portping"); + let script = format!( + "'http://127.0.0.1:{port}/health' | & '{}'", + binary.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("200")) + .stdout(predicate::str::contains("ok")); +} + +#[test] +fn supports_head_requests_and_expected_status_checks() { + let (port, receiver) = spawn_capturing_http_server("HTTP/1.1 204 No Content"); + let mut command = cargo_command(); + command + .arg(format!("http://127.0.0.1:{port}/health")) + .arg("--method") + .arg("HEAD") + .arg("--expect-status") + .arg("204") + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"method\":\"HEAD\"")) + .stdout(predicate::str::contains("\"expected_status\":204")) + .stdout(predicate::str::contains( + "\"status_matches_expectation\":true", + )) + .stdout(predicate::str::contains("\"ok\":true")); + + let request = receiver.recv().expect("captured request"); + assert!(request.starts_with("HEAD /health HTTP/1.1\r\n")); +} + +#[test] +fn probes_passive_loopback_listener_started_by_child_process() { + let port = unused_local_port(); + let _listener = spawn_passive_listener_child(port); + wait_for_tcp_listener(port); + + let mut command = cargo_command(); + command + .arg(format!("tcp://127.0.0.1:{port}")) + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")); +} + +#[test] +fn marks_status_mismatches_as_failed_results() { + let port = spawn_http_server(); + let mut command = cargo_command(); + command + .arg(format!("http://127.0.0.1:{port}/health")) + .arg("--expect-status") + .arg("204") + .arg("--json") + .assert() + .code(1) + .stdout(predicate::str::contains("\"status_code\":200")) + .stdout(predicate::str::contains( + "\"status_matches_expectation\":false", + )) + .stdout(predicate::str::contains("\"ok\":false")); +} + +#[test] +fn help_includes_health_check_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--method")) + .stdout(predicate::str::contains("--expect-status")) + .stdout(predicate::str::contains("--report")) + .stdout(predicate::str::contains("Exit code")) + .stdout(predicate::str::contains("--toon")); +} diff --git a/crates/portunlock/Cargo.toml b/crates/portunlock/Cargo.toml new file mode 100644 index 0000000..9520062 --- /dev/null +++ b/crates/portunlock/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "portunlock" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect and free local TCP or UDP port owners on Windows." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +humantime.workspace = true +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true +windowsupport = { path = "../windowsupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/portunlock/src/lib.rs b/crates/portunlock/src/lib.rs new file mode 100644 index 0000000..7b924f6 --- /dev/null +++ b/crates/portunlock/src/lib.rs @@ -0,0 +1,1161 @@ +//! The `portunlock` command inspects and frees local TCP or UDP port owners. +#![allow(clippy::multiple_crate_versions)] + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use humantime::parse_duration; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read}; +use std::time::Duration; +use windowsupport::{ + PortOwner, PortProtocol, ProcessKillTarget, WindowsSupportError, snapshot_port_owners, + terminate_processes_checked, +}; + +const HELP: &str = "\ +Inspect and free local TCP or UDP port owners on Windows. + +Windows only. + +Usage: + portunlock [OPTIONS] who + portunlock [OPTIONS] free + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --force Terminate exact remaining owners after re-probe + --wait Wait between probes, for example 250ms or 2s + --protocol Restrict sockets: tcp, udp, or any + --allow-empty Return exit code 0 when free finds no matching owners + -h, --help Show this help text + -V, --version Show the command version + +Examples: + portunlock who --json --protocol any 3000 8080 | ConvertFrom-Json + portunlock who --json 65431 | ConvertFrom-Json + portunlock free --allow-empty --json 65431 | ConvertFrom-Json + portunlock free --force 5000 + '5000' | portunlock who --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + force: bool, + wait: Duration, + protocol: ProtocolFilter, + action: Action, +} + +#[derive(Debug, Clone)] +enum Action { + Who(Vec), + Free(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ProtocolFilter { + Tcp, + Udp, + Any, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PortActionResult { + port: u16, + protocol: ProtocolFilter, + initial_owners: Vec, + attempts: Vec, + final_owners: Vec, + residual_owners: Vec, + ok: bool, + escalated: bool, + error: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("portunlock {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut force = false; + let mut wait = Duration::from_millis(250); + let mut protocol = ProtocolFilter::Any; + let mut subcommand = None::; + let mut positional = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + force, + wait, + protocol, + action: Action::Who(Vec::new()), + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + force, + wait, + protocol, + action: Action::Who(Vec::new()), + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("force") => force = true, + Long("allow-empty") => {} + Long("wait") => { + wait = parse_duration(&parser_value_string(&mut parser, "--wait")?) + .map_err(|error| CliError::usage(format!("invalid --wait value: {error}")))?; + } + Long("protocol") => { + protocol = parse_protocol_filter(&parser_value_string(&mut parser, "--protocol")?)?; + } + ArgValue(value) => { + if subcommand.is_none() { + subcommand = Some(os_value_string(value, "subcommand")?); + } else { + positional.push(value); + } + } + Short(short) if subcommand.is_some() && short.is_ascii_digit() => { + return Err(parse_port_value(&format!("-{short}")).expect_err("invalid port")); + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let action = parse_action( + subcommand, + positional, + common.input_format, + common.stdin_is_terminal(), + )?; + Ok(( + ParseOutcome::Run, + Cli { + common, + force, + wait, + protocol, + action, + }, + )) +} + +fn run(cli: &Cli) -> Result { + let results = match &cli.action { + Action::Who(ports) => { + let snapshot = snapshot_port_owners().map_err(CliError::from)?; + describe_ports_from_snapshot(ports, cli.protocol, &snapshot) + } + Action::Free(ports) => { + let snapshot = snapshot_port_owners().map_err(CliError::from)?; + ports + .iter() + .map(|port| { + let initial_owners = + filtered_port_owners_from_snapshot(*port, cli.protocol, &snapshot); + free_port_with_initial_owners( + *port, + cli.protocol, + cli.force, + cli.wait, + initial_owners, + ) + }) + .collect::, _>>()? + } + }; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&results)?, + RenderMode::Toon => print_structured(&results, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_text(&results)), + } + + match cli.action { + Action::Who(_) => Ok(if results.iter().all(|result| result.ok) { + ExitCode::Success + } else { + ExitCode::RuntimeError + }), + Action::Free(_) => { + let actionable_initial = results + .iter() + .map(|result| { + result + .initial_owners + .iter() + .filter(|owner| is_actionable_owner(owner)) + .count() + }) + .sum::(); + if actionable_initial == 0 || results.iter().all(|result| result.ok) { + Ok(ExitCode::Success) + } else { + Ok(ExitCode::RuntimeError) + } + } + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_value_string(value, flag) +} + +fn os_value_string(value: OsString, flag: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_protocol_filter(value: &str) -> Result { + match value { + "tcp" => Ok(ProtocolFilter::Tcp), + "udp" => Ok(ProtocolFilter::Udp), + "any" => Ok(ProtocolFilter::Any), + other => Err(CliError::usage(format!( + "invalid --protocol value '{other}'; expected tcp, udp, or any" + ))), + } +} + +fn parse_action( + subcommand: Option, + positional: Vec, + input_format: InputFormat, + stdin_is_terminal: bool, +) -> Result { + let Some(subcommand) = subcommand else { + return Err(CliError::usage("provide a subcommand: who or free")); + }; + + let ports = collect_ports(positional, input_format, stdin_is_terminal)?; + match subcommand.as_str() { + "who" => Ok(Action::Who(ports)), + "free" => Ok(Action::Free(ports)), + _ => Err(CliError::usage("unknown subcommand; expected who or free")), + } +} + +fn collect_ports( + positional: Vec, + input_format: InputFormat, + stdin_is_terminal: bool, +) -> Result, CliError> { + if should_read_stdin(!positional.is_empty(), stdin_is_terminal) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let ports = parse_ports_from_string(&buffer, input_format)?; + if !ports.is_empty() { + return Ok(ports); + } + } + + let mut ports = Vec::new(); + for value in positional { + let text = os_value_string(value, "port")?; + ports.push(parse_port_value(&text)?); + } + if ports.is_empty() { + return Err(CliError::usage("provide at least one port")); + } + Ok(ports) +} + +fn parse_ports_from_string(buffer: &str, input_format: InputFormat) -> Result, CliError> { + let mut ports = Vec::new(); + for (index, raw) in buffer.lines().enumerate() { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let port = match input_format { + InputFormat::Lines => parse_port_value(trimmed)?, + InputFormat::Jsonl => parse_json_port(trimmed, index + 1)?, + InputFormat::Auto => match serde_json::from_str::(trimmed) { + Ok(_) => parse_json_port(trimmed, index + 1)?, + Err(_) => parse_port_value(trimmed)?, + }, + }; + ports.push(port); + } + Ok(ports) +} + +fn parse_json_port(line: &str, line_number: usize) -> Result { + let value = serde_json::from_str::(line).map_err(|error| { + CliError::usage(format!("invalid JSONL port on line {line_number}: {error}")) + })?; + match value { + Value::Number(number) => parse_json_number_port(&number, line_number), + Value::String(text) => parse_port_value(&text), + Value::Object(object) => object + .get("port") + .and_then(Value::as_u64) + .ok_or_else(|| { + CliError::usage(format!( + "JSONL line {line_number} must contain a numeric 'port' field" + )) + }) + .and_then(|port| parse_port_number(port, "port")), + _ => Err(CliError::usage(format!( + "JSONL line {line_number} must be a number, string, or object with 'port'" + ))), + } +} + +fn parse_json_number_port( + number: &serde_json::Number, + line_number: usize, +) -> Result { + number + .as_u64() + .ok_or_else(|| { + CliError::usage(format!( + "JSONL line {line_number} must contain a positive integer port" + )) + }) + .and_then(|port| parse_port_number(port, "port")) +} + +fn parse_port_value(value: &str) -> Result { + let trimmed = value.trim(); + let port = trimmed.parse::().map_err(|_| { + CliError::usage(format!( + "invalid port '{value}'; expected an integer between 1 and 65535" + )) + })?; + if !(1..=u32::from(u16::MAX)).contains(&port) { + return Err(CliError::usage(format!( + "invalid port '{value}'; expected an integer between 1 and 65535" + ))); + } + u16::try_from(port).map_err(|_| { + CliError::usage(format!( + "invalid port '{value}'; expected an integer between 1 and 65535" + )) + }) +} + +fn parse_port_number(port: u64, label: &str) -> Result { + if !(1..=u64::from(u16::MAX)).contains(&port) { + return Err(CliError::usage(format!( + "invalid {label} '{port}'; expected an integer between 1 and 65535" + ))); + } + Ok(u16::try_from(port).expect("validated port range")) +} + +#[cfg(test)] +fn describe_port(port: u16, protocol: ProtocolFilter) -> Result { + let snapshot = snapshot_port_owners().map_err(CliError::from)?; + Ok(describe_port_from_snapshot(port, protocol, &snapshot)) +} + +fn describe_ports_from_snapshot( + ports: &[u16], + protocol: ProtocolFilter, + snapshot: &[PortOwner], +) -> Vec { + ports + .iter() + .map(|port| describe_port_from_snapshot(*port, protocol, snapshot)) + .collect() +} + +fn describe_port_from_snapshot( + port: u16, + protocol: ProtocolFilter, + snapshot: &[PortOwner], +) -> PortActionResult { + let owners = filtered_port_owners_from_snapshot(port, protocol, snapshot); + PortActionResult { + port, + protocol, + initial_owners: owners.clone(), + attempts: Vec::new(), + final_owners: owners, + residual_owners: Vec::new(), + ok: true, + escalated: false, + error: None, + } +} + +#[cfg(test)] +fn free_port( + port: u16, + protocol: ProtocolFilter, + force: bool, + wait: Duration, +) -> Result { + let initial_owners = filtered_port_owners(port, protocol)?; + free_port_with_initial_owners(port, protocol, force, wait, initial_owners) +} + +fn free_port_with_initial_owners( + port: u16, + protocol: ProtocolFilter, + force: bool, + wait: Duration, + initial_owners: Vec, +) -> Result { + free_port_with_initial_owners_using( + port, + protocol, + force, + wait, + initial_owners, + filtered_port_owners, + terminate_processes_checked, + ) +} + +fn free_port_with_initial_owners_using( + port: u16, + protocol: ProtocolFilter, + force: bool, + wait: Duration, + initial_owners: Vec, + mut probe: F, + mut terminate: T, +) -> Result +where + F: FnMut(u16, ProtocolFilter) -> Result, CliError>, + T: FnMut(&[ProcessKillTarget]) -> Result<(), WindowsSupportError>, +{ + if initial_owners.is_empty() { + return Ok(PortActionResult { + port, + protocol, + initial_owners: Vec::new(), + attempts: Vec::new(), + final_owners: Vec::new(), + residual_owners: Vec::new(), + ok: true, + escalated: false, + error: None, + }); + } + + let mut attempts = vec![format!("observed {} owner(s)", initial_owners.len())]; + windowsupport::sleep_for(wait); + attempts.push(format!("waited {}", humantime::format_duration(wait))); + let mut final_owners = probe(port, protocol)?; + let mut escalated = false; + let mut error = None::; + + if final_owners.iter().any(is_actionable_owner) && force { + let pids = final_owners + .iter() + .filter(|owner| is_actionable_owner(owner)) + .map(process_kill_target_from_owner) + .collect::>(); + let pids = pids.into_iter().collect::>(); + let mut termination_error = None::; + match terminate(&pids) { + Ok(()) => attempts.push("terminated remaining owners".to_string()), + Err(error) => { + let message = error.to_string(); + attempts.push(format!("termination reported error: {message}")); + termination_error = Some(message); + } + } + windowsupport::sleep_for(wait); + final_owners = probe(port, protocol)?; + if final_owners.iter().any(is_actionable_owner) { + if let Some(message) = termination_error { + return Err(CliError::from(WindowsSupportError::Process(format!( + "termination failed and owners remain: {message}" + )))); + } + } + escalated = true; + } else if final_owners.iter().any(is_actionable_owner) { + error = Some("owners remain; rerun with --force to terminate them".to_string()); + } + + let residual_owners = final_owners + .iter() + .filter(|owner| !is_actionable_owner(owner)) + .cloned() + .collect::>(); + final_owners.retain(is_actionable_owner); + + Ok(PortActionResult { + port, + protocol, + initial_owners, + attempts, + ok: final_owners.is_empty(), + escalated, + final_owners, + residual_owners, + error, + }) +} + +fn filtered_port_owners(port: u16, protocol: ProtocolFilter) -> Result, CliError> { + let snapshot = snapshot_port_owners().map_err(CliError::from)?; + Ok(filtered_port_owners_from_snapshot( + port, protocol, &snapshot, + )) +} + +fn filtered_port_owners_from_snapshot( + port: u16, + protocol: ProtocolFilter, + snapshot: &[PortOwner], +) -> Vec { + let mut owners = snapshot + .iter() + .filter(|owner| owner.port == port && protocol_matches(owner.protocol, protocol)) + .cloned() + .collect::>(); + owners.sort_by(|left, right| { + ( + protocol_order(left.protocol), + left.local_address.as_str(), + left.pid, + ) + .cmp(&( + protocol_order(right.protocol), + right.local_address.as_str(), + right.pid, + )) + }); + owners +} + +fn protocol_matches(protocol: PortProtocol, filter: ProtocolFilter) -> bool { + match filter { + ProtocolFilter::Any => true, + ProtocolFilter::Tcp => protocol == PortProtocol::Tcp, + ProtocolFilter::Udp => protocol == PortProtocol::Udp, + } +} + +const fn protocol_order(protocol: PortProtocol) -> u8 { + match protocol { + PortProtocol::Tcp => 0, + PortProtocol::Udp => 1, + } +} + +fn render_text(results: &[PortActionResult]) -> String { + let mut output = String::new(); + for result in results { + if result.initial_owners.is_empty() + && result.final_owners.is_empty() + && result.residual_owners.is_empty() + && result.attempts.is_empty() + && result.error.is_none() + && result.ok + { + let _ = writeln!( + output, + "port {} [{}] ok=true owners=0", + result.port, + protocol_filter_label(result.protocol), + ); + continue; + } + let _ = writeln!( + output, + "port {} [{}] ok={} escalated={} initial={} final={} residual={}", + result.port, + protocol_filter_label(result.protocol), + result.ok, + result.escalated, + result.initial_owners.len(), + result.final_owners.len(), + result.residual_owners.len(), + ); + if !result.attempts.is_empty() { + let _ = writeln!(output, " attempts: {}", result.attempts.join(" | ")); + } + for owner in &result.final_owners { + let _ = writeln!( + output, + " {} {}:{} pid={} image={}", + protocol_label(owner.protocol), + owner.local_address, + owner.port, + owner.pid, + owner.image_name.as_deref().unwrap_or("?"), + ); + } + for owner in &result.residual_owners { + let _ = writeln!( + output, + " residual {} {}:{} pid={} state={} image={}", + protocol_label(owner.protocol), + owner.local_address, + owner.port, + owner.pid, + owner.state.as_deref().unwrap_or("?"), + owner.image_name.as_deref().unwrap_or("?"), + ); + } + if let Some(error) = &result.error { + let _ = writeln!(output, " error: {error}"); + } + } + output +} + +const fn protocol_filter_label(protocol: ProtocolFilter) -> &'static str { + match protocol { + ProtocolFilter::Tcp => "tcp", + ProtocolFilter::Udp => "udp", + ProtocolFilter::Any => "any", + } +} + +const fn protocol_label(protocol: PortProtocol) -> &'static str { + match protocol { + PortProtocol::Tcp => "tcp", + PortProtocol::Udp => "udp", + } +} + +fn is_actionable_owner(owner: &PortOwner) -> bool { + owner.pid != 0 && owner.state.as_deref() != Some("TIME_WAIT") +} + +const fn process_kill_target_from_owner(owner: &PortOwner) -> ProcessKillTarget { + ProcessKillTarget { + pid: owner.pid, + start_time_unix: owner.start_time_unix, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{TcpListener, UdpSocket}; + use std::process; + use std::time::Duration; + + #[test] + fn parsers_cover_protocol_and_jsonl_paths() { + assert_eq!( + parse_protocol_filter("tcp").expect("tcp"), + ProtocolFilter::Tcp + ); + assert_eq!(parse_port_value("8080").expect("port"), 8080); + assert_eq!( + parse_json_port(r#"{"port":3000}"#, 1).expect("json port"), + 3000 + ); + assert!(parse_json_port(r#"{"path":3000}"#, 1).is_err()); + } + + #[test] + fn text_rendering_is_compact() { + let text = render_text(&[PortActionResult { + port: 3000, + protocol: ProtocolFilter::Any, + initial_owners: vec![PortOwner { + port: 3000, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("LISTEN".to_string()), + pid: 1234, + start_time_unix: None, + image_name: Some("pwsh".to_string()), + service_name: None, + command_line: Vec::new(), + }], + attempts: vec!["waited 250ms".to_string()], + final_owners: Vec::new(), + residual_owners: Vec::new(), + ok: true, + escalated: true, + error: None, + }]); + assert!(text.contains("port 3000 [any]")); + assert!(text.contains("initial=1")); + } + + #[test] + fn cli_and_input_parsers_cover_help_version_and_errors() { + assert_eq!( + parse_cli_from(["portunlock", "--help"]).expect("help").0, + ParseOutcome::Help + ); + assert_eq!( + parse_cli_from(["portunlock", "--version"]) + .expect("version") + .0, + ParseOutcome::Version + ); + + let (_, cli) = parse_cli_from([ + "portunlock", + "--json", + "--input-format", + "jsonl", + "--color", + "never", + "--force", + "--wait", + "10ms", + "--protocol", + "udp", + "--allow-empty", + "who", + "4000", + ]) + .expect("parsed cli"); + assert!(cli.common.json); + assert_eq!(cli.common.input_format, InputFormat::Jsonl); + assert!(cli.force); + assert_eq!(cli.wait, Duration::from_millis(10)); + assert_eq!(cli.protocol, ProtocolFilter::Udp); + match cli.action { + Action::Who(ports) => assert_eq!(ports, vec![4000]), + Action::Free(_) => panic!("expected who action"), + } + + assert_eq!( + parse_ports_from_string("5000\n", InputFormat::Lines).expect("line ports"), + vec![5000] + ); + assert_eq!( + parse_ports_from_string("{\"port\":6000}\n", InputFormat::Jsonl).expect("jsonl ports"), + vec![6000] + ); + assert_eq!( + parse_ports_from_string("7000\n\"7100\"\n", InputFormat::Auto).expect("auto ports"), + vec![7000, 7100] + ); + + assert!(parse_protocol_filter("icmp").is_err()); + assert!(parse_port_value("0").is_err()); + assert!(parse_port_value("70000").is_err()); + assert!(parse_port_value("hello").is_err()); + assert!(matches!( + parse_cli_from(["portunlock", "who", "-1"]), + Err(CliError::Usage(message)) + if message.contains("invalid port '-1'; expected an integer between 1 and 65535") + )); + assert!(parse_ports_from_string("{\"name\":1}\n", InputFormat::Jsonl).is_err()); + assert!( + parse_action( + Some("list".to_string()), + vec![OsString::from("80")], + InputFormat::Lines, + true + ) + .is_err() + ); + assert!(parse_cli_from(["portunlock", "who"]).is_err()); + } + + #[test] + fn describe_and_free_cover_live_tcp_udp_paths() { + let tcp = TcpListener::bind("127.0.0.1:0").expect("tcp"); + let udp = UdpSocket::bind("127.0.0.1:0").expect("udp"); + let tcp_port = tcp.local_addr().expect("tcp addr").port(); + let udp_port = udp.local_addr().expect("udp addr").port(); + + let tcp_report = describe_port(tcp_port, ProtocolFilter::Tcp).expect("tcp report"); + assert_eq!(tcp_report.port, tcp_port); + assert!(!tcp_report.initial_owners.is_empty()); + assert!( + tcp_report + .initial_owners + .iter() + .all(|owner| owner.pid == process::id()) + ); + + let udp_owners = filtered_port_owners(udp_port, ProtocolFilter::Any).expect("udp owners"); + assert!( + udp_owners + .iter() + .any(|owner| owner.protocol == PortProtocol::Udp) + ); + + let free_report = free_port( + tcp_port, + ProtocolFilter::Tcp, + false, + Duration::from_millis(1), + ) + .expect("free report"); + assert!(!free_report.ok); + assert!(!free_report.escalated); + assert!(free_report.error.is_some()); + assert!(!free_report.final_owners.is_empty()); + + let rendered = render_text(&[free_report]); + assert!(rendered.contains("error: owners remain")); + assert_eq!(protocol_filter_label(ProtocolFilter::Any), "any"); + assert_eq!(protocol_label(PortProtocol::Udp), "udp"); + assert!(protocol_matches(PortProtocol::Tcp, ProtocolFilter::Any)); + assert!(!protocol_matches(PortProtocol::Udp, ProtocolFilter::Tcp)); + assert!(protocol_order(PortProtocol::Tcp) < protocol_order(PortProtocol::Udp)); + assert!(is_actionable_owner(&PortOwner { + port: tcp_port, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("LISTEN".to_string()), + pid: process::id(), + start_time_unix: None, + image_name: Some("pwsh".to_string()), + service_name: None, + command_line: Vec::new(), + })); + } + + #[test] + fn run_and_free_cover_no_owner_paths() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + + let report = describe_port(port, ProtocolFilter::Tcp).expect("describe"); + assert!(report.initial_owners.is_empty()); + + let free_report = + free_port(port, ProtocolFilter::Tcp, false, Duration::from_millis(1)).expect("free"); + assert!(free_report.ok); + assert!(free_report.initial_owners.is_empty()); + assert!(free_report.final_owners.is_empty()); + + let cli = Cli { + common: CommonArgs { + json: false, + format: None, + input_format: InputFormat::Lines, + color: common::ColorChoice::Never, + quiet: false, + }, + force: false, + wait: Duration::from_millis(1), + protocol: ProtocolFilter::Tcp, + action: Action::Who(vec![port]), + }; + assert_eq!(run(&cli).expect("run"), ExitCode::Success); + } + + #[test] + fn describe_ports_can_reuse_one_snapshot() { + let snapshot = vec![ + PortOwner { + port: 3000, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("LISTEN".to_string()), + pid: 42, + start_time_unix: None, + image_name: Some("demo.exe".to_string()), + service_name: None, + command_line: Vec::new(), + }, + PortOwner { + port: 3001, + protocol: PortProtocol::Udp, + local_address: "127.0.0.1".to_string(), + state: None, + pid: 43, + start_time_unix: None, + image_name: Some("other.exe".to_string()), + service_name: None, + command_line: Vec::new(), + }, + ]; + + let reports = describe_ports_from_snapshot(&[3000, 3001], ProtocolFilter::Any, &snapshot); + + assert_eq!(reports.len(), 2); + assert_eq!(reports[0].initial_owners.len(), 1); + assert_eq!(reports[0].initial_owners[0].pid, 42); + assert_eq!(reports[1].initial_owners.len(), 1); + assert_eq!(reports[1].initial_owners[0].pid, 43); + } + + #[test] + fn residual_owners_do_not_block_free_success() { + let residual = PortOwner { + port: 5000, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("TIME_WAIT".to_string()), + pid: 0, + start_time_unix: None, + image_name: None, + service_name: None, + command_line: Vec::new(), + }; + assert!(!is_actionable_owner(&residual)); + let text = render_text(&[PortActionResult { + port: 5000, + protocol: ProtocolFilter::Tcp, + initial_owners: vec![residual.clone()], + attempts: vec!["waited 1ms".to_string()], + final_owners: Vec::new(), + residual_owners: vec![residual], + ok: true, + escalated: false, + error: None, + }]); + assert!(text.contains("residual=1")); + assert!(text.contains("residual tcp 127.0.0.1:5000 pid=0")); + } + + #[test] + fn force_termination_error_is_tolerated_when_reprobe_finds_no_remaining_owners() { + let initial = PortOwner { + port: 5001, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("LISTEN".to_string()), + pid: 42, + start_time_unix: Some(12_345), + image_name: Some("holder.exe".to_string()), + service_name: None, + command_line: Vec::new(), + }; + let mut probe_count = 0_u8; + + let report = free_port_with_initial_owners_using( + 5001, + ProtocolFilter::Tcp, + true, + Duration::ZERO, + vec![initial.clone()], + |_, _| { + probe_count += 1; + if probe_count == 1 { + Ok(vec![initial.clone()]) + } else { + Ok(Vec::new()) + } + }, + |_| { + Err(windowsupport::WindowsSupportError::Process( + "process already exited".to_string(), + )) + }, + ) + .expect("transient termination failure should be tolerated after empty re-probe"); + + assert!(report.ok); + assert!(report.escalated, "{report:?}"); + assert!(report.final_owners.is_empty()); + assert!( + report + .attempts + .iter() + .any(|attempt| attempt.contains("termination reported error")) + ); + } + + #[test] + fn force_termination_success_reports_escalated_and_cleared_owner() { + let initial = PortOwner { + port: 5002, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("LISTEN".to_string()), + pid: 43, + start_time_unix: Some(98_765), + image_name: Some("holder.exe".to_string()), + service_name: None, + command_line: Vec::new(), + }; + let mut probe_count = 0_u8; + let mut terminated = Vec::::new(); + + let report = free_port_with_initial_owners_using( + 5002, + ProtocolFilter::Tcp, + true, + Duration::ZERO, + vec![initial.clone()], + |_, _| { + probe_count += 1; + if probe_count == 1 { + Ok(vec![initial.clone()]) + } else { + Ok(Vec::new()) + } + }, + |targets| { + terminated.extend_from_slice(targets); + Ok(()) + }, + ) + .expect("successful termination should clear actionable owners"); + + assert_eq!( + terminated, + vec![windowsupport::ProcessKillTarget { + pid: 43, + start_time_unix: Some(98_765), + }] + ); + assert!(report.ok); + assert!(report.escalated); + assert!(report.final_owners.is_empty()); + assert!( + report + .attempts + .iter() + .any(|attempt| attempt == "terminated remaining owners") + ); + } + + #[test] + fn force_termination_passes_process_identity_to_terminator() { + let initial = PortOwner { + port: 5003, + protocol: PortProtocol::Tcp, + local_address: "127.0.0.1".to_string(), + state: Some("LISTEN".to_string()), + pid: 44, + start_time_unix: Some(55_555), + image_name: Some("holder.exe".to_string()), + service_name: None, + command_line: Vec::new(), + }; + let mut probe_count = 0_u8; + let mut terminated = Vec::::new(); + + let report = free_port_with_initial_owners_using( + 5003, + ProtocolFilter::Tcp, + true, + Duration::ZERO, + vec![initial.clone()], + |_, _| { + probe_count += 1; + if probe_count == 1 { + Ok(vec![initial.clone()]) + } else { + Ok(Vec::new()) + } + }, + |targets| { + terminated.extend_from_slice(targets); + Ok(()) + }, + ) + .expect("successful termination should clear actionable owners"); + + assert!(report.ok); + assert_eq!( + terminated, + vec![windowsupport::ProcessKillTarget { + pid: 44, + start_time_unix: Some(55_555), + }] + ); + } + + #[test] + fn empty_results_render_compact_summary() { + let text = render_text(&[PortActionResult { + port: 65431, + protocol: ProtocolFilter::Any, + initial_owners: Vec::new(), + attempts: Vec::new(), + final_owners: Vec::new(), + residual_owners: Vec::new(), + ok: true, + escalated: false, + error: None, + }]); + assert_eq!(text, "port 65431 [any] ok=true owners=0\n"); + } +} diff --git a/crates/portunlock/src/main.rs b/crates/portunlock/src/main.rs new file mode 100644 index 0000000..ea2c050 --- /dev/null +++ b/crates/portunlock/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `portunlock`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(portunlock::main_entry()); +} diff --git a/crates/portunlock/tests/portunlock_cli.rs b/crates/portunlock/tests/portunlock_cli.rs new file mode 100644 index 0000000..62e4eab --- /dev/null +++ b/crates/portunlock/tests/portunlock_cli.rs @@ -0,0 +1,95 @@ +//! Integration tests for the `portunlock` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command as StdCommand, Stdio}; + +fn cargo_command() -> Command { + Command::cargo_bin("portunlock").expect("binary") +} + +struct Holder { + child: Child, + tcp_port: u16, + udp_port: u16, +} + +impl Drop for Holder { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn spawn_holder() -> Holder { + let script = "\ + $ErrorActionPreference='Stop'; \ + $tcp=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,0); \ + $tcp.Start(); \ + $udp=[System.Net.Sockets.UdpClient]::new(0); \ + $tcpPort=([System.Net.IPEndPoint]$tcp.LocalEndpoint).Port; \ + $udpPort=([System.Net.IPEndPoint]$udp.Client.LocalEndPoint).Port; \ + [Console]::Out.WriteLine(\"$tcpPort,$udpPort\"); \ + [Console]::Out.Flush(); \ + try { Start-Sleep -Seconds 30 } finally { $tcp.Stop(); $udp.Close() }"; + let mut child = StdCommand::new("pwsh") + .args(["-NoProfile", "-Command", script]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn holder"); + let stdout = child.stdout.take().expect("holder stdout"); + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + let read = reader.read_line(&mut line).expect("holder port line"); + if read == 0 { + let status = child.wait().expect("holder exit status"); + panic!("holder exited before reporting ports: {status}"); + } + let (tcp, udp) = line.trim().split_once(',').expect("tcp,udp port line"); + Holder { + child, + tcp_port: tcp.parse().expect("tcp port"), + udp_port: udp.parse().expect("udp port"), + } +} + +#[test] +fn reports_port_owners_and_non_force_free_reports_remaining_tcp_owner() { + let holder = spawn_holder(); + let tcp_port = holder.tcp_port; + let udp_port = holder.udp_port; + + let mut who = cargo_command(); + who.args(["who", "--json", "--protocol", "any"]) + .arg(tcp_port.to_string()) + .arg(udp_port.to_string()) + .assert() + .success() + .stdout(predicate::str::contains(format!("\"port\":{tcp_port}"))) + .stdout(predicate::str::contains(format!("\"port\":{udp_port}"))) + .stdout(predicate::str::contains("\"protocol\":\"tcp\"")) + .stdout(predicate::str::contains("\"protocol\":\"udp\"")); + + let mut free = cargo_command(); + free.args(["free", "--json", "--protocol", "tcp"]) + .arg(tcp_port.to_string()) + .assert() + .code(3) + .stdout(predicate::str::contains(format!("\"port\":{tcp_port}"))) + .stdout(predicate::str::contains("\"ok\":false")) + .stdout(predicate::str::contains("owners remain")); +} + +#[test] +fn help_includes_port_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--protocol")) + .stdout(predicate::str::contains("portunlock who")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} diff --git a/crates/proctree/Cargo.toml b/crates/proctree/Cargo.toml new file mode 100644 index 0000000..1eb2add --- /dev/null +++ b/crates/proctree/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "proctree" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect Windows process trees with compact AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true +windowsupport = { path = "../windowsupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/proctree/src/lib.rs b/crates/proctree/src/lib.rs new file mode 100644 index 0000000..9bc81c4 --- /dev/null +++ b/crates/proctree/src/lib.rs @@ -0,0 +1,900 @@ +//! The `proctree` command inspects process trees. +#![allow(clippy::multiple_crate_versions)] + +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, print_json, print_quick_help_error, print_structured, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use regex_lite::{Regex, RegexBuilder}; +use serde::Serialize; +use windowsupport::{ProcessDescriptor, sleep_for, snapshot_processes}; + +const HELP: &str = "\ +Inspect Windows process trees with compact AI-friendly output. + +Windows only. + +Usage: + proctree [OPTIONS] system + proctree [OPTIONS] root + proctree [OPTIONS] run -- + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --match Filter by image name or command line + --max-depth Optional maximum depth to emit + --include-cmdline Include command-line text in compact output + --orphans Highlight descendants still alive after the root exits + -h, --help Show this help text + -V, --version Show the command version + +Examples: + proctree system + proctree root 1234 --json | ConvertFrom-Json + proctree run -- pwsh -NoProfile -Command \"npm test\" + proctree system --match 'pwsh|Mercury' --include-cmdline +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + mode: Mode, + match_pattern: Option, + max_depth: Option, + include_cmdline: bool, + orphans: bool, +} + +#[derive(Debug, Clone)] +enum Mode { + System, + Root(u32), + Run(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ProcessTreeReport { + root_pid: Option, + message: Option, + nodes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ProcessNode { + pid: u32, + parent_pid: Option, + depth: usize, + image_name: String, + exe: Option, + command_line: Vec, + command_line_preview: String, + start_time_unix: u64, + run_time_seconds: u64, + run_time_display: String, + orphan: bool, +} + +#[derive(Debug, Default)] +struct RunObservation { + root_pid: u32, + observed_processes: HashMap, + observed_descendants: HashSet, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("proctree {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut mode = None::; + let mut match_pattern = None::; + let mut max_depth = None::; + let mut include_cmdline = false; + let mut orphans = false; + let mut collecting_run = false; + let mut run_args = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + if collecting_run { + if let ArgValue(value) = argument { + run_args.push(value); + continue; + } + return Err(CliError::usage( + "run only accepts command arguments after --", + )); + } + + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, empty_cli(common))), + Long("version") | Short('V') => { + return Ok((ParseOutcome::Version, empty_cli(common))); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("match") => { + match_pattern = Some(parse_regex(&parser_value_string(&mut parser, "--match")?)?); + } + Long("max-depth") => { + max_depth = Some(parse_usize_flag( + "--max-depth", + &parser_value_string(&mut parser, "--max-depth")?, + )?); + } + Long("include-cmdline") => include_cmdline = true, + Long("orphans") => orphans = true, + ArgValue(value) => { + if matches!(mode, Some(Mode::Run(_))) { + collecting_run = true; + run_args.push(value); + continue; + } + let text = arg_to_string(value, "subcommand")?; + if mode.is_none() { + mode = Some(parse_mode_token(&text, &mut parser)?); + } else { + return Err(CliError::usage( + "unexpected positional argument; use --help to see available options", + )); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let mode = match mode { + Some(Mode::Run(_)) => { + if run_args.is_empty() { + return Err(CliError::usage("run requires a command after --")); + } + Mode::Run(run_args) + } + Some(mode) => mode, + None => { + return Err(CliError::usage( + "provide a subcommand: system, root, or run", + )); + } + }; + + Ok(( + ParseOutcome::Run, + Cli { + common, + mode, + match_pattern, + max_depth, + include_cmdline, + orphans, + }, + )) +} + +const fn empty_cli(common: CommonArgs) -> Cli { + Cli { + common, + mode: Mode::System, + match_pattern: None, + max_depth: None, + include_cmdline: false, + orphans: false, + } +} + +fn arg_to_string(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_mode_token(text: &str, parser: &mut lexopt::Parser) -> Result { + match text { + "system" => Ok(Mode::System), + "root" => { + let pid = parser_value_string(parser, "root pid")? + .parse::() + .map_err(|error| CliError::usage(format!("invalid root pid: {error}")))?; + Ok(Mode::Root(pid)) + } + "run" => Ok(Mode::Run(Vec::new())), + _ => Err(CliError::usage( + "unknown subcommand; expected system, root, or run", + )), + } +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_regex(value: &str) -> Result { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(|error| CliError::usage(format!("invalid --match regex '{value}': {error}"))) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + let report = match &cli.mode { + Mode::System => build_system_report(cli), + Mode::Root(pid) => build_root_report(cli, *pid, false), + Mode::Run(command) => build_run_report(cli, command)?, + }; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_report(&report, cli.include_cmdline)), + } + + Ok(map_result_count(report.nodes.len())) +} + +fn build_system_report(cli: &Cli) -> ProcessTreeReport { + let processes = filter_processes(snapshot_processes(), cli.match_pattern.as_ref()); + let nodes = flatten_system_nodes(&processes, cli.max_depth); + ProcessTreeReport { + root_pid: None, + message: None, + nodes, + } +} + +fn build_root_report(cli: &Cli, root_pid: u32, orphan_mode: bool) -> ProcessTreeReport { + let processes = filter_processes(snapshot_processes(), cli.match_pattern.as_ref()); + let nodes = flatten_subtree_nodes(&processes, root_pid, cli.max_depth, orphan_mode); + ProcessTreeReport { + root_pid: Some(root_pid), + message: nodes + .is_empty() + .then(|| format!("root process {root_pid} not found at snapshot time")), + nodes, + } +} + +fn build_run_report(cli: &Cli, command: &[OsString]) -> Result { + let Some(program) = command.first() else { + return Err(CliError::usage("run requires a command")); + }; + let mut child = Command::new(program) + .args(&command[1..]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| CliError::runtime(format!("failed to launch command: {error}")))?; + let root_pid = child.id(); + + let mut observation = RunObservation::new(root_pid); + loop { + let snapshot = snapshot_processes(); + observation.record(&snapshot); + if child + .try_wait() + .map_err(|error| CliError::runtime(format!("failed to poll command: {error}")))? + .is_some() + { + break; + } + sleep_for(Duration::from_millis(75)); + } + + let mut final_snapshot = snapshot_processes(); + observation.record(&final_snapshot); + if cli.orphans { + sleep_for(Duration::from_millis(150)); + final_snapshot = snapshot_processes(); + observation.record(&final_snapshot); + } + + Ok(observation.build_report(cli, &final_snapshot)) +} + +impl RunObservation { + fn new(root_pid: u32) -> Self { + Self { + root_pid, + observed_processes: HashMap::new(), + observed_descendants: HashSet::new(), + } + } + + fn record(&mut self, processes: &[ProcessDescriptor]) { + let process_map = processes + .iter() + .cloned() + .map(|process| (process.pid, process)) + .collect::>(); + if let Some(root) = process_map.get(&self.root_pid) { + self.observed_processes.insert(self.root_pid, root.clone()); + } + let children = build_children_map(processes); + let descendants = collect_descendant_ids(&children, self.root_pid); + for pid in descendants.iter().copied() { + if let Some(process) = process_map.get(&pid) { + self.observed_processes.insert(pid, process.clone()); + } + } + self.observed_descendants.extend(descendants); + } + + fn build_report(&self, cli: &Cli, live_processes: &[ProcessDescriptor]) -> ProcessTreeReport { + let historical = self + .observed_processes + .values() + .cloned() + .collect::>(); + let mut nodes = flatten_subtree_nodes(&historical, self.root_pid, cli.max_depth, false); + if let Some(pattern) = cli.match_pattern.as_ref() { + nodes.retain(|node| { + pattern.is_match(&node.image_name) + || node.command_line.iter().any(|arg| pattern.is_match(arg)) + }); + } + if cli.orphans { + let live_pids = live_processes + .iter() + .map(|process| process.pid) + .collect::>(); + for node in &mut nodes { + node.orphan = node.pid != self.root_pid + && self.observed_descendants.contains(&node.pid) + && live_pids.contains(&node.pid); + } + } + ProcessTreeReport { + root_pid: Some(self.root_pid), + message: nodes + .is_empty() + .then(|| format!("root process {} exited before snapshot", self.root_pid)), + nodes, + } + } +} + +fn filter_processes( + processes: Vec, + pattern: Option<&Regex>, +) -> Vec { + if let Some(pattern) = pattern { + processes + .into_iter() + .filter(|process| { + pattern.is_match(&process.image_name) + || process.command_line.iter().any(|arg| pattern.is_match(arg)) + }) + .collect() + } else { + processes + } +} + +fn flatten_system_nodes( + processes: &[ProcessDescriptor], + max_depth: Option, +) -> Vec { + let children = build_children_map(processes); + let process_map = processes + .iter() + .cloned() + .map(|process| (process.pid, process)) + .collect::>(); + let mut roots = processes + .iter() + .filter(|process| { + process + .parent_pid + .is_none_or(|parent| !process_map.contains_key(&parent)) + }) + .map(|process| process.pid) + .collect::>(); + roots.sort_unstable(); + + let mut nodes = Vec::new(); + for root in roots { + collect_tree_nodes( + root, + 0, + &children, + &process_map, + max_depth, + false, + &mut nodes, + ); + } + nodes +} + +fn flatten_subtree_nodes( + processes: &[ProcessDescriptor], + root_pid: u32, + max_depth: Option, + orphan_mode: bool, +) -> Vec { + let children = build_children_map(processes); + let process_map = processes + .iter() + .cloned() + .map(|process| (process.pid, process)) + .collect::>(); + let mut nodes = Vec::new(); + if process_map.contains_key(&root_pid) { + collect_tree_nodes( + root_pid, + 0, + &children, + &process_map, + max_depth, + orphan_mode, + &mut nodes, + ); + } else if orphan_mode { + let descendants = collect_descendant_ids(&children, root_pid); + let mut orphan_pids = descendants.into_iter().collect::>(); + orphan_pids.sort_unstable(); + for pid in orphan_pids { + if let Some(process) = process_map.get(&pid) { + nodes.push(ProcessNode { + pid: process.pid, + parent_pid: process.parent_pid, + depth: 0, + image_name: process.image_name.clone(), + exe: process.exe.clone(), + command_line: process.command_line.clone(), + command_line_preview: compact_command_line(&process.command_line), + start_time_unix: process.start_time_unix, + run_time_seconds: process.run_time_seconds, + run_time_display: format_run_time(process.run_time_seconds), + orphan: true, + }); + } + } + } + nodes +} + +fn build_children_map(processes: &[ProcessDescriptor]) -> HashMap> { + let mut map = HashMap::>::new(); + for process in processes { + if let Some(parent) = process.parent_pid { + map.entry(parent).or_default().push(process.pid); + } + } + for children in map.values_mut() { + children.sort_unstable(); + } + map +} + +fn collect_tree_nodes( + pid: u32, + depth: usize, + children: &HashMap>, + process_map: &HashMap, + max_depth: Option, + orphan: bool, + nodes: &mut Vec, +) { + if max_depth.is_some_and(|max_depth| depth > max_depth) { + return; + } + let Some(process) = process_map.get(&pid) else { + return; + }; + nodes.push(ProcessNode { + pid: process.pid, + parent_pid: process.parent_pid, + depth, + image_name: process.image_name.clone(), + exe: process.exe.clone(), + command_line: process.command_line.clone(), + command_line_preview: compact_command_line(&process.command_line), + start_time_unix: process.start_time_unix, + run_time_seconds: process.run_time_seconds, + run_time_display: format_run_time(process.run_time_seconds), + orphan, + }); + if let Some(child_pids) = children.get(&pid) { + for child in child_pids { + collect_tree_nodes( + *child, + depth + 1, + children, + process_map, + max_depth, + orphan, + nodes, + ); + } + } +} + +fn collect_descendant_ids(children: &HashMap>, root_pid: u32) -> HashSet { + let mut seen = HashSet::new(); + let mut stack = Vec::new(); + if let Some(root_children) = children.get(&root_pid) { + stack.extend(root_children.iter().copied()); + } + while let Some(pid) = stack.pop() { + if seen.insert(pid) + && let Some(next) = children.get(&pid) + { + stack.extend(next.iter().copied()); + } + } + seen +} + +fn render_report(report: &ProcessTreeReport, include_cmdline: bool) -> String { + let mut rendered = String::new(); + if let Some(root_pid) = report.root_pid { + writeln!(rendered, "root_pid={root_pid} nodes={}", report.nodes.len()) + .expect("writing to a String cannot fail"); + } else { + writeln!(rendered, "system nodes={}", report.nodes.len()) + .expect("writing to a String cannot fail"); + } + if let Some(message) = &report.message { + writeln!(rendered, "message={message}").expect("writing to a String cannot fail"); + } + for node in &report.nodes { + let indent = " ".repeat(node.depth); + let orphan = if node.orphan { " orphan" } else { "" }; + if include_cmdline && !node.command_line.is_empty() { + writeln!( + rendered, + "{indent}pid={} image={} runtime={}{orphan} cmd={}", + node.pid, + node.image_name, + node.run_time_display, + compact_command_line(&node.command_line) + ) + .expect("writing to a String cannot fail"); + } else { + writeln!( + rendered, + "{indent}pid={} image={} runtime={}{orphan}", + node.pid, node.image_name, node.run_time_display + ) + .expect("writing to a String cannot fail"); + } + } + rendered +} + +fn compact_command_line(arguments: &[String]) -> String { + const MAX_COMMAND_LINE_CHARS: usize = 120; + + let rendered = join_command_line(&sanitize_command_line(arguments)); + let character_count = rendered.chars().count(); + if character_count <= MAX_COMMAND_LINE_CHARS { + return rendered; + } + + let visible = MAX_COMMAND_LINE_CHARS.saturating_sub(3); + let prefix = rendered.chars().take(visible).collect::(); + format!("{prefix}...") +} + +fn join_command_line(arguments: &[String]) -> String { + let mut rendered = String::new(); + for argument in arguments { + if !rendered.is_empty() { + rendered.push(' '); + } + rendered.push_str(argument); + } + rendered +} + +fn sanitize_command_line(arguments: &[String]) -> Vec { + let mut sanitized = Vec::with_capacity(arguments.len()); + let mut index = 0_usize; + while index < arguments.len() { + let value = &arguments[index]; + if value.eq_ignore_ascii_case("-encodedcommand") + || value.eq_ignore_ascii_case("-ec") + || value.eq_ignore_ascii_case("/encodedcommand") + { + sanitized.push(value.clone()); + if let Some(payload) = arguments.get(index + 1) { + sanitized.push(format!("", payload.chars().count())); + index += 2; + continue; + } + } + sanitized.push(value.clone()); + index += 1; + } + sanitized +} + +fn format_run_time(run_time_seconds: u64) -> String { + if run_time_seconds == 0 { + "<1s".to_string() + } else { + format!("{run_time_seconds}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_processes() -> Vec { + vec![ + ProcessDescriptor { + pid: 1, + parent_pid: None, + image_name: "root.exe".to_string(), + exe: Some("C:\\root.exe".to_string()), + command_line: vec!["root".to_string()], + start_time_unix: 0, + run_time_seconds: 10, + }, + ProcessDescriptor { + pid: 10, + parent_pid: Some(1), + image_name: "child.exe".to_string(), + exe: None, + command_line: vec!["child".to_string(), "--watch".to_string()], + start_time_unix: 1, + run_time_seconds: 5, + }, + ProcessDescriptor { + pid: 11, + parent_pid: Some(10), + image_name: "grand.exe".to_string(), + exe: None, + command_line: vec!["grand".to_string()], + start_time_unix: 2, + run_time_seconds: 1, + }, + ] + } + + #[test] + fn parser_accepts_run_mode_with_common_flags_after_subcommand() { + let (_, cli) = parse_cli_from([ + "proctree", + "run", + "--json", + "--max-depth", + "2", + "--", + "pwsh", + "-NoProfile", + ]) + .expect("cli"); + + assert!(cli.common.json); + assert_eq!(cli.max_depth, Some(2)); + assert!(matches!(cli.mode, Mode::Run(_))); + } + + #[test] + fn flatten_subtree_marks_orphans() { + let processes = sample_processes().into_iter().skip(1).collect::>(); + + let nodes = flatten_subtree_nodes(&processes, 1, None, true); + assert_eq!(nodes.len(), 2); + assert!(nodes.iter().all(|node| node.orphan)); + } + + #[test] + fn help_and_root_modes_parse_cleanly() { + let (outcome, _) = parse_cli_from(["proctree", "--help"]).expect("help"); + assert_eq!(outcome, ParseOutcome::Help); + + let (_, cli) = parse_cli_from([ + "proctree", + "--match", + "child", + "--include-cmdline", + "root", + "10", + ]) + .expect("root"); + assert!(cli.include_cmdline); + assert!(cli.match_pattern.is_some()); + assert!(matches!(cli.mode, Mode::Root(10))); + } + + #[test] + fn helper_parsers_and_filters_cover_error_paths() { + assert!(matches!( + parse_regex("["), + Err(CliError::Usage(message)) if message.contains("invalid --match regex") + )); + assert!(matches!( + parse_usize_flag("--max-depth", "bad"), + Err(CliError::Usage(message)) if message.contains("invalid --max-depth") + )); + assert!(matches!( + parse_mode_token("wat", &mut lexopt::Parser::from_iter(["proctree"])), + Err(CliError::Usage(message)) if message.contains("unknown subcommand") + )); + + let regex = parse_regex("watch").expect("regex"); + let filtered = filter_processes(sample_processes(), Some(®ex)); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].pid, 10); + } + + #[test] + fn system_and_root_rendering_stay_compact() { + let processes = sample_processes(); + let system_nodes = flatten_system_nodes(&processes, Some(1)); + let root_nodes = flatten_subtree_nodes(&processes, 1, Some(2), false); + let report = ProcessTreeReport { + root_pid: Some(1), + message: None, + nodes: root_nodes, + }; + let text = render_report(&report, true); + + assert_eq!(system_nodes.len(), 2); + assert!(text.contains("root_pid=1")); + assert!(text.contains("cmd=child --watch")); + assert!(text.contains("runtime=5s")); + assert_eq!(format_run_time(0), "<1s"); + } + + #[test] + fn compact_command_line_truncates_long_output() { + let command = compact_command_line(&[ + "pwsh".to_string(), + "-EncodedCommand".to_string(), + "A".repeat(200), + ]); + + assert_eq!(command, "pwsh -EncodedCommand "); + } + + #[test] + fn children_maps_and_descendants_are_stable() { + let processes = sample_processes(); + let children = build_children_map(&processes); + let descendants = collect_descendant_ids(&children, 1); + + assert_eq!(children.get(&1), Some(&vec![10])); + assert!(descendants.contains(&10)); + assert!(descendants.contains(&11)); + } + + #[test] + fn run_observation_preserves_seen_descendants_after_root_exits() { + let root = ProcessDescriptor { + pid: 1, + parent_pid: None, + image_name: "root.exe".to_string(), + exe: None, + command_line: vec!["root".to_string()], + start_time_unix: 0, + run_time_seconds: 1, + }; + let child = ProcessDescriptor { + pid: 10, + parent_pid: Some(1), + image_name: "child.exe".to_string(), + exe: None, + command_line: vec!["child".to_string()], + start_time_unix: 0, + run_time_seconds: 1, + }; + let detached_child = ProcessDescriptor { + parent_pid: None, + ..child.clone() + }; + + let mut observation = RunObservation::new(1); + observation.record(&[root, child]); + observation.record(std::slice::from_ref(&detached_child)); + + let cli = Cli { + common: CommonArgs::default(), + mode: Mode::System, + match_pattern: None, + max_depth: None, + include_cmdline: false, + orphans: true, + }; + let report = observation.build_report(&cli, &[detached_child]); + assert_eq!(report.root_pid, Some(1)); + assert_eq!(report.message, None); + assert_eq!(report.nodes.len(), 2); + assert!(report.nodes.iter().any(|node| node.pid == 1)); + assert!( + report + .nodes + .iter() + .find(|node| node.pid == 10) + .is_some_and(|node| node.orphan) + ); + assert!( + report + .nodes + .iter() + .find(|node| node.pid == 10) + .is_some_and(|node| node.command_line_preview == "child") + ); + } +} diff --git a/crates/proctree/src/main.rs b/crates/proctree/src/main.rs new file mode 100644 index 0000000..e9b6ef7 --- /dev/null +++ b/crates/proctree/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `proctree`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(proctree::main_entry()); +} diff --git a/crates/proctree/tests/proctree_cli.rs b/crates/proctree/tests/proctree_cli.rs new file mode 100644 index 0000000..f5369cb --- /dev/null +++ b/crates/proctree/tests/proctree_cli.rs @@ -0,0 +1,38 @@ +//! Integration tests for the `proctree` command. + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("proctree").expect("binary") +} + +#[test] +fn reports_rooted_run_tree_as_json() { + let mut command = cargo_command(); + command + .arg("run") + .arg("--json") + .arg("--") + .arg("pwsh") + .arg("-NoProfile") + .arg("-Command") + .arg("Start-Process pwsh -ArgumentList '-NoProfile','-Command','Start-Sleep -Milliseconds 800' -PassThru | Out-Null; Start-Sleep -Milliseconds 200") + .assert() + .success() + .stdout(predicate::str::contains("\"root_pid\"")) + .stdout(predicate::str::contains("\"nodes\"")) + .stdout(predicate::str::contains("\"image_name\":\"pwsh.exe\"")); +} + +#[test] +fn help_includes_proctree_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("system")) + .stdout(predicate::str::contains("root ")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} diff --git a/crates/recent/Cargo.toml b/crates/recent/Cargo.toml new file mode 100644 index 0000000..1393b9a --- /dev/null +++ b/crates/recent/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "recent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "List recently changed files with gitignore-aware filtering." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +humantime.workspace = true +ignore.workspace = true +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +filetime.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/recent/src/cli.rs b/crates/recent/src/cli.rs new file mode 100644 index 0000000..8908027 --- /dev/null +++ b/crates/recent/src/cli.rs @@ -0,0 +1,278 @@ +use std::ffi::OsString; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + parse_input_format, print_quick_help_error, +}; +use lexopt::prelude::{Long, Short, Value}; + +use super::{Cli, KindFilter}; + +const HELP: &str = "\ +List recently changed files with gitignore-aware filtering. + +Usage: + recent [OPTIONS] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --since Maximum accepted age such as 15m or 2h + --ext Comma-delimited list of file extensions to keep + --name Regular expression applied to the basename + --kind Filesystem kinds to report: file, dir, any + --limit Maximum number of entries to print + --root Explicit root directory to scan + -h, --help Show this help text + -V, --version Show the command version + +Examples: + recent --root . --since 2h --ext rs --name '^(lib|main)$' + recent --root . --kind dir --limit 10 + recent --root . --since 30d --ext cs --name 'Player|Trainer|Plugin' --limit 40 + '.' | recent --json | ConvertFrom-Json + +Notes: + On large repos, start with --since plus at least one of --ext, --name, or --limit to keep output high-signal +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("recent {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match super::run(cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + since: None, + ext: Vec::new(), + name: None, + kind: KindFilter::File, + limit: None, + roots: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("since") => { + cli.since = Some(parser_value_string(&mut parser, "--since")?); + } + Long("ext") => { + cli.ext.extend(split_csv_values(&parser_value_string( + &mut parser, + "--ext", + )?)); + } + Long("name") => { + cli.name = Some(parser_value_string(&mut parser, "--name")?); + } + Long("kind") => { + cli.kind = parse_kind_filter(&parser_value_string(&mut parser, "--kind")?)?; + } + Long("limit") => { + cli.limit = Some(parse_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + Long("root") => { + cli.roots.push(PathBuf::from( + parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?, + )); + } + Value(unexpected) => { + return Err(CliError::usage(format!( + "unexpected positional argument '{}'; recent only accepts --root or stdin input", + unexpected.to_string_lossy() + ))); + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn split_csv_values(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() +} + +fn parse_kind_filter(value: &str) -> Result { + match value { + "file" => Ok(KindFilter::File), + "dir" => Ok(KindFilter::Dir), + "any" => Ok(KindFilter::Any), + other => Err(CliError::usage(format!( + "invalid --kind value '{other}'; expected file, dir, or any" + ))), + } +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use common::{ColorChoice, InputFormat}; + + #[test] + fn parse_cli_accepts_common_and_recent_specific_flags() { + let (_, cli) = parse_cli_from([ + "recent", + "--json", + "--input-format", + "lines", + "--color", + "never", + "--quiet", + "--since", + "2h", + "--ext", + "rs,toml", + "--name", + "^(lib|main)$", + "--kind", + "dir", + "--limit", + "5", + "--root", + ".", + ]) + .expect("cli"); + + assert!(cli.common.json); + assert_eq!(cli.common.input_format, InputFormat::Lines); + assert_eq!(cli.common.color, ColorChoice::Never); + assert!(cli.common.quiet); + assert_eq!(cli.since.as_deref(), Some("2h")); + assert_eq!(cli.ext, vec!["rs", "toml"]); + assert_eq!(cli.name.as_deref(), Some("^(lib|main)$")); + assert_eq!(cli.kind, KindFilter::Dir); + assert_eq!(cli.limit, Some(5)); + assert_eq!(cli.roots, vec![PathBuf::from(".")]); + } + + #[test] + fn parse_cli_supports_help_and_version_outcomes() { + let (outcome, cli) = parse_cli_from(["recent", "--help"]).expect("help"); + assert_eq!(outcome, ParseOutcome::Help); + assert!(cli.roots.is_empty()); + + let (outcome, cli) = parse_cli_from(["recent", "-V"]).expect("version"); + assert_eq!(outcome, ParseOutcome::Version); + assert_eq!(cli.kind, KindFilter::File); + } + + #[test] + fn parse_cli_rejects_unexpected_positionals_and_invalid_values() { + assert!(matches!( + parse_cli_from(["recent", "demo"]), + Err(CliError::Usage(message)) + if message.contains("unexpected positional argument") + )); + assert!(matches!( + parse_cli_from(["recent", "--kind", "socket"]), + Err(CliError::Usage(message)) + if message.contains("invalid --kind value 'socket'") + )); + assert!(matches!( + parse_cli_from(["recent", "--limit", "many"]), + Err(CliError::Usage(message)) + if message.contains("invalid --limit value 'many'") + )); + } + + #[test] + fn helper_parsers_cover_csv_and_kind_paths() { + assert_eq!( + split_csv_values("rs, toml, , json "), + vec!["rs", "toml", "json"] + ); + assert_eq!(parse_kind_filter("file").expect("file"), KindFilter::File); + assert_eq!(parse_kind_filter("any").expect("any"), KindFilter::Any); + } +} diff --git a/crates/recent/src/lib.rs b/crates/recent/src/lib.rs new file mode 100644 index 0000000..1d113e8 --- /dev/null +++ b/crates/recent/src/lib.rs @@ -0,0 +1,648 @@ +//! The `recent` command lists recently modified files or directories. + +use std::cmp::{Ordering, Reverse}; +use std::collections::BinaryHeap; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use common::{ + CliError, ExitCode, RenderMode, print_json, print_structured, read_existing_stdin_path_records, + should_read_stdin, +}; +use ignore::WalkBuilder; +use regex_lite::Regex; +use serde::Serialize; + +const MAX_SCANNED_ENTRIES: usize = 250_000; + +/// Hidden CLI entry helpers for the `recent` binary. +#[doc(hidden)] +pub mod cli; + +/// Selects which filesystem node kinds should be reported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum KindFilter { + /// Report only regular files. + File, + /// Report only directories. + Dir, + /// Report both files and directories. + Any, +} + +#[derive(Debug, Clone)] +struct Cli { + common: common::CommonArgs, + since: Option, + ext: Vec, + name: Option, + kind: KindFilter, + limit: Option, + roots: Vec, +} + +/// Collected runtime options for recent-file scans. +#[derive(Debug, Clone)] +pub struct RecentOptions { + /// Root directories to traverse. + pub roots: Vec, + /// Optional maximum age for returned entries. + pub since: Option, + /// File extensions to keep, without a leading dot. + pub exts: Vec, + /// Filesystem object kinds to include. + pub kind: KindFilter, + /// Maximum number of entries to return. + pub limit: Option, + /// Optional basename filter applied with a regular expression. + pub name_pattern: Option, +} + +/// A single filesystem entry returned by `recent`. +#[derive(Debug, Clone, Serialize)] +pub struct RecentEntry { + /// Filesystem path for the matched entry. + pub path: PathBuf, + /// Whether the entry is a file or directory. + pub kind: KindFilter, + /// RFC 3339 timestamp rendered for CLI and JSON output. + pub modified_rfc3339: String, + #[serde(skip_serializing)] + modified: SystemTime, +} + +#[derive(Debug, Clone)] +struct RankedEntry(RecentEntry); + +/// Scans the configured roots and returns matching entries ordered by freshness. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when directory traversal or metadata access fails. +pub fn collect_recent( + options: &RecentOptions, + now: SystemTime, +) -> Result, CliError> { + collect_recent_with_entry_cap(options, now, MAX_SCANNED_ENTRIES) +} + +fn collect_recent_with_entry_cap( + options: &RecentOptions, + now: SystemTime, + max_scanned_entries: usize, +) -> Result, CliError> { + if options.limit == Some(0) { + return Ok(Vec::new()); + } + + let cutoff = options.since.map(|duration| now - duration); + let exts = options + .exts + .iter() + .map(|ext| ext.trim_start_matches('.').to_ascii_lowercase()) + .collect::>(); + let mut entries = Vec::new(); + let mut limited_entries = options + .limit + .map_or_else(BinaryHeap::new, BinaryHeap::with_capacity); + let mut scanned = 0_usize; + for root in &options.roots { + let walker = WalkBuilder::new(root) + .standard_filters(true) + .require_git(false) + .build(); + + for candidate in walker { + scanned += 1; + if scanned > max_scanned_entries { + return Err(CliError::runtime(format!( + "recent scan exceeded {max_scanned_entries} filesystem entries; narrow the scan with --root, --since, --ext, or --name" + ))); + } + let candidate = + candidate.map_err(|error| CliError::runtime(format!("walk failed: {error}")))?; + let path = candidate.path(); + if path == root { + continue; + } + + let file_type = candidate.file_type().ok_or_else(|| { + CliError::runtime(format!( + "failed to determine file type for {}", + path.display() + )) + })?; + let is_file = file_type.is_file(); + let is_dir = file_type.is_dir(); + + if !matches_kind(options.kind, is_file, is_dir) { + continue; + } + + if !matches_extension(&exts, path, is_dir) { + continue; + } + + if !matches_basename(options.name_pattern.as_ref(), path) { + continue; + } + + let metadata = candidate.metadata().map_err(|error| { + CliError::runtime(format!( + "failed to read metadata for {}: {error}", + path.display() + )) + })?; + let modified = metadata.modified().map_err(|error| { + CliError::runtime(format!( + "failed to read modified time for {}: {error}", + path.display() + )) + })?; + + if cutoff.is_some_and(|instant| modified < instant) { + continue; + } + + let entry = RecentEntry { + path: path.to_path_buf(), + kind: if is_dir { + KindFilter::Dir + } else { + KindFilter::File + }, + modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(), + modified, + }; + + if let Some(limit) = options.limit { + push_limited_entry(&mut limited_entries, limit, entry); + } else { + entries.push(entry); + } + } + } + + if options.limit.is_some() { + entries = finalize_limited_entries(limited_entries); + } else { + sort_recent_entries(&mut entries); + } + Ok(entries) +} + +pub use cli::main_entry; + +/// Executes the `recent` command with the provided arguments. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] for invalid filters and [`CliError::Runtime`] for I/O failures. +fn run(cli: Cli) -> Result { + let options = RecentOptions { + roots: collect_roots(&cli)?, + since: parse_since(cli.since.as_deref())?, + exts: cli.ext, + name_pattern: parse_name_pattern(cli.name.as_deref())?, + kind: cli.kind, + limit: cli.limit, + }; + + let entries = collect_recent(&options, SystemTime::now())?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&entries)?, + RenderMode::Toon => print_structured(&entries, RenderMode::Toon)?, + RenderMode::Text => { + if entries.is_empty() { + if !cli.common.quiet { + println!("no_matches=true"); + } + } else { + for entry in &entries { + println!( + "{} {} {}", + entry.modified_rfc3339, + kind_label(entry.kind), + entry.path.display() + ); + } + } + } + } + + Ok(if entries.is_empty() { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn collect_roots(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.roots.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let stdin_roots = parse_roots_from_string(&buffer, cli.common.input_format)?; + if !stdin_roots.is_empty() { + return Ok(stdin_roots); + } + } + + if !cli.roots.is_empty() { + return common::expand_input_patterns(&cli.roots, "recent"); + } + + std::env::current_dir() + .map(|path| vec![path]) + .map_err(|error| CliError::runtime(format!("failed to resolve current directory: {error}"))) +} + +fn parse_since(raw: Option<&str>) -> Result, CliError> { + raw.map(humantime::parse_duration) + .transpose() + .map_err(|error| CliError::usage(format!("invalid --since value: {error}"))) +} + +fn parse_roots_from_string( + buffer: &str, + input_format: common::InputFormat, +) -> Result, CliError> { + Ok(read_existing_stdin_path_records(buffer, input_format, "recent")?.unwrap_or_default()) +} + +fn parse_name_pattern(raw: Option<&str>) -> Result, CliError> { + raw.map(Regex::new) + .transpose() + .map_err(|error| CliError::usage(format!("invalid --name regex: {error}"))) +} + +const fn matches_kind(filter: KindFilter, is_file: bool, is_dir: bool) -> bool { + match filter { + KindFilter::File => is_file, + KindFilter::Dir => is_dir, + KindFilter::Any => is_file || is_dir, + } +} + +fn matches_extension(exts: &[String], path: &Path, is_dir: bool) -> bool { + if exts.is_empty() { + return true; + } + if is_dir { + return false; + } + + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + exts.iter() + .any(|candidate| candidate.eq_ignore_ascii_case(ext)) + }) +} + +fn matches_basename(pattern: Option<&Regex>, path: &Path) -> bool { + pattern.is_none_or(|regex| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| regex.is_match(name)) + || path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| regex.is_match(stem)) + }) +} + +const fn kind_label(kind: KindFilter) -> &'static str { + match kind { + KindFilter::File => "file", + KindFilter::Dir => "dir", + KindFilter::Any => "any", + } +} + +fn push_limited_entry( + entries: &mut BinaryHeap>, + limit: usize, + entry: RecentEntry, +) { + let ranked = RankedEntry(entry); + if entries.len() < limit { + entries.push(Reverse(ranked)); + return; + } + + if entries + .peek() + .is_some_and(|oldest_retained| ranked > oldest_retained.0) + { + let _ = entries.pop(); + entries.push(Reverse(ranked)); + } +} + +fn finalize_limited_entries(entries: BinaryHeap>) -> Vec { + let mut results = entries + .into_sorted_vec() + .into_iter() + .map(|Reverse(entry)| entry.0) + .collect::>(); + sort_recent_entries(&mut results); + results +} + +fn sort_recent_entries(entries: &mut [RecentEntry]) { + entries.sort_unstable_by(|left, right| { + right + .modified + .cmp(&left.modified) + .then_with(|| left.path.cmp(&right.path)) + }); +} + +impl PartialEq for RankedEntry { + fn eq(&self, other: &Self) -> bool { + self.0.modified == other.0.modified && self.0.path == other.0.path + } +} + +impl Eq for RankedEntry {} + +impl PartialOrd for RankedEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RankedEntry { + fn cmp(&self, other: &Self) -> Ordering { + self.0 + .modified + .cmp(&other.0.modified) + .then_with(|| self.0.path.cmp(&other.0.path)) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::CommonArgs; + use filetime::{FileTime, set_file_mtime}; + use tempfile::tempdir; + + use super::*; + + fn touch_with_age(path: &Path, age: Duration) { + fs::write(path, path.display().to_string()).expect("write fixture"); + let timestamp = FileTime::from_system_time(SystemTime::now() - age); + set_file_mtime(path, timestamp).expect("set file mtime"); + } + + fn common_args(json: bool) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format: common::InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn parse_since_accepts_none_and_reports_invalid_values() { + assert_eq!(parse_since(None).expect("no since"), None); + assert_eq!( + parse_since(Some("15m")).expect("duration"), + Some(Duration::from_secs(15 * 60)) + ); + + let error = parse_since(Some("nonsense")).expect_err("invalid duration should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("invalid --since value") + )); + } + + #[test] + fn parse_roots_from_string_discards_blank_lines() { + let root = tempdir().expect("tempdir"); + let first = root.path().join("logs"); + let second = root.path().join("src"); + fs::write(&first, "").expect("first"); + fs::write(&second, "").expect("second"); + assert_eq!( + parse_roots_from_string( + &format!(" \n{}\n\n{} \n", first.display(), second.display()), + common::InputFormat::Lines + ) + .expect("roots"), + vec![first, second] + ); + } + + #[test] + fn parse_name_pattern_accepts_none_and_reports_invalid_values() { + assert!(parse_name_pattern(None).expect("none").is_none()); + assert!( + parse_name_pattern(Some("^(lib|main)$")) + .expect("valid regex") + .is_some() + ); + + let error = parse_name_pattern(Some("(")).expect_err("invalid regex should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("invalid --name regex") + )); + } + + #[test] + fn kind_and_extension_helpers_cover_all_variants() { + assert!(matches_kind(KindFilter::File, true, false)); + assert!(matches_kind(KindFilter::Dir, false, true)); + assert!(matches_kind(KindFilter::Any, true, false)); + assert!(matches_kind(KindFilter::Any, false, true)); + assert!(!matches_kind(KindFilter::File, false, true)); + + let path = Path::new("demo.RS"); + assert!(matches_extension(&[], path, false)); + assert!(matches_extension(&["rs".into()], path, false)); + assert!(!matches_extension(&["txt".into()], path, false)); + assert!(!matches_extension(&["rs".into()], path, true)); + assert!(matches_basename( + parse_name_pattern(Some("^demo$")).expect("regex").as_ref(), + Path::new("demo.rs") + )); + assert!(!matches_basename( + parse_name_pattern(Some("^other$")).expect("regex").as_ref(), + Path::new("demo.rs") + )); + + assert_eq!(kind_label(KindFilter::File), "file"); + assert_eq!(kind_label(KindFilter::Dir), "dir"); + assert_eq!(kind_label(KindFilter::Any), "any"); + } + + #[test] + fn collect_roots_prefers_explicit_roots_and_defaults_to_current_dir() { + let explicit_root = PathBuf::from("C:\\jade-explicit-root"); + let explicit = collect_roots(&Cli { + common: common_args(false), + since: None, + ext: Vec::new(), + name: None, + kind: KindFilter::File, + limit: None, + roots: vec![explicit_root.clone()], + }) + .expect("explicit roots"); + assert_eq!(explicit, vec![explicit_root]); + + let fallback = collect_roots(&Cli { + common: common_args(false), + since: None, + ext: Vec::new(), + name: None, + kind: KindFilter::File, + limit: None, + roots: Vec::new(), + }) + .expect("current dir root"); + assert_eq!(fallback.len(), 1); + assert_eq!( + fallback[0], + std::env::current_dir().expect("current directory") + ); + } + + #[test] + fn collect_recent_filters_extensions_since_and_limit() { + let root = tempdir().expect("tempdir"); + let old_rs = root.path().join("old.rs"); + let new_rs = root.path().join("new.rs"); + let new_txt = root.path().join("new.txt"); + touch_with_age(&old_rs, Duration::from_secs(60 * 60 * 2)); + touch_with_age(&new_rs, Duration::from_secs(30)); + touch_with_age(&new_txt, Duration::from_secs(10)); + + let entries = collect_recent( + &RecentOptions { + roots: vec![root.path().to_path_buf()], + since: Some(Duration::from_secs(60 * 60)), + exts: vec!["rs".into()], + name_pattern: parse_name_pattern(Some("^new$")).expect("regex"), + kind: KindFilter::File, + limit: Some(1), + }, + SystemTime::now(), + ) + .expect("recent entries"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, KindFilter::File); + assert!(entries[0].path.ends_with("new.rs")); + } + + #[test] + fn collect_recent_can_return_directories() { + let root = tempdir().expect("tempdir"); + let directory = root.path().join("plugins"); + fs::create_dir_all(&directory).expect("directory"); + + let entries = collect_recent( + &RecentOptions { + roots: vec![root.path().to_path_buf()], + since: None, + exts: Vec::new(), + name_pattern: None, + kind: KindFilter::Dir, + limit: None, + }, + SystemTime::now(), + ) + .expect("directory entries"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, KindFilter::Dir); + assert_eq!(entries[0].path, directory); + } + + #[test] + fn collect_recent_rejects_unbounded_large_tree_scans() { + let root = tempdir().expect("tempdir"); + let max_scanned_entries = 8; + for index in 0..=max_scanned_entries { + touch_with_age( + &root.path().join(format!("file-{index}.txt")), + Duration::from_secs(1), + ); + } + + let error = collect_recent_with_entry_cap( + &RecentOptions { + roots: vec![root.path().to_path_buf()], + since: None, + exts: Vec::new(), + name_pattern: None, + kind: KindFilter::File, + limit: None, + }, + SystemTime::now(), + max_scanned_entries, + ) + .expect_err("large unbounded scan should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("recent scan exceeded") + )); + } + + #[test] + fn run_maps_empty_and_non_empty_results_to_exit_codes() { + let root = tempdir().expect("tempdir"); + let recent_file = root.path().join("active.rs"); + touch_with_age(&recent_file, Duration::from_secs(5)); + + let success = run(Cli { + common: common_args(true), + since: Some("1h".into()), + ext: vec!["rs".into()], + name: Some("^active$".into()), + kind: KindFilter::File, + limit: Some(5), + roots: vec![root.path().to_path_buf()], + }) + .expect("recent run"); + assert_eq!(success, ExitCode::Success); + + let no_results = run(Cli { + common: common_args(false), + since: Some("1s".into()), + ext: vec!["txt".into()], + name: None, + kind: KindFilter::File, + limit: Some(5), + roots: vec![root.path().to_path_buf()], + }) + .expect("empty run"); + assert_eq!(no_results, ExitCode::NoResults); + + let text_success = run(Cli { + common: common_args(false), + since: Some("1h".into()), + ext: vec!["rs".into()], + name: Some("^active$".into()), + kind: KindFilter::File, + limit: Some(5), + roots: vec![root.path().to_path_buf()], + }) + .expect("text run"); + assert_eq!(text_success, ExitCode::Success); + } +} diff --git a/crates/recent/src/main.rs b/crates/recent/src/main.rs new file mode 100644 index 0000000..a78f22e --- /dev/null +++ b/crates/recent/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `recent`. + +fn main() { + std::process::exit(recent::main_entry()); +} diff --git a/crates/recent/tests/recent_cli.rs b/crates/recent/tests/recent_cli.rs new file mode 100644 index 0000000..7f3ab86 --- /dev/null +++ b/crates/recent/tests/recent_cli.rs @@ -0,0 +1,178 @@ +//! Integration tests for the `recent` command. + +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use assert_cmd::Command; +use filetime::{FileTime, set_file_mtime}; +use predicates::prelude::*; +use recent::{KindFilter, RecentOptions, collect_recent}; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("recent").expect("binary") +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +fn touch(path: &std::path::Path, age: Duration) { + fs::write(path, path.display().to_string()).expect("write fixture"); + let timestamp = FileTime::from_system_time(SystemTime::now() - age); + set_file_mtime(path, timestamp).expect("set mtime"); +} + +fn file_recent_options(root: PathBuf) -> RecentOptions { + RecentOptions { + roots: vec![root], + since: None, + exts: Vec::new(), + name_pattern: None, + kind: KindFilter::File, + limit: None, + } +} + +#[test] +fn collect_recent_respects_gitignore() { + let root = tempdir().expect("tempdir"); + fs::write(root.path().join(".gitignore"), "ignored.log\n").expect("gitignore"); + touch(&root.path().join("visible.log"), Duration::from_secs(30)); + touch(&root.path().join("ignored.log"), Duration::from_secs(10)); + + let entries = collect_recent( + &file_recent_options(root.path().to_path_buf()), + SystemTime::now(), + ) + .expect("recent entries"); + + assert_eq!(entries.len(), 1); + assert!(entries[0].path.ends_with("visible.log")); +} + +#[test] +fn filters_since_and_extension_in_json_output() { + let root = tempdir().expect("tempdir"); + touch( + &root.path().join("old.rs"), + Duration::from_secs(60 * 60 * 24), + ); + touch(&root.path().join("new.rs"), Duration::from_secs(30)); + touch(&root.path().join("new.txt"), Duration::from_secs(30)); + + let mut command = cargo_command(); + command + .arg("--root") + .arg(root.path()) + .arg("--since") + .arg("1h") + .arg("--ext") + .arg("rs") + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"path\"").and(predicate::str::contains("new.rs"))) + .stdout(predicate::str::contains("old.rs").not()) + .stdout(predicate::str::contains("new.txt").not()); +} + +#[test] +fn supports_powershell_root_pipeline() { + let root = tempdir().expect("tempdir"); + touch(&root.path().join("one.rs"), Duration::from_secs(10)); + touch(&root.path().join("two.rs"), Duration::from_secs(20)); + + let binary = assert_cmd::cargo::cargo_bin("recent"); + let script = format!( + "{} | & {} --limit 1", + ps_quote(root.path().display()), + ps_quote(binary.display()) + ); + + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("one.rs")); +} + +#[test] +fn filters_by_basename_regex() { + let root = tempdir().expect("tempdir"); + touch(&root.path().join("alpha.rs"), Duration::from_secs(10)); + touch(&root.path().join("beta.rs"), Duration::from_secs(20)); + touch(&root.path().join("gamma.txt"), Duration::from_secs(5)); + + let mut command = cargo_command(); + command + .arg("--root") + .arg(root.path()) + .arg("--name") + .arg("^(alpha|gamma)$") + .assert() + .success() + .stdout(predicate::str::contains("alpha.rs")) + .stdout(predicate::str::contains("gamma.txt")) + .stdout(predicate::str::contains("beta.rs").not()) + .stdout(predicate::str::contains("delta").not()); +} + +#[test] +fn help_includes_name_filter_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--name")) + .stdout(predicate::str::contains( + "recent --root . --since 2h --ext rs --name", + )) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} + +#[test] +fn basename_filter_keeps_directory_suffixes() { + let root = tempdir().expect("tempdir"); + let dotted_dir = root.path().join("plugins.v1"); + fs::create_dir_all(&dotted_dir).expect("directory"); + + let mut command = cargo_command(); + command + .arg("--root") + .arg(root.path()) + .arg("--kind") + .arg("dir") + .arg("--name") + .arg("^plugins\\.v1$") + .assert() + .success() + .stdout(predicate::str::contains("plugins.v1")); +} + +#[test] +fn text_mode_emits_no_matches_hint() { + let root = tempdir().expect("tempdir"); + touch(&root.path().join("alpha.rs"), Duration::from_secs(10)); + + let mut command = cargo_command(); + command + .arg("--root") + .arg(root.path()) + .arg("--name") + .arg("^beta$") + .assert() + .code(1) + .stdout(predicate::str::contains("no_matches=true")); +} diff --git a/crates/refs/Cargo.toml b/crates/refs/Cargo.toml new file mode 100644 index 0000000..a3e2256 --- /dev/null +++ b/crates/refs/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "refs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Find exact symbol use-sites and callers with AST-backed matching and text fallback." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +codeindex = { path = "../codeindex" } +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true diff --git a/crates/refs/src/lib.rs b/crates/refs/src/lib.rs new file mode 100644 index 0000000..8a722ab --- /dev/null +++ b/crates/refs/src/lib.rs @@ -0,0 +1,1396 @@ +//! The `refs` command finds exact symbol use-sites and caller contexts. + +use codeindex::{ + CodeIndexer, CodeLanguage, IndexedSymbol, ReferenceMatch, SUPPORTED_LANGUAGE_LIST, SymbolKind, + detect_language, parse_language_label, +}; +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files, + is_probable_test_path, parse_color_choice, parse_format_choice, parse_input_format, print_json, + print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +const MAX_INDEXED_FILES: usize = 50_000; +const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024; + +const HELP: &str = "\ +Find exact symbol use-sites with AST-backed matching and caller summaries. + +Usage: + refs [OPTIONS] [PATH...] + refs [OPTIONS] --at [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --lang Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript + --kind Restrict caller kinds: module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static + --limit Maximum number of rows to emit + --callers Group hits by enclosing caller definition + --allow-empty Exit 0 when no references are found + --engine Matching engine: auto, ast, text + --at Resolve the target symbol from a definition location + -h, --help Show this help text + -V, --version Show the command version + +Examples: + refs helper .\\fixtures\\polyglot\\repo + refs --callers helper .\\fixtures\\polyglot\\repo --json | ConvertFrom-Json + refs --at .\\fixtures\\polyglot\\repo\\src\\lib.rs:22 .\\fixtures\\polyglot\\repo + '.\\fixtures\\polyglot\\repo' | refs helper --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + symbol: Option, + anchor: Option, + paths: Vec, + languages: Option>, + kinds: Option>, + limit: usize, + callers: bool, + allow_empty: bool, + engine: EngineMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EngineMode { + Auto, + Ast, + Text, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SourceAnchor { + path: PathBuf, + line: usize, + column: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct UseSite { + engine: String, + path: String, + language: CodeLanguage, + name: String, + line: usize, + column: usize, + line_text: String, + enclosing_kind: Option, + enclosing_name: Option, + enclosing_qualified_name: Option, + enclosing_signature: Option, + enclosing_start_line: Option, + enclosing_end_line: Option, + caller: Option, + caller_signature: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CallerHit { + line: usize, + column: usize, + line_text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CallerSummary { + engine: String, + path: String, + language: CodeLanguage, + kind: Option, + name: Option, + qualified_name: Option, + signature: Option, + start_line: usize, + end_line: usize, + hit_count: usize, + hits: Vec, + caller: Option, + caller_signature: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TextOccurrence { + line: usize, + column: usize, + line_text: String, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("refs {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + symbol: None, + anchor: None, + paths: Vec::new(), + languages: None, + kinds: None, + limit: 200, + callers: false, + allow_empty: false, + engine: EngineMode::Auto, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("lang") => { + cli.languages = Some(parse_language_list(&parser_value_string( + &mut parser, + "--lang", + )?)?); + } + Long("kind") => { + cli.kinds = Some(parse_kind_list(&parser_value_string( + &mut parser, + "--kind", + )?)?); + } + Long("limit") => { + cli.limit = + parse_usize_flag("--limit", &parser_value_string(&mut parser, "--limit")?)?; + } + Long("callers") => cli.callers = true, + Long("allow-empty") => cli.allow_empty = true, + Long("engine") => { + cli.engine = parse_engine_mode(&parser_value_string(&mut parser, "--engine")?)?; + } + Long("at") => { + cli.anchor = Some(parse_anchor(&parser_value_string(&mut parser, "--at")?)?); + } + ArgValue(value) => { + if cli.symbol.is_none() && cli.anchor.is_none() { + cli.symbol = Some(os_value_string(value, "symbol")?); + } else { + cli.paths.push(PathBuf::from(value)); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if cli.limit == 0 { + return Err(CliError::usage("--limit must be greater than 0")); + } + if cli.symbol.is_none() && cli.anchor.is_none() { + return Err(CliError::usage("provide an exact symbol name or use --at")); + } + + Ok((ParseOutcome::Run, cli)) +} + +fn run(cli: &Cli) -> Result { + let roots = collect_roots(cli)?; + let files = discover_supported_files(&roots, cli.languages.as_ref())?; + let mut indexer = CodeIndexer::new(); + let target_name = resolve_target_name(cli, &mut indexer)?; + let mut uses = collect_use_sites(cli, &files, &target_name, &mut indexer)?; + + if let Some(kinds) = &cli.kinds { + uses.retain(|item| { + item.enclosing_kind + .is_some_and(|kind| kinds.contains(&kind)) + }); + } + if uses.len() > cli.limit { + uses.truncate(cli.limit); + } + + match (cli.common.render_mode(), cli.callers) { + (RenderMode::Json, false) => print_json(&uses)?, + (RenderMode::Json, true) => print_json(&group_callers(&uses))?, + (RenderMode::Toon, false) => print_structured(&uses, RenderMode::Toon)?, + (RenderMode::Toon, true) => print_structured(&group_callers(&uses), RenderMode::Toon)?, + (RenderMode::Text, false) => { + if uses.is_empty() { + if !cli.common.quiet { + if cli.allow_empty { + println!("0 references"); + } else { + println!("0 references (use --allow-empty to exit 0)"); + } + } + } else { + print!("{}", render_use_sites(&uses)); + } + } + (RenderMode::Text, true) => { + let callers = group_callers(&uses); + if callers.is_empty() { + if !cli.common.quiet { + if cli.allow_empty { + println!("0 callers"); + } else { + println!("0 callers (use --allow-empty to exit 0)"); + } + } + } else { + print!("{}", render_callers(&callers)); + } + } + } + + Ok(if uses.is_empty() { + if cli.allow_empty { + ExitCode::Success + } else { + ExitCode::NoResults + } + } else { + ExitCode::Success + }) +} + +fn resolve_target_name(cli: &Cli, indexer: &mut CodeIndexer) -> Result { + if let Some(symbol) = &cli.symbol { + return Ok(symbol.clone()); + } + + let anchor = cli + .anchor + .as_ref() + .ok_or_else(|| CliError::usage("provide an exact symbol name or use --at"))?; + let source = read_bounded_source(&anchor.path)?; + let symbol = indexer + .find_enclosing_symbol(&anchor.path, &source, anchor.line, anchor.column) + .map_err(|error| { + CliError::runtime(format!( + "failed to resolve symbol at {}:{}: {}", + anchor.path.display(), + anchor.line, + error + )) + })? + .ok_or_else(|| { + CliError::runtime(format!( + "no enclosing definition found at {}:{}", + anchor.path.display(), + anchor.line + )) + })?; + Ok(symbol.name) +} + +fn collect_use_sites( + cli: &Cli, + files: &[PathBuf], + target_name: &str, + indexer: &mut CodeIndexer, +) -> Result, CliError> { + let mut uses = Vec::new(); + for path in files { + let source = read_bounded_source(path)?; + let file_uses = match cli.engine { + EngineMode::Ast => ast_use_sites(indexer, path, &source, target_name)?, + EngineMode::Text => text_use_sites(indexer, path, &source, target_name), + EngineMode::Auto => auto_use_sites(indexer, path, &source, target_name), + }; + uses.extend(file_uses); + } + + sort_use_sites(&mut uses); + Ok(uses) +} + +fn sort_use_sites(uses: &mut [UseSite]) { + uses.sort_unstable_by(|left, right| { + ( + is_probable_test_path(&left.path), + left.path.as_str(), + left.line, + left.column, + left.enclosing_qualified_name.as_deref().unwrap_or_default(), + ) + .cmp(&( + is_probable_test_path(&right.path), + right.path.as_str(), + right.line, + right.column, + right + .enclosing_qualified_name + .as_deref() + .unwrap_or_default(), + )) + }); +} + +fn ast_use_sites( + indexer: &mut CodeIndexer, + path: &Path, + source: &str, + target_name: &str, +) -> Result, CliError> { + let cached_file = indexer.index_file_summary(path, source)?; + Ok(cached_file + .find_references(target_name) + .into_iter() + .map(ast_match_to_use_site) + .collect::>()) +} + +fn auto_use_sites( + indexer: &mut CodeIndexer, + path: &Path, + source: &str, + target_name: &str, +) -> Vec { + indexer.index_file_summary(path, source).map_or_else( + |_| text_use_sites_with_symbols(path, source, target_name, None), + |cached_file| { + let ast_uses = cached_file + .find_references(target_name) + .into_iter() + .map(ast_match_to_use_site) + .collect::>(); + if ast_uses.is_empty() { + text_use_sites_with_symbols(path, source, target_name, Some(cached_file.symbols())) + } else { + ast_uses + } + }, + ) +} + +fn ast_match_to_use_site(reference: ReferenceMatch) -> UseSite { + let caller = reference + .enclosing_qualified_name + .clone() + .or_else(|| reference.enclosing_name.clone()); + let caller_signature = reference.enclosing_signature.clone(); + UseSite { + engine: reference.engine.to_string(), + path: reference.path, + language: reference.language, + name: reference.name, + line: reference.line, + column: reference.column, + line_text: reference.line_text, + enclosing_kind: reference.enclosing_kind, + enclosing_name: reference.enclosing_name, + enclosing_qualified_name: reference.enclosing_qualified_name, + enclosing_signature: reference.enclosing_signature, + enclosing_start_line: reference.enclosing_start_line, + enclosing_end_line: reference.enclosing_end_line, + caller, + caller_signature, + } +} + +fn text_use_sites( + indexer: &mut CodeIndexer, + path: &Path, + source: &str, + target_name: &str, +) -> Vec { + text_use_sites_with_symbol_loader(path, source, target_name, || { + indexer + .index_file_summary(path, source) + .ok() + .map(|indexed| indexed.symbols().to_vec()) + }) +} + +fn text_use_sites_with_symbol_loader( + path: &Path, + source: &str, + target_name: &str, + load_symbols: F, +) -> Vec +where + F: FnOnce() -> Option>, +{ + let occurrences = collect_text_occurrences(source, target_name); + if occurrences.is_empty() { + return Vec::new(); + } + + let indexed_symbols = if detect_language(path).is_some() { + load_symbols() + } else { + None + }; + text_use_sites_from_occurrences(path, target_name, occurrences, indexed_symbols.as_deref()) +} + +fn text_use_sites_with_symbols( + path: &Path, + source: &str, + target_name: &str, + indexed_symbols: Option<&[IndexedSymbol]>, +) -> Vec { + let occurrences = collect_text_occurrences(source, target_name); + if occurrences.is_empty() { + return Vec::new(); + } + + text_use_sites_from_occurrences(path, target_name, occurrences, indexed_symbols) +} + +fn text_use_sites_from_occurrences( + path: &Path, + target_name: &str, + occurrences: Vec, + indexed_symbols: Option<&[IndexedSymbol]>, +) -> Vec { + let detected_language = detect_language(path); + let language = detected_language.unwrap_or(CodeLanguage::Rust); + let mut uses = Vec::new(); + for occurrence in occurrences { + let enclosing = + select_enclosing_symbol(indexed_symbols, occurrence.line, Some(occurrence.column)); + uses.push(UseSite { + engine: "text".to_string(), + path: path.display().to_string(), + language, + name: target_name.to_string(), + line: occurrence.line, + column: occurrence.column, + line_text: occurrence.line_text, + enclosing_kind: enclosing.map(|symbol| symbol.kind), + enclosing_name: enclosing.map(|symbol| symbol.name.clone()), + enclosing_qualified_name: enclosing.map(|symbol| symbol.qualified_name.clone()), + enclosing_signature: enclosing.map(|symbol| symbol.signature.clone()), + enclosing_start_line: enclosing.map(|symbol| symbol.start_line), + enclosing_end_line: enclosing.map(|symbol| symbol.end_line), + caller: enclosing + .map(|symbol| symbol.qualified_name.clone()) + .or_else(|| enclosing.map(|symbol| symbol.name.clone())), + caller_signature: enclosing.map(|symbol| symbol.signature.clone()), + }); + } + uses +} + +fn collect_text_occurrences(source: &str, target_name: &str) -> Vec { + let mut occurrences = Vec::new(); + for (line_index, line_text) in source.lines().enumerate() { + for column in find_word_columns(line_text, target_name) { + occurrences.push(TextOccurrence { + line: line_index + 1, + column, + line_text: line_text.to_string(), + }); + } + } + occurrences +} + +fn read_bounded_source(path: &Path) -> Result { + read_bounded_source_with_limit(path, MAX_SOURCE_BYTES) +} + +fn read_bounded_source_with_limit(path: &Path, max_source_bytes: u64) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!("failed to inspect {}: {error}", path.display())) + })?; + if metadata.len() > max_source_bytes { + return Err(CliError::runtime(format!( + "refusing to read {} because it is {} bytes; refs source files are capped at {max_source_bytes} bytes", + path.display(), + metadata.len() + ))); + } + fs::read_to_string(path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn group_callers(uses: &[UseSite]) -> Vec { + let mut grouped = BTreeMap::::new(); + + for item in uses { + let key = format!( + "{}:{}:{}", + item.path, + item.enclosing_qualified_name.as_deref().unwrap_or("-"), + item.enclosing_signature.as_deref().unwrap_or("-") + ); + let entry = grouped.entry(key).or_insert_with(|| CallerSummary { + engine: item.engine.clone(), + path: item.path.clone(), + language: item.language, + kind: item.enclosing_kind, + name: item.enclosing_name.clone(), + qualified_name: item.enclosing_qualified_name.clone(), + signature: item.enclosing_signature.clone(), + start_line: item.enclosing_start_line.unwrap_or(item.line), + end_line: item.enclosing_end_line.unwrap_or(item.line), + hit_count: 0, + hits: Vec::new(), + caller: item.caller.clone(), + caller_signature: item.caller_signature.clone(), + }); + entry.start_line = entry + .start_line + .min(item.enclosing_start_line.unwrap_or(item.line)); + entry.end_line = entry + .end_line + .max(item.enclosing_end_line.unwrap_or(item.line)); + entry.hit_count += 1; + entry.hits.push(CallerHit { + line: item.line, + column: item.column, + line_text: item.line_text.clone(), + }); + } + + let mut callers = grouped.into_values().collect::>(); + for caller in &mut callers { + caller.hits.sort_unstable_by(|left, right| { + (left.line, left.column, left.line_text.as_str()).cmp(&( + right.line, + right.column, + right.line_text.as_str(), + )) + }); + } + callers.sort_unstable_by(|left, right| { + ( + is_probable_test_path(&left.path), + left.path.as_str(), + left.start_line, + left.qualified_name.as_deref().unwrap_or_default(), + ) + .cmp(&( + is_probable_test_path(&right.path), + right.path.as_str(), + right.start_line, + right.qualified_name.as_deref().unwrap_or_default(), + )) + }); + callers +} + +fn collect_roots(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let roots = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !roots.is_empty() { + return Ok(roots); + } + } + + if cli.paths.is_empty() { + Ok(vec![PathBuf::from(".")]) + } else { + common::expand_input_patterns(&cli.paths, "refs") + } +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "refs")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn discover_supported_files( + roots: &[PathBuf], + languages: Option<&BTreeSet>, +) -> Result, CliError> { + discover_supported_files_with_cap(roots, languages, MAX_INDEXED_FILES) +} + +fn discover_supported_files_with_cap( + roots: &[PathBuf], + languages: Option<&BTreeSet>, + max_indexed_files: usize, +) -> Result, CliError> { + let files = collect_matching_files(roots, &|path| { + detect_language(path) + .is_some_and(|language| languages.is_none_or(|selected| selected.contains(&language))) + })?; + if files.len() > max_indexed_files { + return Err(CliError::runtime(format!( + "refs matched {} files, exceeding the safety cap of {max_indexed_files}; narrow the scan with explicit paths or --lang", + files.len() + ))); + } + Ok(files) +} + +fn select_enclosing_symbol( + symbols: Option<&[codeindex::IndexedSymbol]>, + line: usize, + column: Option, +) -> Option<&codeindex::IndexedSymbol> { + symbols.and_then(|items| { + items + .iter() + .filter(|symbol| line >= symbol.start_line && line <= symbol.end_line) + .min_by_key(|symbol| { + ( + symbol.end_line.saturating_sub(symbol.start_line), + symbol.start_line, + symbol.start_line.abs_diff(line), + column.unwrap_or_default(), + ) + }) + }) +} + +fn find_word_columns(line: &str, needle: &str) -> Vec { + let mut columns = Vec::new(); + let mut search_offset = 0_usize; + while let Some(index) = line[search_offset..].find(needle) { + let byte_index = search_offset + index; + let start_ok = byte_index == 0 + || line[..byte_index] + .chars() + .last() + .is_none_or(|character| !is_identifier_character(character)); + let end_offset = byte_index + needle.len(); + let end_ok = end_offset == line.len() + || line[end_offset..] + .chars() + .next() + .is_none_or(|character| !is_identifier_character(character)); + if start_ok && end_ok { + let column = line[..byte_index].chars().count() + 1; + columns.push(column); + } + search_offset = byte_index + needle.len(); + } + columns +} + +fn is_identifier_character(character: char) -> bool { + character == '_' || character.is_alphanumeric() +} + +fn render_use_sites(uses: &[UseSite]) -> String { + let mut output = String::new(); + for item in uses { + let caller = item + .enclosing_qualified_name + .as_deref() + .or(item.enclosing_name.as_deref()) + .unwrap_or("-"); + writeln!( + output, + "{}:{}:{} engine={} caller={} :: {}", + item.path, item.line, item.column, item.engine, caller, item.line_text + ) + .expect("writing to a String cannot fail"); + } + output +} + +fn render_callers(callers: &[CallerSummary]) -> String { + let mut output = String::new(); + for caller in callers { + let signature = caller.signature.as_deref().unwrap_or("-"); + let qualified_name = caller.qualified_name.as_deref().unwrap_or("-"); + writeln!( + output, + "{}:{}-{} engine={} hits={} caller={} signature={}", + caller.path, + caller.start_line, + caller.end_line, + caller.engine, + caller.hit_count, + qualified_name, + signature + ) + .expect("writing to a String cannot fail"); + } + output +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_value_string(value, flag) +} + +fn os_value_string(value: OsString, flag: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_anchor(value: &str) -> Result { + let (prefix, last) = value + .rsplit_once(':') + .ok_or_else(|| CliError::usage("invalid --at; expected PATH:LINE[:COL]"))?; + let last_value = last + .parse::() + .map_err(|_| CliError::usage("invalid --at; expected PATH:LINE[:COL]"))?; + if last_value == 0 { + return Err(CliError::usage("invalid --at; expected PATH:LINE[:COL]")); + } + + if let Some((path, line_text)) = prefix.rsplit_once(':') + && let Ok(line) = line_text.parse::() + && line > 0 + { + return Ok(SourceAnchor { + path: PathBuf::from(path), + line, + column: Some(last_value), + }); + } + + Ok(SourceAnchor { + path: PathBuf::from(prefix), + line: last_value, + column: None, + }) +} + +fn parse_engine_mode(value: &str) -> Result { + match value { + "auto" => Ok(EngineMode::Auto), + "ast" => Ok(EngineMode::Ast), + "text" => Ok(EngineMode::Text), + other => Err(CliError::usage(format!( + "invalid --engine value '{other}'; expected auto, ast, or text" + ))), + } +} + +fn parse_language_list(value: &str) -> Result, CliError> { + let mut languages = BTreeSet::new(); + for entry in value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + { + let language = parse_language_label(entry).ok_or_else(|| { + CliError::usage(format!( + "invalid --lang entry '{entry}'; expected {SUPPORTED_LANGUAGE_LIST}" + )) + })?; + let _ = languages.insert(language); + } + if languages.is_empty() { + return Err(CliError::usage("--lang requires at least one language")); + } + Ok(languages) +} + +fn parse_kind_list(value: &str) -> Result, CliError> { + let mut kinds = BTreeSet::new(); + for entry in value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + { + let kind = match entry { + "module" => SymbolKind::Module, + "namespace" => SymbolKind::Namespace, + "class" => SymbolKind::Class, + "struct" => SymbolKind::Struct, + "enum" => SymbolKind::Enum, + "interface" => SymbolKind::Interface, + "record" => SymbolKind::Record, + "trait" => SymbolKind::Trait, + "impl" => SymbolKind::Impl, + "type_alias" => SymbolKind::TypeAlias, + "function" => SymbolKind::Function, + "method" => SymbolKind::Method, + "constructor" => SymbolKind::Constructor, + "const" => SymbolKind::Const, + "static" => SymbolKind::Static, + other => { + return Err(CliError::usage(format!( + "invalid --kind entry '{other}'; expected module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static" + ))); + } + }; + let _ = kinds.insert(kind); + } + if kinds.is_empty() { + return Err(CliError::usage("--kind requires at least one kind")); + } + Ok(kinds) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) + } + + #[test] + fn parse_anchor_supports_windows_paths_with_line_only() { + let anchor = parse_anchor(r"C:\repo\src\lib.rs:42").expect("anchor"); + assert_eq!(anchor.path, PathBuf::from(r"C:\repo\src\lib.rs")); + assert_eq!(anchor.line, 42); + assert_eq!(anchor.column, None); + } + + #[test] + fn parse_anchor_supports_relative_paths_with_line_only() { + let anchor = parse_anchor("crates/recent/src/lib.rs:286").expect("anchor"); + assert_eq!(anchor.path, PathBuf::from("crates/recent/src/lib.rs")); + assert_eq!(anchor.line, 286); + assert_eq!(anchor.column, None); + } + + #[test] + fn parse_anchor_supports_line_and_column() { + let anchor = parse_anchor("fixtures/polyglot/repo/src/lib.rs:22:14").expect("anchor"); + assert_eq!(anchor.line, 22); + assert_eq!(anchor.column, Some(14)); + } + + #[test] + fn ast_use_sites_find_helper_callers_in_rust_fixture() { + let path = fixture_path("src/lib.rs"); + let source = fs::read_to_string(&path).expect("fixture"); + let mut indexer = CodeIndexer::new(); + + let uses = ast_use_sites(&mut indexer, &path, &source, "helper").expect("uses"); + + assert_eq!(uses.len(), 2); + assert!(uses.iter().all(|item| item.engine == "codeindex")); + assert!(uses.iter().any(|item| { + item.enclosing_qualified_name.as_deref() == Some("call_helper") + && item.line_text.contains("widget.helper()") + })); + } + + #[test] + fn text_use_sites_respect_identifier_boundaries() { + let path = fixture_path("src/lib.rs"); + let source = "helper\nhelper_label\nwidget.helper()\n"; + let mut indexer = CodeIndexer::new(); + + let uses = text_use_sites(&mut indexer, &path, source, "helper"); + + assert_eq!(uses.len(), 2); + assert_eq!(uses[0].column, 1); + assert_eq!(uses[1].column, 8); + } + + #[test] + fn text_fallback_skips_symbol_indexing_when_no_text_hits() { + let path = fixture_path("src/lib.rs"); + let mut indexed = false; + + let uses = text_use_sites_with_symbol_loader( + &path, + "fn call_helper() { helper_label(); }\n", + "helper", + || { + indexed = true; + Some(Vec::new()) + }, + ); + + assert!(uses.is_empty()); + assert!(!indexed); + } + + #[test] + fn parse_helpers_validate_engines_languages_kinds_and_anchors() { + assert!(matches!(parse_engine_mode("auto"), Ok(EngineMode::Auto))); + assert!(matches!( + parse_engine_mode("grep"), + Err(CliError::Usage(message)) if message.contains("expected auto, ast, or text") + )); + + let languages = parse_language_list("rust, python, java, rust").expect("languages"); + assert!(languages.contains(&CodeLanguage::Rust)); + assert!(languages.contains(&CodeLanguage::Python)); + assert!(languages.contains(&CodeLanguage::Java)); + assert_eq!(languages.len(), 3); + assert!(matches!( + parse_language_list(""), + Err(CliError::Usage(message)) if message.contains("at least one language") + )); + + let kinds = parse_kind_list("function, method, function").expect("kinds"); + assert!(kinds.contains(&SymbolKind::Function)); + assert!(kinds.contains(&SymbolKind::Method)); + assert_eq!(kinds.len(), 2); + assert!(matches!( + parse_kind_list("field"), + Err(CliError::Usage(message)) if message.contains("invalid --kind entry") + )); + + assert!(matches!( + parse_anchor("demo.rs:not-a-line"), + Err(CliError::Usage(message)) if message.contains("expected PATH:LINE[:COL]") + )); + } + + #[test] + fn parse_paths_support_jsonl_and_plain_text_fallbacks() { + let source = fixture_path("src/lib.rs"); + let web_app = fixture_path("web/app.js"); + let auto_paths = parse_paths_from_string( + &format!("{}\n{}\n", source.display(), web_app.display()), + InputFormat::Auto, + ) + .expect("auto paths"); + assert_eq!(auto_paths, vec![source.clone(), web_app.clone()]); + + let jsonl_paths = parse_paths_from_string( + &format!( + "{{\"path\":{}}}\n{{\"path\":{}}}\n", + serde_json::to_string(&source.display().to_string()).expect("json path"), + serde_json::to_string(&web_app.display().to_string()).expect("json path") + ), + InputFormat::Jsonl, + ) + .expect("jsonl paths"); + assert_eq!(jsonl_paths, vec![source, web_app]); + } + + #[test] + fn group_renderers_cover_invalid_and_fallback_cases() { + let uses = vec![ + UseSite { + engine: "text".to_string(), + path: "demo.rs".to_string(), + language: CodeLanguage::Rust, + name: "helper".to_string(), + line: 4, + column: 5, + line_text: "helper();".to_string(), + enclosing_kind: Some(SymbolKind::Function), + enclosing_name: Some("call_helper".to_string()), + enclosing_qualified_name: Some("call_helper".to_string()), + enclosing_signature: Some("fn call_helper()".to_string()), + enclosing_start_line: Some(3), + enclosing_end_line: Some(8), + caller: Some("call_helper".to_string()), + caller_signature: Some("fn call_helper()".to_string()), + }, + UseSite { + engine: "text".to_string(), + path: "demo.rs".to_string(), + language: CodeLanguage::Rust, + name: "helper".to_string(), + line: 7, + column: 9, + line_text: "helper();".to_string(), + enclosing_kind: Some(SymbolKind::Function), + enclosing_name: Some("call_helper".to_string()), + enclosing_qualified_name: Some("call_helper".to_string()), + enclosing_signature: Some("fn call_helper()".to_string()), + enclosing_start_line: Some(3), + enclosing_end_line: Some(8), + caller: Some("call_helper".to_string()), + caller_signature: Some("fn call_helper()".to_string()), + }, + UseSite { + engine: "text".to_string(), + path: "demo.rs".to_string(), + language: CodeLanguage::Rust, + name: "helper".to_string(), + line: 10, + column: 1, + line_text: "helper();".to_string(), + enclosing_kind: None, + enclosing_name: None, + enclosing_qualified_name: None, + enclosing_signature: None, + enclosing_start_line: None, + enclosing_end_line: None, + caller: None, + caller_signature: None, + }, + ]; + + let callers = group_callers(&uses); + assert_eq!(callers.len(), 2); + assert!(callers.iter().any(|caller| { + caller.qualified_name.as_deref() == Some("call_helper") + && caller.hit_count == 2 + && caller.start_line == 3 + && caller.end_line == 8 + })); + + let use_text = render_use_sites(&uses); + assert!(use_text.contains("caller=call_helper")); + assert!(use_text.contains("caller=-")); + + let caller_text = render_callers(&callers); + assert!(caller_text.contains("caller=call_helper")); + assert!(caller_text.contains("caller=- signature=-")); + } + + #[test] + fn resolve_target_name_covers_direct_symbol_and_missing_definition_errors() { + let (_, cli) = parse_cli_from(["refs", "helper"]).expect("cli"); + let mut indexer = CodeIndexer::new(); + assert_eq!( + resolve_target_name(&cli, &mut indexer).expect("symbol"), + "helper" + ); + + let temp = std::env::temp_dir().join(format!( + "refs-anchor-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let notes = temp.join("notes.txt"); + fs::write(¬es, "helper\n").expect("notes"); + + let (_, unsupported_anchor_cli) = + parse_cli_from(["refs", "--at", &format!("{}:1", notes.display())]).expect("anchor"); + assert!(matches!( + resolve_target_name(&unsupported_anchor_cli, &mut indexer), + Err(CliError::Runtime(message)) + if message.contains("failed to resolve symbol at") + )); + + let (_, missing_anchor_cli) = parse_cli_from([ + "refs", + "--at", + &format!("{}:16", fixture_path("src/lib.rs").display()), + ]) + .expect("missing anchor"); + assert!(matches!( + resolve_target_name(&missing_anchor_cli, &mut indexer), + Err(CliError::Runtime(message)) + if message.contains("no enclosing definition found") + )); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn collect_use_sites_auto_falls_back_to_text_and_sorts_matches() { + let temp = std::env::temp_dir().join(format!( + "refs-collect-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let notes = temp.join("scratch.txt"); + fs::write(¬es, "helper helper()\nother helper\n").expect("notes"); + + let (_, cli) = parse_cli_from(["refs", "--engine", "auto", "helper"]).expect("cli"); + let mut indexer = CodeIndexer::new(); + let uses = collect_use_sites(&cli, std::slice::from_ref(¬es), "helper", &mut indexer) + .expect("uses"); + + assert_eq!(uses.len(), 3); + assert!(uses.iter().all(|item| item.engine == "text")); + assert_eq!( + uses.iter() + .map(|item| (item.line, item.column)) + .collect::>(), + vec![(1, 1), (1, 8), (2, 7)] + ); + assert!( + uses.iter() + .all(|item| item.path == notes.display().to_string()) + ); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn collect_use_sites_auto_preserves_supported_text_fallback_callers() { + let temp = std::env::temp_dir().join(format!( + "refs-supported-fallback-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let rust_file = temp.join("main.rs"); + fs::write( + &rust_file, + "fn call_helper() {\n let label = \"helper\";\n}\n", + ) + .expect("rust"); + + let (_, cli) = parse_cli_from(["refs", "--engine", "auto", "helper"]).expect("cli"); + let mut indexer = CodeIndexer::new(); + let uses = collect_use_sites( + &cli, + std::slice::from_ref(&rust_file), + "helper", + &mut indexer, + ) + .expect("uses"); + + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].engine, "text"); + assert_eq!(uses[0].language, CodeLanguage::Rust); + assert_eq!(uses[0].caller.as_deref(), Some("call_helper")); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn discover_supported_files_filters_languages_dedupes_and_reports_missing_roots() { + let temp = std::env::temp_dir().join(format!( + "refs-files-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let rust_file = temp.join("main.rs"); + let python_file = temp.join("tool.py"); + let text_file = temp.join("notes.txt"); + fs::write(&rust_file, "fn helper() {}\n").expect("rust"); + fs::write(&python_file, "def helper():\n return 1\n").expect("python"); + fs::write(&text_file, "helper\n").expect("text"); + + let files = + discover_supported_files(&[temp.clone(), rust_file.clone()], None).expect("all"); + assert_eq!(files.len(), 2); + assert!(files.contains(&rust_file)); + assert!(files.contains(&python_file)); + + let rust_only = discover_supported_files( + &[temp.clone(), rust_file.clone()], + Some(&BTreeSet::from([CodeLanguage::Rust])), + ) + .expect("rust only"); + assert_eq!(rust_only, vec![rust_file]); + + let missing = temp.join("missing"); + assert!(matches!( + discover_supported_files(&[missing], None), + Err(CliError::Runtime(message)) if message.contains("path does not exist") + )); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn discover_supported_files_rejects_scans_above_cap() { + let temp = std::env::temp_dir().join(format!( + "refs-cap-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + for index in 0..=3 { + fs::write(temp.join(format!("file_{index}.rs")), "fn helper() {}\n").expect("file"); + } + + let error = discover_supported_files_with_cap(std::slice::from_ref(&temp), None, 3) + .expect_err("file cap should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("exceeding the safety cap") + )); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn read_bounded_source_rejects_large_files() { + let temp = std::env::temp_dir().join(format!( + "refs-source-cap-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&temp).expect("temp dir"); + let path = temp.join("large.rs"); + fs::write(&path, vec![b'a'; 129]).expect("large file"); + + let error = read_bounded_source_with_limit(&path, 128).expect_err("source cap should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("source files are capped") + )); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn sorting_prefers_non_test_paths_for_use_sites_and_callers() { + let mut uses = vec![ + UseSite { + engine: "text".to_string(), + path: "tests/helper_test.rs".to_string(), + language: CodeLanguage::Rust, + name: "helper".to_string(), + line: 3, + column: 1, + line_text: "helper();".to_string(), + enclosing_kind: Some(SymbolKind::Function), + enclosing_name: Some("test_helper".to_string()), + enclosing_qualified_name: Some("tests::test_helper".to_string()), + enclosing_signature: Some("fn test_helper()".to_string()), + enclosing_start_line: Some(1), + enclosing_end_line: Some(4), + caller: Some("tests::test_helper".to_string()), + caller_signature: Some("fn test_helper()".to_string()), + }, + UseSite { + engine: "text".to_string(), + path: "src/lib.rs".to_string(), + language: CodeLanguage::Rust, + name: "helper".to_string(), + line: 8, + column: 4, + line_text: "helper();".to_string(), + enclosing_kind: Some(SymbolKind::Function), + enclosing_name: Some("run".to_string()), + enclosing_qualified_name: Some("run".to_string()), + enclosing_signature: Some("fn run()".to_string()), + enclosing_start_line: Some(6), + enclosing_end_line: Some(9), + caller: Some("run".to_string()), + caller_signature: Some("fn run()".to_string()), + }, + ]; + + sort_use_sites(&mut uses); + assert_eq!(uses[0].path, "src/lib.rs"); + + let callers = group_callers(&uses); + assert_eq!(callers[0].path, "src/lib.rs"); + assert_eq!(callers[0].hits[0].line, 8); + } + + #[test] + fn find_word_columns_and_renderers_cover_boundaries_and_exact_output() { + assert_eq!( + find_word_columns("helper(helper) helper_label _helper helper!", "helper"), + vec![1, 8, 37] + ); + assert!(matches!( + parse_paths_from_string("{not-json}\n", InputFormat::Jsonl), + Err(CliError::Usage(message)) + if message.contains("stdin JSONL path line 1 is not valid JSON") + )); + + let use_site = UseSite { + engine: "text".to_string(), + path: "demo.rs".to_string(), + language: CodeLanguage::Rust, + name: "helper".to_string(), + line: 4, + column: 5, + line_text: "helper();".to_string(), + enclosing_kind: None, + enclosing_name: None, + enclosing_qualified_name: None, + enclosing_signature: None, + enclosing_start_line: None, + enclosing_end_line: None, + caller: None, + caller_signature: None, + }; + + assert_eq!( + render_use_sites(std::slice::from_ref(&use_site)), + "demo.rs:4:5 engine=text caller=- :: helper();\n" + ); + + let callers = group_callers(&[use_site]); + assert_eq!( + render_callers(&callers), + "demo.rs:4-4 engine=text hits=1 caller=- signature=-\n" + ); + } +} diff --git a/crates/refs/src/main.rs b/crates/refs/src/main.rs new file mode 100644 index 0000000..85c6409 --- /dev/null +++ b/crates/refs/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `refs`. + +fn main() { + std::process::exit(refs::main_entry()); +} diff --git a/crates/refs/tests/refs_cli.rs b/crates/refs/tests/refs_cli.rs new file mode 100644 index 0000000..42741ee --- /dev/null +++ b/crates/refs/tests/refs_cli.rs @@ -0,0 +1,184 @@ +//! Integration tests for the `refs` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; + +fn cargo_command() -> Command { + Command::cargo_bin("refs").expect("binary") +} + +fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("polyglot") + .join("repo") + .join(relative) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn emits_exact_use_sites_as_json() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("helper") + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains( + "\"enclosing_qualified_name\":\"call_helper\"", + )) + .stdout(predicate::str::contains("\"engine\":\"codeindex\"")); +} + +#[test] +fn groups_hits_by_callers() { + let mut command = cargo_command(); + command + .args(["--callers", "helper"]) + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains("caller=call_helper")) + .stdout(predicate::str::contains("hits=2")); +} + +#[test] +fn finds_powershell_callers_in_auto_mode() { + let mut command = cargo_command(); + command + .args(["--callers", "--lang", "powershell", "Invoke-Helper"]) + .arg(fixture_path("scripts/tools.ps1")) + .assert() + .success() + .stdout(predicate::str::contains("caller=Invoke-Formatting")); +} + +#[test] +fn supports_anchor_resolution() { + let mut command = cargo_command(); + command + .args(["--callers", "--at"]) + .arg(format!("{}:22", fixture_path("src/lib.rs").display())) + .arg(fixture_path("")) + .assert() + .success() + .stdout(predicate::str::contains("caller=call_helper")); +} + +#[test] +fn supports_powershell_pipeline_roots() { + let binary = assert_cmd::cargo::cargo_bin("refs"); + let root = fixture_path(""); + let script = format!( + "'{}' | & '{}' helper --json | ConvertFrom-Json | Select-Object -First 1 -ExpandProperty name", + root.display(), + binary.display() + ); + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("helper")); +} + +#[test] +fn accepts_jsonl_roots_from_stdin() { + let root = fixture_path(""); + let mut command = cargo_command(); + command + .args(["--json", "--input-format", "jsonl", "helper"]) + .write_stdin(format!( + "{}\n", + serde_json::json!({ "path": root.display().to_string() }) + )) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"helper\"")) + .stdout(predicate::str::contains("\"path\":\"")); +} + +#[test] +fn reports_no_results_and_quiet_paths_in_text_mode() { + let root = fixture_path(""); + + let mut command = cargo_command(); + command + .arg("totally_absent_symbol") + .arg(&root) + .assert() + .failure() + .stdout(predicate::str::contains("0 references")); + + let mut callers = cargo_command(); + callers + .args(["--callers", "--quiet", "totally_absent_symbol"]) + .arg(root) + .assert() + .failure() + .stdout(predicate::str::is_empty()); +} + +#[test] +fn applies_language_filters_and_limits_to_json_output() { + let root = fixture_path(""); + let mut command = cargo_command(); + let assert = command + .args([ + "--json", + "--lang", + "python,typescript", + "--limit", + "1", + "helper", + ]) + .arg(root) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8"); + let uses = serde_json::from_str::>(&stdout).expect("json"); + assert_eq!(uses.len(), 1); + let path = uses[0] + .get("path") + .and_then(serde_json::Value::as_str) + .expect("path"); + assert!(path.ends_with("worker.py") || path.ends_with("app.ts")); + assert!(!path.ends_with("lib.rs")); + assert!(!path.ends_with("app.js")); +} + +#[test] +fn text_engine_reports_text_use_sites() { + let mut command = cargo_command(); + command + .args(["--json", "--engine", "text", "helper"]) + .arg(fixture_path("src/lib.rs")) + .assert() + .success() + .stdout(predicate::str::contains("\"engine\":\"text\"")) + .stdout(predicate::str::contains("\"name\":\"helper\"")); +} + +#[test] +fn help_includes_examples_and_pipeline_usage() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--callers")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("refs helper")); +} diff --git a/crates/reposhape/Cargo.toml b/crates/reposhape/Cargo.toml new file mode 100644 index 0000000..8d5f8c9 --- /dev/null +++ b/crates/reposhape/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "reposhape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Summarize repository trees into compact file, size, and extension stats." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +configsupport = { path = "../configsupport" } +ignore.workspace = true +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true +toml = "0.8.23" + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/reposhape/src/lib.rs b/crates/reposhape/src/lib.rs new file mode 100644 index 0000000..a5ef661 --- /dev/null +++ b/crates/reposhape/src/lib.rs @@ -0,0 +1,1780 @@ +//! The `reposhape` command summarizes repository trees. +#![allow(clippy::multiple_crate_versions)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use configsupport::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, +}; +use ignore::WalkBuilder; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::Value; + +const MAX_SCANNED_FILES: usize = 250_000; +const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; + +const HELP: &str = "\ +Detect repository ecosystems, manifests, command entrypoints, and CI hints. + +Usage: + reposhape [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --max-depth Optional traversal depth limit + --hidden Include hidden files and directories + -h, --help Show this help text + -V, --version Show the command version + +Examples: + reposhape . + reposhape . --json | ConvertFrom-Json + reposhape C:\\src\\repo --max-depth 4 +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + max_depth: Option, + include_hidden: bool, + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RepoSummary { + root: String, + ecosystems: Vec, + manifests: Vec, + commands: Vec, + entrypoints: Vec, + ci: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Ecosystem { + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Manifest { + ecosystem: String, + kind: String, + path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CommandHint { + ecosystem: String, + build: Vec, + test: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct EntryPoint { + ecosystem: String, + kind: String, + path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CiHint { + provider: String, + kind: String, + path: String, +} + +#[derive(Debug, Default)] +struct CommandAccumulator { + build: BTreeSet, + test: BTreeSet, +} + +#[derive(Debug, Default)] +struct ScriptedLayoutState { + has_src: bool, + has_tests: bool, + has_docs: bool, +} + +struct RepoScanState<'a> { + ecosystems: &'a mut BTreeSet, + manifests: &'a mut Vec, + command_map: &'a mut BTreeMap, + entrypoints: &'a mut Vec, + ci: &'a mut Vec, + scripted_layout: &'a mut ScriptedLayoutState, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("reposhape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + max_depth: None, + include_hidden: false, + path: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => cli.common.quiet = true, + Long("hidden") => cli.include_hidden = true, + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("max-depth") => { + cli.max_depth = Some(parse_usize_flag( + "--max-depth", + &parser_value_string(&mut parser, "--max-depth")?, + )?); + } + ArgValue(path) if cli.path.is_none() => cli.path = Some(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + let summary = inspect_repository(cli)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&summary)?, + RenderMode::Toon => print_structured(&summary, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_summary(&summary)), + } + Ok(ExitCode::Success) +} + +fn inspect_repository(cli: &Cli) -> Result { + inspect_repository_with_file_cap(cli, MAX_SCANNED_FILES) +} + +fn inspect_repository_with_file_cap( + cli: &Cli, + max_scanned_files: usize, +) -> Result { + let root = cli + .path + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + if !root.exists() { + return Err(CliError::runtime(format!( + "repository path does not exist: {}", + root.display() + ))); + } + + let mut builder = WalkBuilder::new(&root); + builder.hidden(!cli.include_hidden); + builder.git_ignore(true); + builder.git_global(true); + builder.git_exclude(true); + if let Some(depth) = cli.max_depth { + builder.max_depth(Some(depth)); + } + + let mut ecosystems = BTreeSet::::new(); + let mut manifests = Vec::::new(); + let mut entrypoints = Vec::::new(); + let mut ci = Vec::::new(); + let mut command_map = BTreeMap::::new(); + let mut scripted_layout = ScriptedLayoutState::default(); + let mut scanned_files = 0_usize; + let mut state = RepoScanState { + ecosystems: &mut ecosystems, + manifests: &mut manifests, + command_map: &mut command_map, + entrypoints: &mut entrypoints, + ci: &mut ci, + scripted_layout: &mut scripted_layout, + }; + + validate_github_workflows_dir(&root)?; + for entry in builder.build() { + let entry = entry + .map_err(|error| CliError::runtime(format!("failed to walk repository: {error}")))?; + if entry.path().is_file() { + scanned_files += 1; + if scanned_files > max_scanned_files { + return Err(CliError::runtime(format!( + "reposhape scan exceeded {max_scanned_files} files; narrow the scan with --max-depth or a smaller root" + ))); + } + scan_file(&root, entry.path(), &mut state)?; + } + } + detect_github_actions(&root, state.ci)?; + state + .scripted_layout + .finish(state.ecosystems, state.entrypoints); + dedup_manifests(state.manifests); + dedup_entrypoints(state.entrypoints); + dedup_ci(state.ci); + + let ecosystems = ecosystems + .into_iter() + .map(|name| Ecosystem { name }) + .collect::>(); + let commands = command_map + .into_iter() + .map(|(ecosystem, hints)| CommandHint { + ecosystem, + build: hints.build.into_iter().collect(), + test: hints.test.into_iter().collect(), + }) + .collect::>(); + + Ok(RepoSummary { + root: root.display().to_string(), + ecosystems, + manifests, + commands, + entrypoints, + ci, + }) +} + +#[allow(clippy::too_many_lines)] +fn scan_file(root: &Path, file: &Path, state: &mut RepoScanState<'_>) -> Result<(), CliError> { + let relative = relative_path(root, file); + let normalized_relative = normalize_separators(&relative); + let Some(file_name) = file.file_name().and_then(|value| value.to_str()) else { + return Ok(()); + }; + + match file_name { + "Cargo.toml" => { + add_ecosystem(state.ecosystems, "cargo"); + add_manifest(state.manifests, "cargo", "cargo_toml", &relative); + add_commands(state.command_map, "cargo", "cargo build", "cargo test"); + detect_cargo_entrypoints(root, file, state.entrypoints); + } + "package.json" => { + detect_node_workspace( + root, + file, + state.ecosystems, + state.manifests, + state.command_map, + state.entrypoints, + )?; + } + "package-lock.json" => { + add_ecosystem(state.ecosystems, "npm"); + add_manifest(state.manifests, "npm", "package_lock", &relative); + } + "pnpm-lock.yaml" => { + add_ecosystem(state.ecosystems, "pnpm"); + add_manifest(state.manifests, "pnpm", "pnpm_lock", &relative); + } + "yarn.lock" => { + add_ecosystem(state.ecosystems, "yarn"); + add_manifest(state.manifests, "yarn", "yarn_lock", &relative); + } + "bun.lock" | "bun.lockb" => { + add_ecosystem(state.ecosystems, "bun"); + add_manifest(state.manifests, "bun", "bun_lock", &relative); + } + "pyproject.toml" => { + detect_python_project( + root, + file, + state.ecosystems, + state.manifests, + state.command_map, + state.entrypoints, + )?; + } + "go.mod" => { + add_ecosystem(state.ecosystems, "go"); + add_manifest(state.manifests, "go", "go_mod", &relative); + add_commands(state.command_map, "go", "go build ./...", "go test ./..."); + detect_go_entrypoints(root, file, state.entrypoints); + } + "CMakeLists.txt" => { + add_ecosystem(state.ecosystems, "cmake"); + add_manifest(state.manifests, "cmake", "cmake_lists", &relative); + add_commands( + state.command_map, + "cmake", + "cmake -S . -B build && cmake --build build", + "ctest --test-dir build", + ); + } + "Makefile" | "makefile" | "GNUmakefile" => { + add_ecosystem(state.ecosystems, "make"); + add_manifest(state.manifests, "make", "makefile", &relative); + detect_make_commands(file, state.command_map)?; + } + _ => { + if has_extension_case_insensitive(file_name, "csproj") { + add_ecosystem(state.ecosystems, "dotnet"); + add_manifest(state.manifests, "dotnet", "csproj", &relative); + add_entrypoint(state.entrypoints, "dotnet", "project", &relative); + add_command_line( + state.command_map, + "dotnet", + "build", + format!("dotnet build {relative}"), + ); + add_command_line( + state.command_map, + "dotnet", + "test", + format!("dotnet test {relative}"), + ); + } else if has_extension_case_insensitive(file_name, "sln") { + add_ecosystem(state.ecosystems, "dotnet"); + add_manifest(state.manifests, "dotnet", "sln", &relative); + add_entrypoint(state.entrypoints, "dotnet", "solution", &relative); + add_command_line( + state.command_map, + "dotnet", + "build", + format!("dotnet build {relative}"), + ); + add_command_line( + state.command_map, + "dotnet", + "test", + format!("dotnet test {relative}"), + ); + } else if file_name.starts_with("requirements") + && has_extension_case_insensitive(file_name, "txt") + { + add_ecosystem(state.ecosystems, "python"); + add_manifest(state.manifests, "python", "requirements", &relative); + add_command_line(state.command_map, "python", "test", "pytest".to_string()); + } else if normalized_relative.starts_with(".github/workflows/") + && is_yaml_path(&relative) + { + state.ci.push(CiHint { + provider: "github_actions".to_string(), + kind: "workflow".to_string(), + path: relative.clone(), + }); + } + } + } + + scan_scripted_layout_path( + &relative, + &normalized_relative, + state.ecosystems, + state.manifests, + state.command_map, + state.entrypoints, + state.scripted_layout, + ); + + Ok(()) +} + +fn scan_scripted_layout_path( + relative: &str, + normalized_relative: &str, + ecosystems: &mut BTreeSet, + manifests: &mut Vec, + command_map: &mut BTreeMap, + entrypoints: &mut Vec, + scripted_layout: &mut ScriptedLayoutState, +) { + match normalized_relative { + "build.ps1" => { + add_ecosystem(ecosystems, "scripted"); + add_manifest(manifests, "scripted", "build_script", relative); + add_command_line( + command_map, + "scripted", + "build", + format!("pwsh -NoProfile -File {relative}"), + ); + add_entrypoint(entrypoints, "scripted", "build_script", relative); + } + "build.cmd" | "build.bat" => { + add_ecosystem(ecosystems, "scripted"); + add_manifest(manifests, "scripted", "build_script", relative); + add_command_line( + command_map, + "scripted", + "build", + format!("cmd /d /c {relative}"), + ); + add_entrypoint(entrypoints, "scripted", "build_script", relative); + } + "build.sh" => { + add_ecosystem(ecosystems, "scripted"); + add_manifest(manifests, "scripted", "build_script", relative); + add_command_line(command_map, "scripted", "build", format!("sh {relative}")); + add_entrypoint(entrypoints, "scripted", "build_script", relative); + } + "test.ps1" => { + add_ecosystem(ecosystems, "scripted"); + add_manifest(manifests, "scripted", "test_script", relative); + add_command_line( + command_map, + "scripted", + "test", + format!("pwsh -NoProfile -File {relative}"), + ); + add_entrypoint(entrypoints, "scripted", "test_script", relative); + } + "test.cmd" | "test.bat" => { + add_ecosystem(ecosystems, "scripted"); + add_manifest(manifests, "scripted", "test_script", relative); + add_command_line( + command_map, + "scripted", + "test", + format!("cmd /d /c {relative}"), + ); + add_entrypoint(entrypoints, "scripted", "test_script", relative); + } + "test.sh" => { + add_ecosystem(ecosystems, "scripted"); + add_manifest(manifests, "scripted", "test_script", relative); + add_command_line(command_map, "scripted", "test", format!("sh {relative}")); + add_entrypoint(entrypoints, "scripted", "test_script", relative); + } + "README.md" => add_entrypoint(entrypoints, "scripted", "readme", relative), + _ => {} + } + + if normalized_relative.starts_with("src/") { + scripted_layout.has_src = true; + } + if normalized_relative.starts_with("tests/") { + scripted_layout.has_tests = true; + } + if normalized_relative.starts_with("docs/") { + scripted_layout.has_docs = true; + } +} + +impl ScriptedLayoutState { + fn finish(&self, ecosystems: &mut BTreeSet, entrypoints: &mut Vec) { + if self.has_src { + add_ecosystem(ecosystems, "scripted"); + add_entrypoint(entrypoints, "scripted", "source_dir", "src"); + } + if self.has_tests { + add_ecosystem(ecosystems, "scripted"); + add_entrypoint(entrypoints, "scripted", "test_dir", "tests"); + } + if self.has_docs { + add_ecosystem(ecosystems, "scripted"); + add_entrypoint(entrypoints, "scripted", "docs_dir", "docs"); + } + } +} + +fn validate_github_workflows_dir(root: &Path) -> Result<(), CliError> { + let workflows = root.join(".github").join("workflows"); + if workflows.exists() && !workflows.is_dir() { + return Err(CliError::runtime(format!( + "failed to read GitHub workflow directory {}: path is not a directory", + workflows.display() + ))); + } + Ok(()) +} + +fn detect_cargo_entrypoints(root: &Path, manifest: &Path, entrypoints: &mut Vec) { + let base = manifest.parent().unwrap_or(root); + let main = base.join("src").join("main.rs"); + if main.exists() { + add_entrypoint( + entrypoints, + "cargo", + "rust_bin", + &relative_path(root, &main), + ); + } + let lib = base.join("src").join("lib.rs"); + if lib.exists() { + add_entrypoint(entrypoints, "cargo", "rust_lib", &relative_path(root, &lib)); + } +} + +fn detect_node_workspace( + root: &Path, + package_json: &Path, + ecosystems: &mut BTreeSet, + manifests: &mut Vec, + command_map: &mut BTreeMap, + entrypoints: &mut Vec, +) -> Result<(), CliError> { + let relative = relative_path(root, package_json); + let raw = read_bounded_text_file(package_json, "package.json")?; + let parsed = serde_json::from_str::(&raw).map_err(|error| { + CliError::runtime(format!( + "failed to parse package.json {}: {error}", + package_json.display() + )) + })?; + let manager = detect_node_manager(package_json, &parsed); + add_ecosystem(ecosystems, manager); + add_manifest(manifests, manager, "package_json", &relative); + add_default_node_commands(command_map, manager, &parsed); + add_node_entrypoints(root, package_json, manager, &parsed, entrypoints); + Ok(()) +} + +fn detect_node_manager(path: &Path, package_json: &Value) -> &'static str { + let base = path.parent().unwrap_or_else(|| Path::new(".")); + if base.join("pnpm-lock.yaml").exists() { + return "pnpm"; + } + if base.join("yarn.lock").exists() { + return "yarn"; + } + if base.join("bun.lock").exists() || base.join("bun.lockb").exists() { + return "bun"; + } + let package_manager = package_json + .get("packageManager") + .and_then(Value::as_str) + .unwrap_or_default(); + if package_manager.starts_with("pnpm@") { + "pnpm" + } else if package_manager.starts_with("yarn@") { + "yarn" + } else if package_manager.starts_with("bun@") { + "bun" + } else { + "npm" + } +} + +fn add_default_node_commands( + command_map: &mut BTreeMap, + manager: &str, + package_json: &Value, +) { + let scripts = package_json.get("scripts").and_then(Value::as_object); + if let Some(scripts) = scripts { + if scripts.contains_key("build") { + add_command_line( + command_map, + manager, + "build", + node_script_command(manager, "build"), + ); + } + if scripts.contains_key("test") { + add_command_line( + command_map, + manager, + "test", + node_script_command(manager, "test"), + ); + } + } +} + +fn node_script_command(manager: &str, script: &str) -> String { + match manager { + "yarn" => format!("yarn {script}"), + "bun" => format!("bun run {script}"), + "pnpm" => format!("pnpm run {script}"), + _ => format!("npm run {script}"), + } +} + +fn add_node_entrypoints( + root: &Path, + package_json_path: &Path, + manager: &str, + package_json: &Value, + entrypoints: &mut Vec, +) { + let base = package_json_path.parent().unwrap_or(root); + if let Some(main) = package_json.get("main").and_then(Value::as_str) { + let path = base.join(main); + add_entrypoint( + entrypoints, + manager, + "node_main", + &relative_path(root, &path), + ); + } + if let Some(bin) = package_json.get("bin") { + match bin { + Value::String(path_text) => { + let path = base.join(path_text); + add_entrypoint( + entrypoints, + manager, + "node_bin", + &relative_path(root, &path), + ); + } + Value::Object(map) => { + for value in map.values() { + if let Some(path_text) = value.as_str() { + let path = base.join(path_text); + add_entrypoint( + entrypoints, + manager, + "node_bin", + &relative_path(root, &path), + ); + } + } + } + _ => {} + } + } +} + +fn detect_python_project( + root: &Path, + pyproject: &Path, + ecosystems: &mut BTreeSet, + manifests: &mut Vec, + command_map: &mut BTreeMap, + entrypoints: &mut Vec, +) -> Result<(), CliError> { + let relative = relative_path(root, pyproject); + add_ecosystem(ecosystems, "python"); + add_manifest(manifests, "python", "pyproject_toml", &relative); + add_commands(command_map, "python", "python -m build", "pytest"); + + let raw = read_bounded_text_file(pyproject, "pyproject")?; + let parsed = raw.parse::().map_err(|error| { + CliError::runtime(format!( + "failed to parse pyproject {}: {error}", + pyproject.display() + )) + })?; + let scripts = parsed + .get("project") + .and_then(|value| value.get("scripts")) + .and_then(toml::Value::as_table); + if let Some(table) = scripts { + for (name, _) in table { + add_entrypoint( + entrypoints, + "python", + "project_script", + &format!("{relative}::{name}"), + ); + } + } + let poetry_scripts = parsed + .get("tool") + .and_then(|value| value.get("poetry")) + .and_then(|value| value.get("scripts")) + .and_then(toml::Value::as_table); + if let Some(table) = poetry_scripts { + for (name, _) in table { + add_entrypoint( + entrypoints, + "python", + "poetry_script", + &format!("{relative}::{name}"), + ); + } + } + Ok(()) +} + +#[cfg(test)] +fn detect_scripted_layout( + root: &Path, + files: &[PathBuf], + ecosystems: &mut BTreeSet, + manifests: &mut Vec, + command_map: &mut BTreeMap, + entrypoints: &mut Vec, +) { + let mut scripted_layout = ScriptedLayoutState::default(); + for file in files { + let relative = relative_path(root, file); + let normalized_relative = normalize_separators(&relative); + scan_scripted_layout_path( + &relative, + &normalized_relative, + ecosystems, + manifests, + command_map, + entrypoints, + &mut scripted_layout, + ); + } + scripted_layout.finish(ecosystems, entrypoints); + dedup_manifests(manifests); + dedup_entrypoints(entrypoints); +} + +fn detect_go_entrypoints(root: &Path, go_mod: &Path, entrypoints: &mut Vec) { + let base = go_mod.parent().unwrap_or(root); + let main = base.join("main.go"); + if main.exists() { + add_entrypoint(entrypoints, "go", "go_main", &relative_path(root, &main)); + } + let cmd_dir = base.join("cmd"); + if !cmd_dir.exists() { + return; + } + if let Ok(entries) = fs::read_dir(cmd_dir) { + for entry in entries.flatten() { + let main_path = entry.path().join("main.go"); + if main_path.exists() { + add_entrypoint( + entrypoints, + "go", + "go_cmd", + &relative_path(root, &main_path), + ); + } + } + } +} + +fn detect_make_commands( + makefile: &Path, + command_map: &mut BTreeMap, +) -> Result<(), CliError> { + let content = read_bounded_text_file(makefile, "Makefile")?; + add_command_line(command_map, "make", "build", "make".to_string()); + if content.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("test:") || trimmed.starts_with("check:") + }) { + add_command_line(command_map, "make", "test", "make test".to_string()); + } + Ok(()) +} + +fn detect_github_actions(root: &Path, ci: &mut Vec) -> Result<(), CliError> { + let workflows = root.join(".github").join("workflows"); + if !workflows.exists() { + return Ok(()); + } + let entries = fs::read_dir(&workflows).map_err(|error| { + CliError::runtime(format!( + "failed to read GitHub workflow directory {}: {error}", + workflows.display() + )) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + CliError::runtime(format!( + "failed to read workflow entry in {}: {error}", + workflows.display() + )) + })?; + let path = entry.path(); + let relative = relative_path(root, &path); + if is_yaml_path(&relative) { + ci.push(CiHint { + provider: "github_actions".to_string(), + kind: "workflow".to_string(), + path: relative, + }); + } + } + dedup_ci(ci); + Ok(()) +} + +fn has_extension_case_insensitive(path_like: &str, extension: &str) -> bool { + Path::new(path_like) + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(extension)) +} + +fn is_yaml_path(path: &str) -> bool { + has_extension_case_insensitive(path, "yml") || has_extension_case_insensitive(path, "yaml") +} + +fn add_ecosystem(ecosystems: &mut BTreeSet, ecosystem: &str) { + ecosystems.insert(ecosystem.to_string()); +} + +fn add_manifest(manifests: &mut Vec, ecosystem: &str, kind: &str, path: &str) { + manifests.push(Manifest { + ecosystem: ecosystem.to_string(), + kind: kind.to_string(), + path: path.to_string(), + }); +} + +fn add_entrypoint(entrypoints: &mut Vec, ecosystem: &str, kind: &str, path: &str) { + entrypoints.push(EntryPoint { + ecosystem: ecosystem.to_string(), + kind: kind.to_string(), + path: path.to_string(), + }); +} + +fn add_commands( + command_map: &mut BTreeMap, + ecosystem: &str, + build: &str, + test: &str, +) { + add_command_line(command_map, ecosystem, "build", build.to_string()); + add_command_line(command_map, ecosystem, "test", test.to_string()); +} + +fn add_command_line( + command_map: &mut BTreeMap, + ecosystem: &str, + kind: &str, + value: String, +) { + let entry = command_map.entry(ecosystem.to_string()).or_default(); + match kind { + "build" => { + entry.build.insert(value); + } + "test" => { + entry.test.insert(value); + } + _ => {} + } +} + +fn dedup_manifests(manifests: &mut Vec) { + manifests.sort_by(|left, right| { + ( + left.ecosystem.as_str(), + left.kind.as_str(), + left.path.as_str(), + ) + .cmp(&( + right.ecosystem.as_str(), + right.kind.as_str(), + right.path.as_str(), + )) + }); + manifests.dedup_by(|left, right| { + left.ecosystem == right.ecosystem && left.kind == right.kind && left.path == right.path + }); +} + +fn dedup_entrypoints(entrypoints: &mut Vec) { + entrypoints.sort_by(|left, right| { + ( + left.ecosystem.as_str(), + left.kind.as_str(), + left.path.as_str(), + ) + .cmp(&( + right.ecosystem.as_str(), + right.kind.as_str(), + right.path.as_str(), + )) + }); + entrypoints.dedup_by(|left, right| { + left.ecosystem == right.ecosystem && left.kind == right.kind && left.path == right.path + }); +} + +fn dedup_ci(ci: &mut Vec) { + ci.sort_by(|left, right| { + ( + left.provider.as_str(), + left.kind.as_str(), + left.path.as_str(), + ) + .cmp(&( + right.provider.as_str(), + right.kind.as_str(), + right.path.as_str(), + )) + }); + ci.dedup_by(|left, right| { + left.provider == right.provider && left.kind == right.kind && left.path == right.path + }); +} + +fn read_bounded_text_file(path: &Path, label: &str) -> Result { + read_bounded_text_file_with_limit(path, label, MAX_MANIFEST_BYTES) +} + +fn read_bounded_text_file_with_limit( + path: &Path, + label: &str, + max_manifest_bytes: u64, +) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + } else { + CliError::runtime(format!("failed to inspect {}: {error}", path.display())) + } + })?; + if metadata.len() > max_manifest_bytes { + return Err(CliError::runtime(format!( + "refusing to read {label} {} because it is {} bytes; reposhape manifest files are capped at {max_manifest_bytes} bytes", + path.display(), + metadata.len() + ))); + } + fs::read_to_string(path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn normalize_separators(path: &str) -> String { + path.replace('\\', "/") +} + +fn render_summary(summary: &RepoSummary) -> String { + let mut output = format!( + "root={} ecosystems={} manifests={} commands={} entrypoints={} ci={}\n", + summary.root, + render_pipe_list( + &summary + .ecosystems + .iter() + .map(|item| item.name.clone()) + .collect::>() + ), + summary.manifests.len(), + summary.commands.len(), + summary.entrypoints.len(), + summary.ci.len() + ); + for manifest in summary.manifests.iter().take(8) { + let _ = writeln!( + output, + "manifest ecosystem={} kind={} path={}", + manifest.ecosystem, manifest.kind, manifest.path + ); + } + append_omitted_line(&mut output, "manifest", summary.manifests.len(), 8); + for command in summary.commands.iter().take(6) { + let _ = writeln!( + output, + "command ecosystem={} build={} test={}", + command.ecosystem, + render_pipe_list(&command.build), + render_pipe_list(&command.test) + ); + } + append_omitted_line(&mut output, "command", summary.commands.len(), 6); + for entrypoint in summary.entrypoints.iter().take(12) { + let _ = writeln!( + output, + "entrypoint ecosystem={} kind={} path={}", + entrypoint.ecosystem, entrypoint.kind, entrypoint.path + ); + } + append_omitted_line(&mut output, "entrypoint", summary.entrypoints.len(), 12); + for pipeline in summary.ci.iter().take(6) { + let _ = writeln!( + output, + "ci provider={} kind={} path={}", + pipeline.provider, pipeline.kind, pipeline.path + ); + } + append_omitted_line(&mut output, "ci", summary.ci.len(), 6); + output +} + +fn append_omitted_line(output: &mut String, label: &str, total: usize, shown: usize) { + if total > shown { + let _ = writeln!(output, "{}_omitted={}", label, total - shown); + } +} + +fn render_pipe_list(values: &[String]) -> String { + if values.is_empty() { + "-".to_string() + } else { + values.join("|") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + + #[test] + fn parse_cli_supports_depth_hidden_and_path() { + let (_, cli) = parse_cli_from([ + "reposhape", + "--json", + "--max-depth", + "2", + "--hidden", + "demo", + ]) + .expect("cli"); + assert!(cli.common.json); + assert_eq!(cli.max_depth, Some(2)); + assert!(cli.include_hidden); + assert_eq!(cli.path, Some(PathBuf::from("demo"))); + } + + #[test] + fn node_manager_and_relative_path_helpers_work() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write( + temp.path().join("package.json"), + "{\"scripts\":{\"build\":\"tsc\",\"test\":\"vitest\"}}", + ) + .expect("package"); + fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("lock"); + let package = serde_json::from_str::( + &fs::read_to_string(temp.path().join("package.json")).expect("read package"), + ) + .expect("json"); + assert_eq!( + detect_node_manager(&temp.path().join("package.json"), &package), + "pnpm" + ); + assert_eq!( + relative_path(temp.path(), &temp.path().join("package.json")), + "package.json" + ); + } + + #[test] + fn parse_cli_reports_help_version_and_usage_errors() { + let (help, _) = parse_cli_from(["reposhape", "--help"]).expect("help"); + assert_eq!(help, ParseOutcome::Help); + + let (version, _) = parse_cli_from(["reposhape", "--version"]).expect("version"); + assert_eq!(version, ParseOutcome::Version); + + let missing_depth = parse_cli_from(["reposhape", "--max-depth"]).expect_err("missing"); + assert!(missing_depth.to_string().contains("--max-depth")); + + let invalid_depth = + parse_cli_from(["reposhape", "--max-depth", "abc"]).expect_err("invalid depth"); + assert!( + invalid_depth + .to_string() + .contains("invalid --max-depth value 'abc'") + ); + + let invalid_color = + parse_cli_from(["reposhape", "--color", "always"]).expect_err("invalid color"); + assert!( + invalid_color + .to_string() + .contains("invalid --color value 'always'") + ); + + let unsupported = parse_cli_from(["reposhape", "--unknown"]).expect_err("unsupported"); + assert!(unsupported.to_string().contains("unsupported argument")); + } + + #[test] + fn inspect_repository_reports_missing_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let cli = Cli { + common: CommonArgs::default(), + max_depth: None, + include_hidden: false, + path: Some(temp.path().join("missing")), + }; + + let error = inspect_repository(&cli).expect_err("missing path should fail"); + assert!(error.to_string().contains("repository path does not exist")); + } + + #[test] + fn inspect_repository_rejects_unbounded_large_file_sets() { + let temp = tempfile::tempdir().expect("tempdir"); + for index in 0..=3 { + fs::write(temp.path().join(format!("file-{index}.txt")), "x").expect("file"); + } + let cli = Cli { + common: CommonArgs::default(), + max_depth: None, + include_hidden: false, + path: Some(temp.path().to_path_buf()), + }; + + let error = inspect_repository_with_file_cap(&cli, 2).expect_err("file cap should fail"); + + assert!( + error + .to_string() + .contains("reposhape scan exceeded 2 files") + ); + } + + #[test] + fn read_bounded_text_file_rejects_large_manifests() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("package.json"); + fs::write(&path, vec![b' '; 129]).expect("large manifest"); + + let error = read_bounded_text_file_with_limit(&path, "package.json", 128) + .expect_err("manifest cap"); + + assert!(error.to_string().contains("manifest files are capped")); + } + + #[test] + fn detect_node_manager_prefers_locks_then_package_manager_then_default() { + let temp = tempfile::tempdir().expect("tempdir"); + let package_path = temp.path().join("package.json"); + + assert_eq!( + detect_node_manager(&package_path, &serde_json::json!({})), + "npm" + ); + assert_eq!( + detect_node_manager( + &package_path, + &serde_json::json!({"packageManager":"yarn@4.1.0"}) + ), + "yarn" + ); + + fs::write(temp.path().join("bun.lock"), "").expect("bun lock"); + assert_eq!( + detect_node_manager( + &package_path, + &serde_json::json!({"packageManager":"pnpm@9.0.0"}) + ), + "bun" + ); + } + + #[test] + fn default_node_commands_and_script_command_variants_work() { + let mut command_map = BTreeMap::new(); + + add_default_node_commands( + &mut command_map, + "pnpm", + &serde_json::json!({"scripts":{"build":"tsc"}}), + ); + let hints = command_map.get("pnpm").expect("pnpm hints"); + assert!(hints.build.contains("pnpm run build")); + assert!(hints.test.is_empty()); + + add_default_node_commands( + &mut command_map, + "pnpm", + &serde_json::json!({"scripts":{"test":"vitest"}}), + ); + let hints = command_map.get("pnpm").expect("pnpm hints"); + assert!(hints.test.contains("pnpm run test")); + + assert_eq!(node_script_command("yarn", "build"), "yarn build"); + assert_eq!(node_script_command("bun", "build"), "bun run build"); + assert_eq!(node_script_command("pnpm", "build"), "pnpm run build"); + assert_eq!(node_script_command("npm", "build"), "npm run build"); + } + + #[test] + fn detect_node_workspace_supports_scripts_main_and_bin_object() { + let temp = tempfile::tempdir().expect("tempdir"); + let package_json = temp.path().join("package.json"); + fs::write( + &package_json, + r#"{ + "scripts":{"build":"tsc","test":"vitest"}, + "main":"index.js", + "bin":{"tool":"bin/tool.js","alt":"bin/alt.js"}, + "packageManager":"npm@10.5.0" + }"#, + ) + .expect("package"); + fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("lock"); + + let mut ecosystems = BTreeSet::new(); + let mut manifests = Vec::new(); + let mut command_map = BTreeMap::new(); + let mut entrypoints = Vec::new(); + + detect_node_workspace( + temp.path(), + &package_json, + &mut ecosystems, + &mut manifests, + &mut command_map, + &mut entrypoints, + ) + .expect("workspace detection"); + + assert!(ecosystems.contains("pnpm")); + assert!(manifests.iter().any(|manifest| { + manifest.ecosystem == "pnpm" + && manifest.kind == "package_json" + && manifest.path == "package.json" + })); + let hints = command_map.get("pnpm").expect("pnpm hints"); + assert!(hints.build.contains("pnpm run build")); + assert!(hints.test.contains("pnpm run test")); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.ecosystem == "pnpm" + && entrypoint.kind == "node_main" + && entrypoint.path == "index.js" + })); + assert_eq!( + entrypoints + .iter() + .filter(|entrypoint| entrypoint.kind == "node_bin") + .count(), + 2 + ); + } + + #[test] + fn detect_node_workspace_reports_read_and_parse_errors() { + let temp = tempfile::tempdir().expect("tempdir"); + let missing = temp.path().join("missing-package.json"); + let mut ecosystems = BTreeSet::new(); + let mut manifests = Vec::new(); + let mut command_map = BTreeMap::new(); + let mut entrypoints = Vec::new(); + + let missing_error = detect_node_workspace( + temp.path(), + &missing, + &mut ecosystems, + &mut manifests, + &mut command_map, + &mut entrypoints, + ) + .expect_err("missing package should fail"); + assert!(missing_error.to_string().contains("failed to read")); + + let broken = temp.path().join("package.json"); + fs::write(&broken, "{not-json").expect("broken package"); + let parse_error = detect_node_workspace( + temp.path(), + &broken, + &mut ecosystems, + &mut manifests, + &mut command_map, + &mut entrypoints, + ) + .expect_err("broken package should fail"); + assert!( + parse_error + .to_string() + .contains("failed to parse package.json") + ); + } + + #[test] + fn detect_python_project_collects_scripts_and_reports_parse_errors() { + let temp = tempfile::tempdir().expect("tempdir"); + let pyproject = temp.path().join("pyproject.toml"); + fs::write( + &pyproject, + r#" +[project] +name = "demo" +[project.scripts] +serve = "demo:main" +[tool.poetry.scripts] +fmt = "demo:fmt" +"#, + ) + .expect("pyproject"); + + let mut ecosystems = BTreeSet::new(); + let mut manifests = Vec::new(); + let mut command_map = BTreeMap::new(); + let mut entrypoints = Vec::new(); + detect_python_project( + temp.path(), + &pyproject, + &mut ecosystems, + &mut manifests, + &mut command_map, + &mut entrypoints, + ) + .expect("python project"); + + assert!(ecosystems.contains("python")); + assert!(manifests.iter().any(|manifest| { + manifest.ecosystem == "python" + && manifest.kind == "pyproject_toml" + && manifest.path == "pyproject.toml" + })); + let hints = command_map.get("python").expect("python hints"); + assert!(hints.build.contains("python -m build")); + assert!(hints.test.contains("pytest")); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.kind == "project_script" && entrypoint.path == "pyproject.toml::serve" + })); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.kind == "poetry_script" && entrypoint.path == "pyproject.toml::fmt" + })); + + fs::write(&pyproject, "[project\nname = \"broken\"").expect("broken pyproject"); + let parse_error = detect_python_project( + temp.path(), + &pyproject, + &mut ecosystems, + &mut manifests, + &mut command_map, + &mut entrypoints, + ) + .expect_err("broken pyproject should fail"); + assert!( + parse_error + .to_string() + .contains("failed to parse pyproject") + ); + } + + #[test] + fn detect_go_entrypoints_finds_root_and_cmd_main_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let go_mod = temp.path().join("go.mod"); + fs::write(&go_mod, "module example.com/demo\n").expect("go.mod"); + fs::create_dir_all(temp.path().join("cmd").join("server")).expect("cmd dir"); + fs::create_dir_all(temp.path().join("cmd").join("worker")).expect("cmd dir"); + fs::write(temp.path().join("main.go"), "package main\n").expect("main"); + fs::write( + temp.path().join("cmd").join("server").join("main.go"), + "package main\n", + ) + .expect("cmd main"); + + let mut entrypoints = Vec::new(); + detect_go_entrypoints(temp.path(), &go_mod, &mut entrypoints); + + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.ecosystem == "go" + && entrypoint.kind == "go_main" + && entrypoint.path == "main.go" + })); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.ecosystem == "go" + && entrypoint.kind == "go_cmd" + && entrypoint.path.replace('\\', "/") == "cmd/server/main.go" + })); + assert!( + !entrypoints + .iter() + .any(|entrypoint| entrypoint.path.replace('\\', "/") == "cmd/worker/main.go") + ); + } + + #[test] + fn detect_scripted_layout_surfaces_manifestless_build_projects() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("src dir"); + fs::create_dir_all(temp.path().join("tests")).expect("tests dir"); + fs::create_dir_all(temp.path().join("docs")).expect("docs dir"); + fs::write(temp.path().join("build.ps1"), "Write-Host build").expect("build script"); + fs::write(temp.path().join("README.md"), "# demo").expect("readme"); + fs::write(temp.path().join("src").join("Plugin.cs"), "class Plugin {}").expect("source"); + fs::write( + temp.path().join("tests").join("PluginTests.cs"), + "class PluginTests {}", + ) + .expect("test source"); + + let files = vec![ + temp.path().join("build.ps1"), + temp.path().join("README.md"), + temp.path().join("src").join("Plugin.cs"), + temp.path().join("tests").join("PluginTests.cs"), + temp.path().join("docs").join("notes.md"), + ]; + let mut ecosystems = BTreeSet::new(); + let mut manifests = Vec::new(); + let mut command_map = BTreeMap::new(); + let mut entrypoints = Vec::new(); + + detect_scripted_layout( + temp.path(), + &files, + &mut ecosystems, + &mut manifests, + &mut command_map, + &mut entrypoints, + ); + + assert!(ecosystems.contains("scripted")); + assert!(manifests.iter().any(|manifest| { + manifest.ecosystem == "scripted" + && manifest.kind == "build_script" + && normalize_separators(&manifest.path) == "build.ps1" + })); + let scripted = command_map.get("scripted").expect("scripted commands"); + assert!(scripted.build.contains("pwsh -NoProfile -File build.ps1")); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.ecosystem == "scripted" + && entrypoint.kind == "source_dir" + && entrypoint.path == "src" + })); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.ecosystem == "scripted" + && entrypoint.kind == "test_dir" + && entrypoint.path == "tests" + })); + assert!(entrypoints.iter().any(|entrypoint| { + entrypoint.ecosystem == "scripted" + && entrypoint.kind == "docs_dir" + && entrypoint.path == "docs" + })); + } + + #[test] + fn detect_make_commands_handles_test_and_check_targets() { + let temp = tempfile::tempdir().expect("tempdir"); + let makefile = temp.path().join("Makefile"); + fs::write(&makefile, "build:\n\t@echo build\ncheck:\n\t@echo check\n").expect("makefile"); + + let mut command_map = BTreeMap::new(); + detect_make_commands(&makefile, &mut command_map).expect("make commands"); + let hints = command_map.get("make").expect("make hints"); + assert!(hints.build.contains("make")); + assert!(hints.test.contains("make test")); + + fs::write(&makefile, "build:\n\t@echo build\n").expect("makefile"); + let mut command_map = BTreeMap::new(); + detect_make_commands(&makefile, &mut command_map).expect("make commands"); + let hints = command_map.get("make").expect("make hints"); + assert!(hints.build.contains("make")); + assert!(hints.test.is_empty()); + } + + #[test] + fn detect_github_actions_filters_yaml_and_reports_directory_errors() { + let temp = tempfile::tempdir().expect("tempdir"); + let workflows = temp.path().join(".github").join("workflows"); + fs::create_dir_all(&workflows).expect("workflows"); + fs::write(workflows.join("ci.yml"), "name: ci\n").expect("ci"); + fs::write(workflows.join("release.yaml"), "name: release\n").expect("release"); + fs::write(workflows.join("notes.txt"), "ignore\n").expect("notes"); + + let mut ci = Vec::new(); + detect_github_actions(temp.path(), &mut ci).expect("github actions"); + assert_eq!(ci.len(), 2); + assert!( + ci.iter() + .any(|hint| hint.path.replace('\\', "/") == ".github/workflows/ci.yml") + ); + assert!( + ci.iter() + .any(|hint| hint.path.replace('\\', "/") == ".github/workflows/release.yaml") + ); + + let broken = tempfile::tempdir().expect("broken tempdir"); + let broken_github = broken.path().join(".github"); + fs::create_dir_all(&broken_github).expect("broken github dir"); + fs::write(broken_github.join("workflows"), "not a directory").expect("broken workflows"); + let mut ci = Vec::new(); + let error = + detect_github_actions(broken.path(), &mut ci).expect_err("workflows file should fail"); + assert!( + error + .to_string() + .contains("failed to read GitHub workflow directory") + ); + } + + #[test] + fn inspect_repository_detects_mixed_repo_layouts_in_one_pass() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join(".github/workflows")).expect("workflow dir"); + fs::create_dir_all(temp.path().join("src")).expect("src dir"); + fs::create_dir_all(temp.path().join("tests")).expect("tests dir"); + fs::create_dir_all(temp.path().join("docs")).expect("docs dir"); + fs::create_dir_all(temp.path().join("cmd/demo")).expect("cmd dir"); + + fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='demo'\nversion='0.1.0'\n", + ) + .expect("cargo"); + fs::write( + temp.path().join("package.json"), + r#"{"packageManager":"pnpm@9.0.0","scripts":{"build":"tsc","test":"vitest"},"main":"index.js"}"#, + ) + .expect("package"); + fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("pnpm"); + fs::write( + temp.path().join("pyproject.toml"), + "[project]\nname='demo'\n[project.scripts]\nserve='demo:main'\n", + ) + .expect("pyproject"); + fs::write(temp.path().join("go.mod"), "module example.com/demo\n").expect("go"); + fs::write(temp.path().join("CMakeLists.txt"), "project(demo)\n").expect("cmake"); + fs::write(temp.path().join("Makefile"), "test:\n\tpytest\n").expect("make"); + fs::write(temp.path().join("build.ps1"), "Write-Host build\n").expect("build script"); + fs::write(temp.path().join("test.sh"), "pytest\n").expect("test script"); + fs::write(temp.path().join("requirements.txt"), "pytest\n").expect("requirements"); + fs::write( + temp.path().join(".github/workflows/ci.yml"), + "name: ci\non: [push]\n", + ) + .expect("workflow"); + fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").expect("src"); + fs::write( + temp.path().join("tests/test_demo.py"), + "def test_demo():\n assert True\n", + ) + .expect("tests"); + fs::write(temp.path().join("docs/guide.md"), "# guide\n").expect("docs"); + fs::write( + temp.path().join("cmd/demo/main.go"), + "package main\nfunc main() {}\n", + ) + .expect("go main"); + + let summary = inspect_repository(&Cli { + common: CommonArgs::default(), + max_depth: None, + include_hidden: true, + path: Some(temp.path().to_path_buf()), + }) + .expect("summary"); + + let ecosystems = summary + .ecosystems + .iter() + .map(|ecosystem| ecosystem.name.as_str()) + .collect::>(); + assert!(ecosystems.contains("cargo")); + assert!(ecosystems.contains("pnpm")); + assert!(ecosystems.contains("python")); + assert!(ecosystems.contains("go")); + assert!(ecosystems.contains("cmake")); + assert!(ecosystems.contains("make")); + assert!(ecosystems.contains("scripted")); + assert!( + summary + .ci + .iter() + .any(|hint| normalize_separators(&hint.path) == ".github/workflows/ci.yml") + ); + assert!( + summary + .entrypoints + .iter() + .any(|entrypoint| entrypoint.path == "src") + ); + assert!(summary.commands.iter().any(|command| { + command.ecosystem == "cargo" && command.build.contains(&"cargo build".to_string()) + })); + assert!(summary.commands.iter().any(|command| { + command.ecosystem == "scripted" + && command + .build + .iter() + .any(|value| value.contains("build.ps1")) + })); + } + + #[test] + fn dedup_helpers_sort_and_remove_duplicates() { + let mut manifests = vec![ + Manifest { + ecosystem: "python".to_string(), + kind: "pyproject_toml".to_string(), + path: "pyproject.toml".to_string(), + }, + Manifest { + ecosystem: "cargo".to_string(), + kind: "cargo_toml".to_string(), + path: "Cargo.toml".to_string(), + }, + Manifest { + ecosystem: "python".to_string(), + kind: "pyproject_toml".to_string(), + path: "pyproject.toml".to_string(), + }, + ]; + dedup_manifests(&mut manifests); + assert_eq!(manifests.len(), 2); + assert_eq!(manifests[0].ecosystem, "cargo"); + assert_eq!(manifests[1].ecosystem, "python"); + + let mut entrypoints = vec![ + EntryPoint { + ecosystem: "go".to_string(), + kind: "go_cmd".to_string(), + path: "cmd/server/main.go".to_string(), + }, + EntryPoint { + ecosystem: "go".to_string(), + kind: "go_main".to_string(), + path: "main.go".to_string(), + }, + EntryPoint { + ecosystem: "go".to_string(), + kind: "go_cmd".to_string(), + path: "cmd/server/main.go".to_string(), + }, + ]; + dedup_entrypoints(&mut entrypoints); + assert_eq!(entrypoints.len(), 2); + assert_eq!(entrypoints[0].kind, "go_cmd"); + assert_eq!(entrypoints[1].kind, "go_main"); + + let mut ci = vec![ + CiHint { + provider: "github_actions".to_string(), + kind: "workflow".to_string(), + path: ".github/workflows/release.yaml".to_string(), + }, + CiHint { + provider: "github_actions".to_string(), + kind: "workflow".to_string(), + path: ".github/workflows/ci.yml".to_string(), + }, + CiHint { + provider: "github_actions".to_string(), + kind: "workflow".to_string(), + path: ".github/workflows/ci.yml".to_string(), + }, + ]; + dedup_ci(&mut ci); + assert_eq!(ci.len(), 2); + assert_eq!(ci[0].path, ".github/workflows/ci.yml"); + assert_eq!(ci[1].path, ".github/workflows/release.yaml"); + } + + #[test] + fn render_summary_and_pipe_list_cover_empty_and_populated_variants() { + let empty = RepoSummary { + root: "repo".to_string(), + ecosystems: Vec::new(), + manifests: Vec::new(), + commands: Vec::new(), + entrypoints: Vec::new(), + ci: Vec::new(), + }; + let empty_rendered = render_summary(&empty); + assert!( + empty_rendered + .contains("root=repo ecosystems=- manifests=0 commands=0 entrypoints=0 ci=0") + ); + assert_eq!(render_pipe_list(&[]), "-"); + + let populated = RepoSummary { + root: "repo".to_string(), + ecosystems: vec![Ecosystem { + name: "cargo".to_string(), + }], + manifests: vec![Manifest { + ecosystem: "cargo".to_string(), + kind: "cargo_toml".to_string(), + path: "Cargo.toml".to_string(), + }], + commands: vec![CommandHint { + ecosystem: "cargo".to_string(), + build: vec!["cargo build".to_string(), "cargo clippy".to_string()], + test: Vec::new(), + }], + entrypoints: vec![EntryPoint { + ecosystem: "cargo".to_string(), + kind: "rust_bin".to_string(), + path: "src/main.rs".to_string(), + }], + ci: vec![CiHint { + provider: "github_actions".to_string(), + kind: "workflow".to_string(), + path: ".github/workflows/ci.yml".to_string(), + }], + }; + let rendered = render_summary(&populated); + assert!(rendered.contains("ecosystems=cargo manifests=1 commands=1 entrypoints=1 ci=1")); + assert!(rendered.contains("manifest ecosystem=cargo kind=cargo_toml path=Cargo.toml")); + assert!(rendered.contains("command ecosystem=cargo build=cargo build|cargo clippy test=-")); + assert!(rendered.contains("entrypoint ecosystem=cargo kind=rust_bin path=src/main.rs")); + assert!( + rendered + .contains("ci provider=github_actions kind=workflow path=.github/workflows/ci.yml") + ); + } +} diff --git a/crates/reposhape/src/main.rs b/crates/reposhape/src/main.rs new file mode 100644 index 0000000..061fdfb --- /dev/null +++ b/crates/reposhape/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `reposhape`. + +fn main() { + std::process::exit(reposhape::main_entry()); +} diff --git a/crates/reposhape/tests/reposhape_cli.rs b/crates/reposhape/tests/reposhape_cli.rs new file mode 100644 index 0000000..e267c4a --- /dev/null +++ b/crates/reposhape/tests/reposhape_cli.rs @@ -0,0 +1,189 @@ +//! Integration tests for the `reposhape` command. + +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("reposhape").expect("binary") +} + +#[test] +fn detects_multiple_ecosystems_and_grouped_sections_as_json() { + let temp = tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("src"); + fs::create_dir_all(temp.path().join(".github").join("workflows")).expect("workflows"); + fs::create_dir_all(temp.path().join("cmd").join("server")).expect("go cmd"); + fs::create_dir_all(temp.path().join("app")).expect("app"); + + fs::write(temp.path().join("Cargo.toml"), "[package]\nname=\"demo\"\n").expect("cargo"); + fs::write(temp.path().join("src").join("main.rs"), "fn main() {}\n").expect("main"); + fs::write( + temp.path().join("package.json"), + "{\"scripts\":{\"build\":\"tsc\",\"test\":\"vitest\"},\"main\":\"index.js\"}", + ) + .expect("package"); + fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("lock"); + fs::write( + temp.path().join("pyproject.toml"), + "[project]\nname=\"demo\"\n[project.scripts]\nserve=\"demo:main\"\n", + ) + .expect("pyproject"); + fs::write(temp.path().join("requirements.txt"), "pytest\n").expect("requirements"); + fs::write( + temp.path().join("go.mod"), + "module example.com/demo\n\ngo 1.22\n", + ) + .expect("go"); + fs::write( + temp.path().join("cmd").join("server").join("main.go"), + "package main\nfunc main(){}\n", + ) + .expect("go main"); + fs::write( + temp.path().join("CMakeLists.txt"), + "cmake_minimum_required(VERSION 3.20)\n", + ) + .expect("cmake"); + fs::write( + temp.path().join("Makefile"), + "build:\n\t@echo build\ntest:\n\t@echo test\n", + ) + .expect("make"); + fs::write( + temp.path().join("demo.sln"), + "Microsoft Visual Studio Solution File\n", + ) + .expect("sln"); + fs::write( + temp.path().join("app").join("demo.csproj"), + "\n", + ) + .expect("csproj"); + fs::write( + temp.path().join(".github").join("workflows").join("ci.yml"), + "name: ci\non: [push]\n", + ) + .expect("workflow"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"ecosystems\"")) + .stdout(predicate::str::contains("\"manifests\"")) + .stdout(predicate::str::contains("\"commands\"")) + .stdout(predicate::str::contains("\"entrypoints\"")) + .stdout(predicate::str::contains("\"ci\"")) + .stdout(predicate::str::contains("\"name\":\"cargo\"")) + .stdout(predicate::str::contains("\"name\":\"pnpm\"")) + .stdout(predicate::str::contains("\"name\":\"python\"")) + .stdout(predicate::str::contains("\"name\":\"dotnet\"")) + .stdout(predicate::str::contains("\"name\":\"go\"")) + .stdout(predicate::str::contains("\"name\":\"cmake\"")) + .stdout(predicate::str::contains("\"name\":\"make\"")) + .stdout(predicate::str::contains("\"provider\":\"github_actions\"")); +} + +#[test] +fn help_includes_grouped_output_language() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--max-depth")) + .stdout(predicate::str::contains("ecosystems")) + .stdout(predicate::str::contains("reposhape")); +} + +#[test] +fn max_depth_limits_nested_package_detection() { + let temp = tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("src"); + fs::create_dir_all(temp.path().join("deep")).expect("deep"); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname=\"demo\"\n").expect("cargo"); + fs::write(temp.path().join("src").join("main.rs"), "fn main() {}\n").expect("main"); + fs::write( + temp.path().join("deep").join("package.json"), + "{\"scripts\":{\"build\":\"tsc\"}}", + ) + .expect("package"); + + let mut shallow = cargo_command(); + shallow + .arg("--json") + .arg("--max-depth") + .arg("1") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"cargo\"")) + .stdout(predicate::str::contains("\"kind\":\"package_json\"").not()); + + let mut deep = cargo_command(); + deep.arg("--json") + .arg("--max-depth") + .arg("4") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"kind\":\"package_json\"")) + .stdout(predicate::str::contains("deep")); +} + +#[test] +fn hidden_flag_controls_hidden_workspace_detection() { + let temp = tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("src"); + fs::create_dir_all(temp.path().join(".hidden")).expect("hidden"); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname=\"demo\"\n").expect("cargo"); + fs::write(temp.path().join("src").join("main.rs"), "fn main() {}\n").expect("main"); + fs::write( + temp.path().join(".hidden").join("package.json"), + "{\"scripts\":{\"test\":\"vitest\"}}", + ) + .expect("package"); + + let mut without_hidden = cargo_command(); + without_hidden + .arg("--json") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"kind\":\"package_json\"").not()); + + let mut with_hidden = cargo_command(); + with_hidden + .arg("--json") + .arg("--hidden") + .arg(temp.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"kind\":\"package_json\"")) + .stdout(predicate::str::contains("hidden")); +} + +#[test] +fn cli_surfaces_invalid_depth_and_missing_path_errors() { + let mut invalid_depth = cargo_command(); + invalid_depth + .arg("--max-depth") + .arg("nope") + .assert() + .failure() + .stderr(predicate::str::contains("invalid --max-depth value")); + + let temp = tempdir().expect("tempdir"); + let missing = temp.path().join("does-not-exist"); + let mut missing_path = cargo_command(); + missing_path + .arg(missing) + .assert() + .failure() + .stderr(predicate::str::contains("repository path does not exist")); +} diff --git a/crates/runprobe/Cargo.toml b/crates/runprobe/Cargo.toml new file mode 100644 index 0000000..fcb584a --- /dev/null +++ b/crates/runprobe/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "runprobe" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Run a command with timeout/exit expectations and emit probe diagnostics." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +runtimekit = { path = "../runtimekit" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/runprobe/src/lib.rs b/crates/runprobe/src/lib.rs new file mode 100644 index 0000000..586ef3d --- /dev/null +++ b/crates/runprobe/src/lib.rs @@ -0,0 +1,752 @@ +//! The `runprobe` command executes one command and reports runtime outcomes. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs::{self, OpenOptions}; +use std::io::Write as IoWrite; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, print_text, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use runtimekit::{ + ShellMode, collect_command_values, parse_duration_flag, parse_shell_mode, parse_usize_flag, + parser_value_string, render_command, run_command_capture_with_shell, tail_bytes_to_string, +}; +use serde::Serialize; + +const HELP: &str = "\ +Probe one command execution with stable JSON and bounded output tails. + +Capture success is separate from child success: when `runprobe` launches, waits, and captures +output successfully, it exits 0 and reports child state in `ok`, `exit_code`, and `timed_out`. +Use `--fail-on-child-error` when calling scripts want `runprobe` itself to exit nonzero on child +failure or timeout. + +Usage: + runprobe [OPTIONS] -- + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --fail-on-child-error Exit nonzero when the child fails or times out + --shell Launch mode: raw, pwsh, cmd (default: raw) + --timeout Kill the command if it exceeds this duration + --cwd Working directory used when spawning the command + --tail-bytes Bytes of stdout/stderr to keep in the tail fields (default: 4096) + --log-dir Write a full stdout/stderr log file into this directory + -h, --help Show this help text + -V, --version Show the command version + +Examples: + runprobe --shell raw -- cmd /d /s /c \"exit 0\" + runprobe --shell pwsh -- Write-Output done + runprobe --shell pwsh -- '& { Write-Output done }' + runprobe --json --shell pwsh -- '& { Write-Error boom; exit 9 }' | ConvertFrom-Json + runprobe --fail-on-child-error --shell pwsh -- '& { Write-Error boom; exit 9 }' + runprobe --json --shell cmd -- echo hello | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + fail_on_child_error: bool, + shell: ShellMode, + timeout: Option, + cwd: Option, + tail_bytes: usize, + log_dir: Option, + command: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ProbeReport { + exit_code: Option, + duration_ms: u128, + timed_out: bool, + termination_reason: String, + stdout_tail: String, + stderr_tail: String, + log_path: Option, + command: String, + argv: Vec, + cwd: Option, + ok: bool, + next_hint: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("runprobe {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: None, + cwd: None, + tail_bytes: 4096, + log_dir: None, + command: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => cli.common.quiet = true, + Long("fail-on-child-error") => cli.fail_on_child_error = true, + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("shell") => { + cli.shell = + parse_shell_mode("--shell", &parser_value_string(&mut parser, "--shell")?)?; + } + Long("timeout") => { + cli.timeout = Some(parse_duration_flag( + "--timeout", + &parser_value_string(&mut parser, "--timeout")?, + )?); + } + Long("cwd") => { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + cli.cwd = Some(PathBuf::from(value)); + } + Long("tail-bytes") => { + cli.tail_bytes = parse_usize_flag( + "--tail-bytes", + &parser_value_string(&mut parser, "--tail-bytes")?, + )?; + } + Long("log-dir") => { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + cli.log_dir = Some(PathBuf::from(value)); + } + ArgValue(value) => { + cli.command = collect_command_values(&mut parser, value)?; + break; + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if cli.command.is_empty() { + return Err(CliError::usage("runprobe requires a command after --")); + } + + Ok((ParseOutcome::Run, cli)) +} + +fn run(cli: &Cli) -> Result { + let captured = + run_command_capture_with_shell(&cli.command, cli.shell, cli.cwd.as_deref(), cli.timeout)?; + let stdout_tail = tail_bytes_to_string(&captured.stdout, cli.tail_bytes); + let stderr_tail = tail_bytes_to_string(&captured.stderr, cli.tail_bytes); + let log_path = cli + .log_dir + .as_deref() + .map(|path| write_log_file(path, cli, &captured)) + .transpose()?; + let report = ProbeReport { + exit_code: captured.exit_code, + duration_ms: captured.duration.as_millis(), + timed_out: captured.timed_out, + termination_reason: termination_reason(captured.timed_out, captured.exit_code), + stdout_tail, + stderr_tail, + log_path, + command: render_command(&cli.command), + argv: cli + .command + .iter() + .map(|value| value.to_string_lossy().to_string()) + .collect(), + cwd: cli.cwd.as_ref().map(|path| path.display().to_string()), + ok: !captured.timed_out && captured.exit_code == Some(0), + next_hint: child_failure_hint(!captured.timed_out && captured.exit_code == Some(0), cli), + }; + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print_text(render_report_text(&report))?, + } + if cli.fail_on_child_error && !report.ok { + Ok(ExitCode::RuntimeError) + } else { + Ok(ExitCode::Success) + } +} + +fn write_log_file( + log_dir: &Path, + cli: &Cli, + captured: &runtimekit::CapturedCommand, +) -> Result { + fs::create_dir_all(log_dir).map_err(|error| { + CliError::runtime(format!("failed to create {}: {error}", log_dir.display())) + })?; + let log_path = log_dir.join(format!("runprobe-{}.log", unique_suffix())); + let mut body = String::new(); + writeln!(body, "command={}", render_command(&cli.command)) + .expect("writing to a String cannot fail"); + writeln!( + body, + "cwd={}", + cli.cwd + .as_ref() + .map_or_else(String::new, |path| path.display().to_string()) + ) + .expect("writing to a String cannot fail"); + writeln!( + body, + "exit_code={}", + format_optional_i32(captured.exit_code) + ) + .expect("writing to a String cannot fail"); + writeln!(body, "timed_out={}", captured.timed_out).expect("writing to a String cannot fail"); + writeln!(body, "duration_ms={}", captured.duration.as_millis()) + .expect("writing to a String cannot fail"); + body.push('\n'); + body.push_str("[stdout]\n"); + body.push_str(&String::from_utf8_lossy(&captured.stdout)); + if !body.ends_with('\n') { + body.push('\n'); + } + body.push_str("\n[stderr]\n"); + body.push_str(&String::from_utf8_lossy(&captured.stderr)); + if !body.ends_with('\n') { + body.push('\n'); + } + write_log_file_exclusive(&log_path, &body)?; + Ok(log_path.display().to_string()) +} + +fn write_log_file_exclusive(path: &Path, body: &str) -> Result<(), CliError> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| { + CliError::runtime(format!( + "refusing to replace existing runprobe log {}: {error}", + path.display() + )) + })?; + file.write_all(body.as_bytes()).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + file.flush() + .map_err(|error| CliError::runtime(format!("failed to flush {}: {error}", path.display()))) +} + +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |value| value.as_nanos()); + format!("{}-{nanos}", std::process::id()) +} + +fn render_report_text(report: &ProbeReport) -> String { + let mut text = format!( + "{} exit_code={} duration_ms={} timed_out={} command={}", + status_word(report), + format_optional_i32(report.exit_code), + report.duration_ms, + report.timed_out, + report.command + ); + write!(text, " reason={}", report.termination_reason).expect("writing to a String cannot fail"); + if let Some(cwd) = &report.cwd { + write!(text, " cwd={cwd}").expect("writing to a String cannot fail"); + } + if let Some(log_path) = &report.log_path { + write!(text, " log_path={log_path}").expect("writing to a String cannot fail"); + } + if report.ok { + return text; + } + if !report.stderr_tail.is_empty() { + text.push_str("\nstderr_tail:\n"); + text.push_str(&report.stderr_tail); + } + if !report.stdout_tail.is_empty() { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str("stdout_tail:\n"); + text.push_str(&report.stdout_tail); + } + if let Some(next_hint) = &report.next_hint { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str("next_hint:\n"); + text.push_str(next_hint); + } + text +} + +const fn status_word(report: &ProbeReport) -> &'static str { + if report.ok { + "ok" + } else if report.timed_out { + "timeout" + } else { + "fail" + } +} + +fn format_optional_i32(value: Option) -> String { + value.map_or_else(|| "none".to_string(), |item| item.to_string()) +} + +fn termination_reason(timed_out: bool, exit_code: Option) -> String { + if timed_out { + "timeout".to_string() + } else if exit_code.is_some() { + "exit".to_string() + } else { + "terminated".to_string() + } +} + +fn child_failure_hint(ok: bool, cli: &Cli) -> Option { + if ok || cli.fail_on_child_error { + return None; + } + Some( + "rerun with --fail-on-child-error if the caller should treat child failure or timeout as a nonzero tool exit" + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use common::CommonArgs; + use tempfile::TempDir; + + fn command(items: &[&str]) -> Vec { + items.iter().map(OsString::from).collect() + } + + #[test] + fn parse_cli_accepts_v3_flags() { + let (_, cli) = parse_cli_from([ + "runprobe", + "--json", + "--shell", + "pwsh", + "--timeout", + "250ms", + "--cwd", + ".", + "--tail-bytes", + "99", + "--log-dir", + "logs", + "--", + "Write-Output", + "done", + ]) + .expect("cli"); + assert!(cli.common.json); + assert_eq!(cli.shell, ShellMode::Pwsh); + assert_eq!(cli.timeout, Some(Duration::from_millis(250))); + assert_eq!(cli.tail_bytes, 99); + assert!(cli.cwd.is_some()); + assert!(cli.log_dir.is_some()); + assert_eq!(cli.command.len(), 2); + } + + #[test] + fn parse_cli_rejects_missing_command() { + let error = parse_cli_from(["runprobe", "--timeout", "1s"]).expect_err("missing command"); + assert!(matches!( + error, + CliError::Usage(message) if message.contains("requires a command") + )); + } + + #[test] + fn report_text_stays_compact_on_success() { + let report = ProbeReport { + exit_code: Some(0), + duration_ms: 12, + timed_out: false, + termination_reason: "exit".into(), + stdout_tail: "done\n".into(), + stderr_tail: String::new(), + log_path: None, + command: "Write-Output done".into(), + argv: vec!["Write-Output".into(), "done".into()], + cwd: Some(".".into()), + ok: true, + next_hint: None, + }; + let text = render_report_text(&report); + assert!(text.contains("ok exit_code=0")); + assert!(text.contains("duration_ms=12")); + assert!(!text.contains("stdout_tail:")); + } + + #[test] + fn report_text_includes_tails_on_failure() { + let report = ProbeReport { + exit_code: Some(9), + duration_ms: 12, + timed_out: false, + termination_reason: "exit".into(), + stdout_tail: "out".into(), + stderr_tail: "boom".into(), + log_path: Some("log.txt".into()), + command: render_command(&command(&["Write-Output", "done"])), + argv: vec!["Write-Output".into(), "done".into()], + cwd: None, + ok: false, + next_hint: Some("rerun with --fail-on-child-error".into()), + }; + let text = render_report_text(&report); + assert!(text.contains("fail exit_code=9")); + assert!(text.contains("stderr_tail:")); + assert!(text.contains("stdout_tail:")); + assert!(text.contains("log_path=log.txt")); + assert!(text.contains("next_hint:")); + } + + #[test] + fn write_log_file_records_metadata_and_optional_exit_codes() { + let temp = TempDir::new().expect("temp dir"); + let cwd = temp.path().join("work"); + fs::create_dir_all(&cwd).expect("cwd"); + let cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: Some(Duration::from_millis(50)), + cwd: Some(cwd.clone()), + tail_bytes: 32, + log_dir: Some(temp.path().join("logs")), + command: command(&["tool.exe", "--flag"]), + }; + let captured = runtimekit::CapturedCommand { + exit_code: None, + timed_out: true, + duration: Duration::from_millis(75), + stdout: b"out".to_vec(), + stderr: b"boom".to_vec(), + }; + + let log_path = write_log_file(cli.log_dir.as_deref().expect("log dir"), &cli, &captured) + .expect("log path"); + let body = fs::read_to_string(&log_path).expect("log body"); + + assert!(body.contains("command=tool.exe --flag")); + assert!(body.contains(&format!("cwd={}", cwd.display()))); + assert!(body.contains("exit_code=none")); + assert!(body.contains("timed_out=true")); + assert!(body.contains("[stdout]\nout")); + assert!(body.contains("[stderr]\nboom")); + assert_eq!(format_optional_i32(Some(-9)), "-9"); + assert_eq!(format_optional_i32(None), "none"); + } + + #[test] + fn log_file_writer_refuses_preexisting_paths() { + let temp = TempDir::new().expect("temp dir"); + let path = temp.path().join("runprobe-existing.log"); + fs::write(&path, "original").expect("preexisting log"); + + let error = + write_log_file_exclusive(&path, "replacement").expect_err("preexisting path refused"); + + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("refusing to replace existing runprobe log") + )); + assert_eq!( + fs::read_to_string(&path).expect("preserved content"), + "original" + ); + } + + #[test] + fn status_word_and_report_text_cover_timeout_and_optional_fields() { + let timeout_report = ProbeReport { + exit_code: None, + duration_ms: 50, + timed_out: true, + termination_reason: "timeout".into(), + stdout_tail: String::new(), + stderr_tail: "still running".into(), + log_path: Some("logs\\probe.log".into()), + command: "pwsh -NoProfile".into(), + argv: vec!["pwsh".into(), "-NoProfile".into()], + cwd: Some("work".into()), + ok: false, + next_hint: Some("rerun with --fail-on-child-error".into()), + }; + assert_eq!(status_word(&timeout_report), "timeout"); + let timeout_text = render_report_text(&timeout_report); + assert!(timeout_text.contains("timeout exit_code=none")); + assert!(timeout_text.contains("reason=timeout")); + assert!(timeout_text.contains("cwd=work")); + assert!(timeout_text.contains("log_path=logs\\probe.log")); + assert!(timeout_text.contains("stderr_tail:")); + assert!(!timeout_text.contains("stdout_tail:")); + + let ok_report = ProbeReport { + ok: true, + timed_out: false, + exit_code: Some(0), + duration_ms: 1, + termination_reason: "exit".into(), + stdout_tail: String::new(), + stderr_tail: String::new(), + log_path: None, + command: "tool".into(), + argv: vec!["tool".into()], + cwd: None, + next_hint: None, + }; + assert_eq!(status_word(&ok_report), "ok"); + + let fail_report = ProbeReport { + ok: false, + timed_out: false, + exit_code: Some(9), + duration_ms: 1, + termination_reason: "exit".into(), + stdout_tail: String::new(), + stderr_tail: String::new(), + log_path: None, + command: "tool".into(), + argv: vec!["tool".into()], + cwd: None, + next_hint: Some("rerun with --fail-on-child-error".into()), + }; + assert_eq!(status_word(&fail_report), "fail"); + } + + #[test] + fn child_failure_hint_is_only_emitted_when_tool_exit_stays_zero() { + let ok_cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: None, + cwd: None, + tail_bytes: 64, + log_dir: None, + command: command(&["tool"]), + }; + assert!(child_failure_hint(true, &ok_cli).is_none()); + + let mut strict_cli = ok_cli.clone(); + strict_cli.fail_on_child_error = true; + assert!(child_failure_hint(false, &strict_cli).is_none()); + + assert!( + child_failure_hint(false, &ok_cli) + .expect("hint") + .contains("--fail-on-child-error") + ); + } + + #[test] + fn write_log_file_surfaces_log_dir_creation_failures() { + let temp = TempDir::new().expect("temp dir"); + let not_a_dir = temp.path().join("occupied"); + fs::write(¬_a_dir, "file").expect("occupied file"); + let cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: None, + cwd: None, + tail_bytes: 16, + log_dir: Some(not_a_dir.clone()), + command: command(&["tool.exe"]), + }; + let captured = runtimekit::CapturedCommand { + exit_code: Some(0), + timed_out: false, + duration: Duration::from_millis(1), + stdout: Vec::new(), + stderr: Vec::new(), + }; + assert!(matches!( + write_log_file(¬_a_dir, &cli, &captured), + Err(CliError::Runtime(message)) if message.contains("failed to create") + )); + } + + #[cfg(windows)] + #[test] + fn run_maps_success_failure_and_timeout_exit_codes() { + let success_cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: Some(Duration::from_secs(1)), + cwd: None, + tail_bytes: 32, + log_dir: None, + command: command(&["pwsh", "-NoProfile", "-Command", "Write-Output ok; exit 0"]), + }; + assert_eq!(run(&success_cli).expect("success"), ExitCode::Success); + + let failure_cli = Cli { + common: CommonArgs { + json: true, + format: None, + ..CommonArgs::default() + }, + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: Some(Duration::from_secs(1)), + cwd: None, + tail_bytes: 32, + log_dir: None, + command: command(&[ + "pwsh", + "-NoProfile", + "-Command", + "Write-Error boom; Write-Output out; exit 9", + ]), + }; + assert_eq!(run(&failure_cli).expect("failure"), ExitCode::Success); + + let temp = TempDir::new().expect("temp dir"); + let log_dir = temp.path().join("logs"); + let timeout_cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: false, + shell: ShellMode::Raw, + timeout: Some(Duration::from_millis(50)), + cwd: None, + tail_bytes: 32, + log_dir: Some(log_dir.clone()), + command: command(&[ + "pwsh", + "-NoProfile", + "-Command", + "Start-Sleep -Milliseconds 200", + ]), + }; + assert_eq!(run(&timeout_cli).expect("timeout"), ExitCode::Success); + let entries = fs::read_dir(&log_dir).expect("log dir"); + assert!(entries.count() >= 1); + } + + #[cfg(windows)] + #[test] + fn run_accepts_single_string_pwsh_commands() { + let cli = Cli { + common: CommonArgs { + json: true, + format: None, + ..CommonArgs::default() + }, + fail_on_child_error: false, + shell: ShellMode::Pwsh, + timeout: Some(Duration::from_secs(1)), + cwd: None, + tail_bytes: 32, + log_dir: None, + command: command(&["Write-Output done"]), + }; + assert_eq!(run(&cli).expect("pwsh inline command"), ExitCode::Success); + } + + #[cfg(windows)] + #[test] + fn fail_on_child_error_switches_exit_code_for_failures_and_timeouts() { + let failure_cli = Cli { + common: CommonArgs::default(), + fail_on_child_error: true, + shell: ShellMode::Raw, + timeout: Some(Duration::from_secs(1)), + cwd: None, + tail_bytes: 32, + log_dir: None, + command: command(&["pwsh", "-NoProfile", "-Command", "Write-Error boom; exit 7"]), + }; + assert_eq!(run(&failure_cli).expect("failure"), ExitCode::RuntimeError); + + let timeout_cli = Cli { + command: command(&[ + "pwsh", + "-NoProfile", + "-Command", + "Start-Sleep -Milliseconds 200", + ]), + timeout: Some(Duration::from_millis(50)), + ..failure_cli + }; + assert_eq!(run(&timeout_cli).expect("timeout"), ExitCode::RuntimeError); + } +} diff --git a/crates/runprobe/src/main.rs b/crates/runprobe/src/main.rs new file mode 100644 index 0000000..03d8c5d --- /dev/null +++ b/crates/runprobe/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `runprobe`. + +fn main() { + std::process::exit(runprobe::main_entry()); +} diff --git a/crates/runprobe/tests/runprobe_cli.rs b/crates/runprobe/tests/runprobe_cli.rs new file mode 100644 index 0000000..0078412 --- /dev/null +++ b/crates/runprobe/tests/runprobe_cli.rs @@ -0,0 +1,148 @@ +//! Integration tests for the `runprobe` command. + +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn cargo_command() -> Command { + Command::cargo_bin("runprobe").expect("binary") +} + +#[cfg(windows)] +fn pwsh_success_args() -> Vec<&'static str> { + vec!["Write-Output", "done"] +} + +#[test] +fn help_mentions_shell_tail_and_log_dir_flags() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--shell ")) + .stdout(predicate::str::contains("--tail-bytes ")) + .stdout(predicate::str::contains("--log-dir ")); +} + +#[cfg(windows)] +#[test] +fn json_output_reports_shell_run_and_cwd() { + let temp = TempDir::new().expect("temp dir"); + let mut command = cargo_command(); + command + .arg("--json") + .arg("--shell") + .arg("pwsh") + .arg("--cwd") + .arg(temp.path()) + .arg("--") + .args(pwsh_success_args()) + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"exit_code\":0")) + .stdout(predicate::str::contains("\"timed_out\":false")) + .stdout(predicate::str::contains("\"stdout_tail\":\"done")) + .stdout(predicate::str::contains(format!( + "\"cwd\":\"{}\"", + temp.path().display().to_string().replace('\\', "\\\\") + ))); +} + +#[cfg(windows)] +#[test] +fn failure_text_includes_stdout_and_stderr_tails() { + let temp = TempDir::new().expect("temp dir"); + let script = temp.path().join("fail.ps1"); + fs::write(&script, "Write-Error 'boom'\nWrite-Output 'out'\nexit 9\n").expect("script"); + let mut command = cargo_command(); + command + .arg("--shell") + .arg("pwsh") + .arg("--tail-bytes") + .arg("32") + .arg("--") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("fail exit_code=9")) + .stdout(predicate::str::contains("stderr_tail:")) + .stdout(predicate::str::contains("boom")) + .stdout(predicate::str::contains("stdout_tail:")) + .stdout(predicate::str::contains("out")); +} + +#[cfg(windows)] +#[test] +fn timeout_json_reports_log_path_when_requested() { + let log_dir = TempDir::new().expect("log dir"); + let script_dir = TempDir::new().expect("script dir"); + let script = script_dir.path().join("slow.ps1"); + fs::write( + &script, + "Start-Sleep -Milliseconds 500\nWrite-Output 'late'\n", + ) + .expect("script"); + let mut command = cargo_command(); + let assert = command + .arg("--json") + .arg("--shell") + .arg("pwsh") + .arg("--timeout") + .arg("100ms") + .arg("--log-dir") + .arg(log_dir.path()) + .arg("--") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"timed_out\":true")) + .stdout(predicate::str::contains("\"log_path\":\"")); + + let output = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let json: serde_json::Value = serde_json::from_str(&output).expect("json"); + let log_path = json["log_path"].as_str().expect("log path"); + let log_body = fs::read_to_string(log_path).expect("log body"); + assert!(log_body.contains("[stdout]")); + assert!(log_body.contains("[stderr]")); +} + +#[cfg(windows)] +#[test] +fn raw_shell_runs_native_command_directly() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("--") + .arg("pwsh") + .arg("-NoProfile") + .arg("-Command") + .arg("Write-Output raw-ok") + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"stdout_tail\":\"raw-ok")); +} + +#[cfg(windows)] +#[test] +fn cmd_shell_runs_command_through_wrapper() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("--shell") + .arg("cmd") + .arg("--") + .arg("cmd") + .arg("/d") + .arg("/s") + .arg("/c") + .arg("echo cmd-ok") + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"stdout_tail\":\"cmd-ok")); +} diff --git a/crates/runtimekit/Cargo.toml b/crates/runtimekit/Cargo.toml new file mode 100644 index 0000000..b0e6208 --- /dev/null +++ b/crates/runtimekit/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "runtimekit" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared runtime helpers for Mercury Toolbox runtime commands." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +humantime.workspace = true +lexopt.workspace = true + +[dev-dependencies] +loom.workspace = true diff --git a/crates/runtimekit/src/lib.rs b/crates/runtimekit/src/lib.rs new file mode 100644 index 0000000..0a34672 --- /dev/null +++ b/crates/runtimekit/src/lib.rs @@ -0,0 +1,827 @@ +//! Shared runtime helpers for Mercury Toolbox runtime-focused commands. + +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io::{self, BufRead, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use common::CliError; +use lexopt::prelude::Value as ArgValue; + +const MAX_CAPTURE_STREAM_BYTES: usize = 4 * 1024 * 1024; + +/// Result details from executing a child process probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommandProbe { + /// Child process exit code when available. + pub exit_code: Option, + /// Whether the process was terminated after crossing the timeout. + pub timed_out: bool, + /// Total wall-clock runtime observed for the process. + pub duration: Duration, +} + +/// Captured output from executing a child process probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedCommand { + /// Child process exit code when available. + pub exit_code: Option, + /// Whether the process was terminated after crossing the timeout. + pub timed_out: bool, + /// Total wall-clock runtime observed for the process. + pub duration: Duration, + /// Bounded stdout bytes collected from the child process. + pub stdout: Vec, + /// Bounded stderr bytes collected from the child process. + pub stderr: Vec, +} + +/// Supported shell launch modes for runtime-oriented commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellMode { + /// Launch the program directly without a shell wrapper. + Raw, + /// Launch the command through a `PowerShell` wrapper. + Pwsh, + /// Launch the command through a cmd.exe wrapper. + Cmd, +} + +/// Reads a UTF-8 flag value from lexopt. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when a value is missing or not UTF-8 text. +pub fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_to_utf8(value, flag) +} + +/// Converts an [`OsString`] to UTF-8 text for user-facing parsing paths. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the value is not UTF-8 text. +pub fn os_to_utf8(value: OsString, context: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{context} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +/// Parses a duration flag using human-friendly units such as `250ms`, `3s`, or `2m`. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the duration string cannot be parsed. +pub fn parse_duration_flag(flag: &str, value: &str) -> Result { + humantime::parse_duration(value) + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +/// Parses an `i32` flag value. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the integer string cannot be parsed. +pub fn parse_i32_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +/// Parses a `usize` flag value. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the integer string cannot be parsed. +pub fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +/// Parses a shell mode flag value. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when the shell name is unsupported. +pub fn parse_shell_mode(flag: &str, value: &str) -> Result { + match value { + "raw" => Ok(ShellMode::Raw), + "pwsh" => Ok(ShellMode::Pwsh), + "cmd" => Ok(ShellMode::Cmd), + other => Err(CliError::usage(format!( + "invalid {flag} value '{other}'; expected raw, pwsh, or cmd" + ))), + } +} + +/// Renders a compact command line for text-mode diagnostics. +#[must_use] +pub fn render_command(command: &[OsString]) -> String { + let mut rendered = String::new(); + for item in command { + if !rendered.is_empty() { + rendered.push(' '); + } + rendered.push_str("e_for_text(&item.to_string_lossy())); + } + rendered +} + +/// Renders the trailing bytes from a captured output stream as lossy UTF-8 text. +#[must_use] +pub fn tail_bytes_to_string(bytes: &[u8], limit: usize) -> String { + let lookbehind = 64; + let start = bytes.len().saturating_sub(limit.saturating_add(lookbehind)); + let stripped = strip_ansi_sequences(&String::from_utf8_lossy(&bytes[start..])); + trim_to_tail_bytes(&stripped, limit) +} + +fn strip_ansi_sequences(text: &str) -> String { + let mut rendered = String::with_capacity(text.len()); + let mut characters = text.chars().peekable(); + while let Some(character) = characters.next() { + if character != '\u{1b}' { + rendered.push(character); + continue; + } + + match characters.peek().copied() { + Some('[') => { + characters.next(); + for next in characters.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + Some(']') => { + characters.next(); + let mut pending_escape = false; + for next in characters.by_ref() { + if pending_escape { + if next == '\\' { + break; + } + pending_escape = next == '\u{1b}'; + continue; + } + if next == '\u{7}' { + break; + } + pending_escape = next == '\u{1b}'; + } + } + _ => {} + } + } + rendered +} + +fn trim_to_tail_bytes(text: &str, limit: usize) -> String { + if text.len() <= limit { + return text.to_string(); + } + + let mut start = text.len().saturating_sub(limit); + while start < text.len() && !text.is_char_boundary(start) { + start += 1; + } + text[start..].to_string() +} + +/// Reads stdin into trimmed line records. +/// +/// # Errors +/// +/// Returns [`CliError::Runtime`] when stdin cannot be read. +pub fn read_stdin_lines() -> Result, CliError> { + io::stdin() + .lock() + .lines() + .map(|line| { + line.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}"))) + }) + .collect() +} + +/// Executes a command directly and optionally enforces a wall-clock timeout. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when no command is provided and [`CliError::Runtime`] for spawn, +/// polling, or wait failures. +pub fn run_command( + command: &[OsString], + cwd: Option<&Path>, + timeout: Option, +) -> Result { + let captured = run_command_capture_with_shell(command, ShellMode::Raw, cwd, timeout)?; + Ok(CommandProbe { + exit_code: captured.exit_code, + timed_out: captured.timed_out, + duration: captured.duration, + }) +} + +/// Executes a command with an explicit shell mode and captures stdout and stderr. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when no command is provided and [`CliError::Runtime`] for spawn, +/// wrapper creation, polling, or wait failures. +pub fn run_command_capture_with_shell( + command: &[OsString], + shell: ShellMode, + cwd: Option<&Path>, + timeout: Option, +) -> Result { + let prepared = prepare_command(command, shell)?; + let cleanup = prepared.cleanup.clone(); + let result = run_prepared_command(&prepared, cwd, timeout); + cleanup_paths(&cleanup); + result +} + +/// Consumes positional values from lexopt after an initial command token is seen. +/// +/// # Errors +/// +/// Returns [`CliError::Usage`] when non-positional tokens appear after command collection starts. +pub fn collect_command_values( + parser: &mut lexopt::Parser, + first: OsString, +) -> Result, CliError> { + let mut command = vec![first]; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + ArgValue(value) => command.push(value), + _ => { + return Err(CliError::usage( + "command arguments must appear after -- and cannot include extra flags", + )); + } + } + } + Ok(command) +} + +#[derive(Debug, Clone)] +struct PreparedCommand { + program: OsString, + args: Vec, + cleanup: Vec, +} + +fn run_prepared_command( + prepared: &PreparedCommand, + cwd: Option<&Path>, + timeout: Option, +) -> Result { + let mut builder = Command::new(&prepared.program); + builder + .args(&prepared.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(path) = cwd { + builder.current_dir(path); + } + + let mut child = builder + .spawn() + .map_err(|error| CliError::runtime(format!("failed to launch command: {error}")))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| CliError::runtime("failed to capture child stdout"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| CliError::runtime("failed to capture child stderr"))?; + let stdout_handle = thread::spawn(move || read_stream(stdout, "stdout")); + let stderr_handle = thread::spawn(move || read_stream(stderr, "stderr")); + let started = Instant::now(); + + let (status, timed_out) = if let Some(limit) = timeout { + loop { + if let Some(status) = child.try_wait().map_err(|error| { + CliError::runtime(format!("failed to poll child process: {error}")) + })? { + break (status, false); + } + if started.elapsed() >= limit { + let _ = child.kill(); + let status = child.wait().map_err(|error| { + CliError::runtime(format!("failed to wait for timed-out process: {error}")) + })?; + break (status, true); + } + thread::sleep(Duration::from_millis(10)); + } + } else { + ( + child.wait().map_err(|error| { + CliError::runtime(format!("failed to wait for command: {error}")) + })?, + false, + ) + }; + + let stdout = join_reader(stdout_handle, "stdout")?; + let stderr = join_reader(stderr_handle, "stderr")?; + Ok(CapturedCommand { + exit_code: status.code(), + timed_out, + duration: started.elapsed(), + stdout, + stderr, + }) +} + +fn prepare_command(command: &[OsString], shell: ShellMode) -> Result { + let Some(program) = command.first() else { + return Err(CliError::usage("expected a command after --")); + }; + match shell { + ShellMode::Raw => Ok(PreparedCommand { + program: program.clone(), + args: command[1..].to_vec(), + cleanup: Vec::new(), + }), + ShellMode::Cmd => { + let wrapper = unique_temp_path("mercury-shell", "cmd"); + let body = "@echo off\r\ncall %*\r\nset MERCURY_EXIT=%ERRORLEVEL%\r\nexit /b %MERCURY_EXIT%\r\n"; + write_shell_wrapper_file(&wrapper, body)?; + let mut args = vec![ + OsString::from("/d"), + OsString::from("/s"), + OsString::from("/c"), + wrapper.as_os_str().to_os_string(), + ]; + args.extend_from_slice(command); + Ok(PreparedCommand { + program: OsString::from("cmd"), + args, + cleanup: vec![wrapper], + }) + } + ShellMode::Pwsh => { + let wrapper = unique_temp_path("mercury-shell", "ps1"); + let body = "\ +$command = @($args)\r\n\ +if ($command.Length -eq 0) { exit 0 }\r\n\ +$program = [string]$command[0]\r\n\ +$childArgs = @()\r\n\ +if ($command.Length -gt 1) {\r\n\ + foreach ($item in $command[1..($command.Length - 1)]) {\r\n\ + $childArgs += [string]$item\r\n\ + }\r\n\ +}\r\n\ +try {\r\n\ + & $program @childArgs\r\n\ + $success = $?\r\n\ + if ($null -ne $LASTEXITCODE) {\r\n\ + exit $LASTEXITCODE\r\n\ + }\r\n\ + if ($success) {\r\n\ + exit 0\r\n\ + }\r\n\ + exit 1\r\n\ +} catch {\r\n\ + [Console]::Error.WriteLine($_)\r\n\ + if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {\r\n\ + exit $LASTEXITCODE\r\n\ + }\r\n\ + exit 1\r\n\ +}\r\n"; + write_shell_wrapper_file(&wrapper, body)?; + let mut args = vec![ + OsString::from("-NoProfile"), + OsString::from("-File"), + wrapper.as_os_str().to_os_string(), + ]; + args.extend_from_slice(command); + Ok(PreparedCommand { + program: OsString::from("pwsh"), + args, + cleanup: vec![wrapper], + }) + } + } +} + +fn cleanup_paths(paths: &[PathBuf]) { + for path in paths { + let _ = fs::remove_file(path); + } +} + +fn write_shell_wrapper_file(path: &Path, body: &str) -> Result<(), CliError> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| { + CliError::runtime(format!( + "refusing to replace existing shell wrapper {}: {error}", + path.display() + )) + })?; + file.write_all(body.as_bytes()).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + file.flush().map_err(|error| { + CliError::runtime(format!("failed to flush {}: {error}", path.display())) + })?; + Ok(()) +} + +fn unique_temp_path(prefix: &str, extension: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |value| value.as_nanos()); + std::env::temp_dir().join(format!("{prefix}-{unique}.{extension}")) +} + +fn join_reader( + handle: thread::JoinHandle, io::Error>>, + stream: &str, +) -> Result, CliError> { + handle + .join() + .map_err(|_| CliError::runtime(format!("{stream} capture thread panicked")))? + .map_err(|error| CliError::runtime(format!("failed to read child {stream}: {error}"))) +} + +fn read_stream(mut reader: R, stream: &str) -> Result, io::Error> +where + R: Read, +{ + read_stream_with_limit(&mut reader, stream, MAX_CAPTURE_STREAM_BYTES) +} + +fn read_stream_with_limit( + reader: &mut R, + stream: &str, + max_capture_stream_bytes: usize, +) -> Result, io::Error> +where + R: Read, +{ + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 8192]; + let mut truncated = false; + loop { + let read = reader.read(&mut chunk)?; + if read == 0 { + break; + } + let remaining = max_capture_stream_bytes.saturating_sub(buffer.len()); + if remaining > 0 { + let keep = remaining.min(read); + buffer.extend_from_slice(&chunk[..keep]); + } + if read > remaining { + truncated = true; + } + } + if truncated { + let marker = format!( + "\n[mercury: child {stream} truncated after {max_capture_stream_bytes} bytes]\n" + ); + buffer.extend_from_slice(marker.as_bytes()); + } + Ok(buffer) +} + +fn quote_for_text(value: &str) -> String { + if value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn success_command() -> Vec { + #[cfg(windows)] + { + vec![ + OsString::from("cmd"), + OsString::from("/d"), + OsString::from("/s"), + OsString::from("/c"), + OsString::from("exit 0"), + ] + } + #[cfg(not(windows))] + { + vec![ + OsString::from("sh"), + OsString::from("-c"), + OsString::from("exit 0"), + ] + } + } + + fn slow_command() -> Vec { + #[cfg(windows)] + { + vec![ + OsString::from("pwsh"), + OsString::from("-NoProfile"), + OsString::from("-Command"), + OsString::from("Start-Sleep -Milliseconds 300"), + ] + } + #[cfg(not(windows))] + { + vec![ + OsString::from("sh"), + OsString::from("-c"), + OsString::from("sleep 1"), + ] + } + } + + #[test] + fn parse_helpers_report_usage_errors() { + assert!(matches!( + parse_duration_flag("--timeout", "nope"), + Err(CliError::Usage(message)) if message.contains("invalid --timeout value") + )); + assert!(matches!( + parse_i32_flag("--expect-exit", "bad"), + Err(CliError::Usage(message)) if message.contains("invalid --expect-exit value") + )); + assert!(matches!( + parse_usize_flag("--tail-bytes", "bad"), + Err(CliError::Usage(message)) if message.contains("invalid --tail-bytes value") + )); + assert!(matches!( + parse_shell_mode("--shell", "bad"), + Err(CliError::Usage(message)) if message.contains("invalid --shell value") + )); + } + + #[test] + fn render_command_quotes_whitespace_and_empty_values() { + assert_eq!( + render_command(&[ + OsString::from("pwsh"), + OsString::from("-Command"), + OsString::from("Write-Output hi"), + OsString::from(""), + ]), + "pwsh -Command \"Write-Output hi\" \"\"" + ); + } + + #[test] + fn tail_bytes_take_only_the_suffix() { + assert_eq!(tail_bytes_to_string(b"abcdef", 3), "def"); + assert_eq!(tail_bytes_to_string(b"abc", 99), "abc"); + } + + #[test] + fn tail_bytes_strip_common_ansi_sequences() { + assert_eq!(tail_bytes_to_string(b"\x1b[31;1mboom\x1b[0m", 32), "boom"); + assert_eq!(tail_bytes_to_string(b"\x1b]0;title\x07done", 32), "done"); + let tail = tail_bytes_to_string(b"prefix\x1b[31;1mboom\x1b[0m", 6); + assert!(!tail.contains('\u{1b}')); + assert!(tail.ends_with("boom")); + } + + #[test] + fn run_command_reports_success_and_timeout() { + let success = run_command(&success_command(), None, Some(Duration::from_secs(2))) + .expect("successful probe"); + assert_eq!(success.exit_code, Some(0)); + assert!(!success.timed_out); + + let timeout = run_command(&slow_command(), None, Some(Duration::from_millis(50))) + .expect("timeout probe"); + assert!(timeout.timed_out); + } + + #[test] + fn capture_command_collects_output() { + #[cfg(windows)] + let command = vec![ + OsString::from("pwsh"), + OsString::from("-NoProfile"), + OsString::from("-Command"), + OsString::from("Write-Output 'out'; Write-Error 'err'"), + ]; + #[cfg(not(windows))] + let command = vec![ + OsString::from("sh"), + OsString::from("-c"), + OsString::from("printf out; printf err >&2"), + ]; + let captured = run_command_capture_with_shell( + &command, + ShellMode::Raw, + None, + Some(Duration::from_secs(2)), + ) + .expect("captured"); + assert!(!captured.stdout.is_empty()); + assert!(!captured.stderr.is_empty()); + } + + #[cfg(windows)] + #[test] + fn pwsh_wrapper_runs_script_files_as_argv() { + let script = unique_temp_path("runtimekit-test", "ps1"); + fs::write(&script, "Write-Output 'done'\n").expect("script"); + let captured = run_command_capture_with_shell( + &[script.as_os_str().to_os_string()], + ShellMode::Pwsh, + None, + Some(Duration::from_secs(2)), + ) + .expect("captured pwsh script file"); + let _ = fs::remove_file(script); + assert_eq!(captured.exit_code, Some(0)); + assert_eq!(String::from_utf8_lossy(&captured.stdout), "done\r\n"); + assert!(captured.stderr.is_empty()); + } + + #[test] + fn run_command_requires_non_empty_command() { + let error = run_command(&[], None, None).expect_err("missing command should fail"); + assert!(matches!( + error, + CliError::Usage(message) if message.contains("expected a command") + )); + } + + #[test] + fn collect_command_values_accepts_positionals_and_rejects_flags() { + let mut parser = lexopt::Parser::from_iter([ + OsString::from("runtimekit"), + OsString::from("tool"), + OsString::from("alpha"), + OsString::from("two words"), + ]); + let first = parser.next().expect("parser item").expect("first value"); + let collected = match first { + ArgValue(value) => collect_command_values(&mut parser, value).expect("values"), + _ => panic!("expected first positional value"), + }; + assert_eq!( + collected, + vec![ + OsString::from("tool"), + OsString::from("alpha"), + OsString::from("two words") + ] + ); + + let mut parser = lexopt::Parser::from_iter([ + OsString::from("runtimekit"), + OsString::from("tool"), + OsString::from("--bad"), + ]); + let first = parser.next().expect("parser item").expect("first value"); + let error = match first { + ArgValue(value) => collect_command_values(&mut parser, value).expect_err("flag error"), + _ => panic!("expected first positional value"), + }; + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("command arguments must appear after --") + )); + } + + #[test] + fn prepare_command_builds_shell_specific_wrappers() { + let command = vec![OsString::from("echo"), OsString::from("hello world")]; + + let raw = prepare_command(&command, ShellMode::Raw).expect("raw"); + assert_eq!(raw.program, OsString::from("echo")); + assert_eq!(raw.args, vec![OsString::from("hello world")]); + assert!(raw.cleanup.is_empty()); + + #[cfg(windows)] + { + let cmd = prepare_command(&command, ShellMode::Cmd).expect("cmd"); + assert_eq!(cmd.program, OsString::from("cmd")); + assert!(cmd.args.len() >= 5); + assert_eq!(cmd.cleanup.len(), 1); + assert_eq!( + cmd.cleanup[0].extension().and_then(std::ffi::OsStr::to_str), + Some("cmd") + ); + assert!(fs::metadata(&cmd.cleanup[0]).is_ok()); + cleanup_paths(&cmd.cleanup); + assert!(fs::metadata(&cmd.cleanup[0]).is_err()); + + let pwsh = prepare_command(&command, ShellMode::Pwsh).expect("pwsh"); + assert_eq!(pwsh.program, OsString::from("pwsh")); + assert!(pwsh.args.len() >= 4); + assert_eq!(pwsh.cleanup.len(), 1); + assert_eq!( + pwsh.cleanup[0] + .extension() + .and_then(std::ffi::OsStr::to_str), + Some("ps1") + ); + assert!(fs::metadata(&pwsh.cleanup[0]).is_ok()); + cleanup_paths(&pwsh.cleanup); + assert!(fs::metadata(&pwsh.cleanup[0]).is_err()); + } + } + + #[test] + fn shell_wrapper_writer_refuses_preexisting_paths() { + let path = unique_temp_path("runtimekit-existing-wrapper", "cmd"); + fs::write(&path, "original").expect("preexisting wrapper"); + + let error = + write_shell_wrapper_file(&path, "replacement").expect_err("preexisting path refused"); + + assert!(matches!( + error, + CliError::Runtime(message) + if message.contains("refusing to replace existing shell wrapper") + )); + assert_eq!( + fs::read_to_string(&path).expect("preserved content"), + "original" + ); + let _ = fs::remove_file(path); + } + + #[test] + fn stream_helpers_cover_success_and_failure_paths() { + let stdout = join_reader( + thread::spawn(|| Ok::, io::Error>(b"hello".to_vec())), + "stdout", + ) + .expect("stdout"); + assert_eq!(stdout, b"hello"); + + let read_error = join_reader( + thread::spawn(|| Err::, io::Error>(io::Error::other("boom"))), + "stderr", + ) + .expect_err("stderr read error"); + assert!(matches!( + read_error, + CliError::Runtime(message) + if message.contains("failed to read child stderr") + )); + + let panic_error = join_reader( + thread::spawn(|| -> Result, io::Error> { + panic!("reader panic"); + }), + "stdout", + ) + .expect_err("panic error"); + assert!(matches!( + panic_error, + CliError::Runtime(message) + if message.contains("stdout capture thread panicked") + )); + + assert_eq!( + read_stream(io::Cursor::new(b"abc".to_vec()), "stdout").expect("cursor"), + b"abc" + ); + assert_eq!(quote_for_text("two words"), "\"two words\""); + assert_eq!(quote_for_text("plain"), "plain"); + } + + #[test] + fn read_stream_caps_memory_and_drains_remaining_bytes() { + let max_capture_stream_bytes = 64; + let input = vec![b'x'; max_capture_stream_bytes + 128]; + let mut reader = io::Cursor::new(input); + let captured = read_stream_with_limit(&mut reader, "stdout", max_capture_stream_bytes) + .expect("capture"); + + assert!(captured.len() < max_capture_stream_bytes + 128); + assert!(captured.starts_with(&[b'x'; 64])); + assert!(String::from_utf8_lossy(&captured).contains("child stdout truncated")); + } +} diff --git a/crates/runtimekit/tests/loom_capture.rs b/crates/runtimekit/tests/loom_capture.rs new file mode 100644 index 0000000..291cb02 --- /dev/null +++ b/crates/runtimekit/tests/loom_capture.rs @@ -0,0 +1,57 @@ +//! Loom model for the runtime command capture join pattern. + +use loom::sync::{Arc, Mutex}; +use loom::thread; + +fn join_capture( + handle: loom::thread::JoinHandle, &'static str>>, + stream: &'static str, +) -> Result, String> { + handle + .join() + .map_err(|_| format!("{stream} capture thread panicked"))? + .map_err(|error| format!("failed to read child {stream}: {error}")) +} + +#[test] +fn capture_threads_publish_before_join_returns() { + loom::model(|| { + let events = Arc::new(Mutex::new(Vec::new())); + let stdout_events = Arc::clone(&events); + let stderr_events = Arc::clone(&events); + + let stdout = thread::spawn(move || { + let mut guard = stdout_events.lock().expect("stdout lock"); + guard.push("stdout"); + }); + let stderr = thread::spawn(move || { + let mut guard = stderr_events.lock().expect("stderr lock"); + guard.push("stderr"); + }); + + stdout.join().expect("stdout join"); + stderr.join().expect("stderr join"); + + { + let guard = events.lock().expect("events lock"); + assert_eq!(guard.len(), 2); + assert!(guard.contains(&"stdout")); + assert!(guard.contains(&"stderr")); + drop(guard); + } + }); +} + +#[test] +fn capture_join_models_success_and_read_error_paths() { + loom::model(|| { + let stdout = thread::spawn(|| Ok(vec![b'o', b'k'])); + let stderr = thread::spawn(|| Err("broken pipe")); + + assert_eq!(join_capture(stdout, "stdout").expect("stdout"), b"ok"); + assert_eq!( + join_capture(stderr, "stderr").expect_err("stderr"), + "failed to read child stderr: broken pipe" + ); + }); +} diff --git a/crates/snip/Cargo.toml b/crates/snip/Cargo.toml new file mode 100644 index 0000000..2f57abd --- /dev/null +++ b/crates/snip/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "snip" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Extract precise code and text snippets for AI-friendly terminal workflows." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/snip/src/lib.rs b/crates/snip/src/lib.rs new file mode 100644 index 0000000..14edac6 --- /dev/null +++ b/crates/snip/src/lib.rs @@ -0,0 +1,867 @@ +//! The `snip` command extracts precise snippets from files or stdin. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + parse_input_format, print_json, print_quick_help_error, print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use regex_lite::Regex; +use serde::Serialize; + +const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024; + +/// CLI arguments for the `snip` binary. +#[derive(Debug, Clone)] +pub struct Cli { + /// Shared output and stdin policy flags. + pub common: CommonArgs, + /// Extract an exact inclusive line range such as `12:20`. + pub lines: Option, + /// Extract snippets around lines matching this regex. + pub around: Option, + /// Extract the block belonging to this symbol name. + pub symbol: Option, + /// Extra context lines around `--around` and `--symbol` matches. + pub context: usize, + /// Maximum number of regex matches to emit for `--around`. + pub max_matches: usize, + /// Optional files to read when stdin is empty. + pub paths: Vec, +} + +const HELP: &str = "\ +Extract precise code and text snippets from files or stdin. + +Usage: + snip [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --lines Extract an exact inclusive line range such as 12:20 + --around Extract snippets around lines matching this regex + --symbol Extract the block belonging to this symbol name + --context Extra context lines around --around and --symbol + --max-matches Maximum number of matches to emit for --around + -h, --help Show this help text + -V, --version Show the command version + +Examples: + snip --lines 16:27 .\\fixtures\\reading\\sample.rs + snip --symbol run .\\fixtures\\reading\\sample.rs --json | ConvertFrom-Json + bat --style=plain --paging=never .\\fixtures\\reading\\sample.rs | snip --around helper --context 0 + +Notes: + for AST-backed full definitions across files or repos, prefer `defsnip` +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LineRange { + start: usize, + end: usize, +} + +#[derive(Debug)] +enum Selector { + Lines(LineRange), + Around { + pattern: String, + regex: Regex, + context: usize, + max_matches: usize, + }, + Symbol { + name: String, + context: usize, + }, +} + +#[derive(Debug)] +struct Source { + label: String, + extension: Option, + lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SnippetLine { + number: usize, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Snippet { + path: String, + start_line: usize, + end_line: usize, + reason: String, + lines: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("snip {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + lines: None, + around: None, + symbol: None, + context: 2, + max_matches: 1, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("lines") => { + cli.lines = Some(parser_value_string(&mut parser, "--lines")?); + } + Long("around") => { + cli.around = Some(parser_value_string(&mut parser, "--around")?); + } + Long("symbol") => { + cli.symbol = Some(parser_value_string(&mut parser, "--symbol")?); + } + Long("context") => { + cli.context = + parse_usize_flag("--context", &parser_value_string(&mut parser, "--context")?)?; + } + Long("max-matches") => { + cli.max_matches = parse_usize_flag( + "--max-matches", + &parser_value_string(&mut parser, "--max-matches")?, + )?; + } + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + let selector = build_selector(cli)?; + let sources = load_sources(cli)?; + let mut snippets = Vec::new(); + + for source in &sources { + snippets.extend(select_snippets(source, &selector)); + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&snippets)?, + RenderMode::Toon => print_structured(&snippets, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_text_snippets(&snippets)), + } + + Ok(if snippets.is_empty() { + ExitCode::NoResults + } else { + ExitCode::Success + }) +} + +fn build_selector(cli: &Cli) -> Result { + let selector_count = usize::from(cli.lines.is_some()) + + usize::from(cli.around.is_some()) + + usize::from(cli.symbol.is_some()); + if selector_count != 1 { + return Err(CliError::usage( + "select exactly one of --lines, --around, or --symbol", + )); + } + if cli.max_matches == 0 { + return Err(CliError::usage("--max-matches must be greater than 0")); + } + + if let Some(lines) = &cli.lines { + return Ok(Selector::Lines(parse_range(lines)?)); + } + if let Some(pattern) = &cli.around { + let regex = Regex::new(pattern) + .map_err(|error| CliError::usage(format!("invalid --around regex: {error}")))?; + return Ok(Selector::Around { + pattern: pattern.clone(), + regex, + context: cli.context, + max_matches: cli.max_matches, + }); + } + if let Some(name) = &cli.symbol { + return Ok(Selector::Symbol { + name: name.clone(), + context: cli.context, + }); + } + + Err(CliError::usage( + "select one of --lines, --around, or --symbol", + )) +} + +fn parse_range(raw: &str) -> Result { + let Some((start, end)) = raw.split_once(':') else { + let line = parse_positive(raw, "--lines")?; + return Ok(LineRange { + start: line, + end: line, + }); + }; + + let start = parse_positive(start, "--lines")?; + let end = parse_positive(end, "--lines")?; + if start > end { + return Err(CliError::usage( + "--lines start must be less than or equal to end", + )); + } + + Ok(LineRange { start, end }) +} + +fn parse_positive(raw: &str, flag: &str) -> Result { + let value = raw + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{raw}': {error}")))?; + if value == 0 { + return Err(CliError::usage(format!( + "{flag} values must be greater than 0" + ))); + } + Ok(value) +} + +fn load_sources(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .take(MAX_SOURCE_BYTES + 1) + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if buffer.len() as u64 > MAX_SOURCE_BYTES { + return Err(CliError::runtime(format!( + "refusing to read stdin larger than {MAX_SOURCE_BYTES} bytes" + ))); + } + if !buffer.is_empty() { + return Ok(vec![Source { + label: "".to_string(), + extension: None, + lines: text_lines(&buffer), + }]); + } + } + + if cli.paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe file content into stdin", + )); + } + + let mut sources = Vec::new(); + for path in &cli.paths { + let content = read_bounded_source(path)?; + sources.push(Source { + label: path.display().to_string(), + extension: extension_label(path), + lines: text_lines(&content), + }); + } + Ok(sources) +} + +fn read_bounded_source(path: &Path) -> Result { + read_bounded_source_with_limit(path, MAX_SOURCE_BYTES) +} + +fn read_bounded_source_with_limit(path: &Path, max_source_bytes: u64) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + CliError::runtime(format!("failed to inspect {}: {error}", path.display())) + })?; + if metadata.len() > max_source_bytes { + return Err(CliError::runtime(format!( + "refusing to read {} because it is {} bytes; snip source files are capped at {max_source_bytes} bytes", + path.display(), + metadata.len() + ))); + } + fs::read_to_string(path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn text_lines(content: &str) -> Vec { + content + .trim_start_matches('\u{feff}') + .lines() + .map(ToString::to_string) + .collect::>() +} + +fn extension_label(path: &Path) -> Option { + path.extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) +} + +fn select_snippets(source: &Source, selector: &Selector) -> Vec { + match selector { + Selector::Lines(range) => { + select_range_snippet(source, *range).map_or_else(Vec::new, |snippet| vec![snippet]) + } + Selector::Around { + pattern, + regex, + context, + max_matches, + } => select_around_snippets(source, pattern, regex, *context, *max_matches), + Selector::Symbol { name, context } => select_symbol_snippet(source, name, *context) + .map_or_else(Vec::new, |snippet| vec![snippet]), + } +} + +fn select_range_snippet(source: &Source, range: LineRange) -> Option { + if range.start > source.lines.len() { + return None; + } + Some(make_snippet( + source, + LineRange { + start: range.start, + end: range.end.min(source.lines.len()), + }, + "lines".to_string(), + )) +} + +fn select_around_snippets( + source: &Source, + pattern: &str, + regex: &Regex, + context: usize, + max_matches: usize, +) -> Vec { + source + .lines + .iter() + .enumerate() + .filter(|(_, line)| regex.is_match(line)) + .take(max_matches) + .map(|(index, _)| { + let line = index + 1; + make_snippet( + source, + LineRange { + start: line.saturating_sub(context).max(1), + end: (line + context).min(source.lines.len()), + }, + format!("around:{pattern}"), + ) + }) + .collect::>() +} + +fn select_symbol_snippet(source: &Source, name: &str, context: usize) -> Option { + let line = find_symbol_line(source, name)?; + let range = expand_symbol_range(&source.lines, line, context); + Some(make_snippet(source, range, format!("symbol:{name}"))) +} + +fn find_symbol_line(source: &Source, name: &str) -> Option { + let patterns = symbol_patterns(source.extension.as_deref(), name); + source.lines.iter().enumerate().find_map(|(index, line)| { + patterns + .iter() + .any(|pattern| pattern.is_match(line)) + .then_some(index + 1) + }) +} + +fn symbol_patterns(extension: Option<&str>, name: &str) -> Vec { + let escaped = regex_lite::escape(name); + let raw_patterns = match extension { + Some("rs") => vec![ + format!(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+{escaped}\b"), + format!(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|mod|type)\s+{escaped}\b"), + format!(r"^\s*impl\b.*\b{escaped}\b"), + ], + Some("cs") => vec![ + format!( + r"^\s*(?:public|private|protected|internal|static|sealed|abstract|partial|\s)+(?:class|struct|enum|interface|record)\s+{escaped}\b" + ), + format!( + r"^\s*(?:public|private|protected|internal|static|virtual|override|async|\s)+[\w<>\[\],?]+\s+{escaped}\s*\(" + ), + format!(r"^\s*namespace\s+.*\b{escaped}\b"), + ], + _ => vec![format!(r"\b{escaped}\b")], + }; + + raw_patterns + .into_iter() + .map(|pattern| Regex::new(&pattern).expect("internal symbol regex must compile")) + .collect::>() +} + +fn expand_symbol_range(lines: &[String], line: usize, context: usize) -> LineRange { + let start_index = line - 1; + find_open_brace_line(lines, start_index).map_or_else( + || LineRange { + start: line.saturating_sub(context).max(1), + end: (line + context).min(lines.len()), + }, + |open_index| LineRange { + start: line.saturating_sub(context).max(1), + end: find_block_end(lines, open_index), + }, + ) +} + +fn find_open_brace_line(lines: &[String], start_index: usize) -> Option { + let search_end = (start_index + 8).min(lines.len().saturating_sub(1)); + (start_index..=search_end).find(|index| lines[*index].contains('{')) +} + +fn find_block_end(lines: &[String], open_index: usize) -> usize { + let mut depth = 0_usize; + let mut saw_open = false; + + for (index, line) in lines.iter().enumerate().skip(open_index) { + for ch in line.chars() { + if ch == '{' { + depth += 1; + saw_open = true; + } else if ch == '}' && saw_open { + depth = depth.saturating_sub(1); + if depth == 0 { + return index + 1; + } + } + } + } + + open_index + 1 +} + +fn make_snippet(source: &Source, range: LineRange, reason: String) -> Snippet { + Snippet { + path: source.label.clone(), + start_line: range.start, + end_line: range.end, + reason, + lines: source.lines[range.start - 1..range.end] + .iter() + .enumerate() + .map(|(offset, text)| SnippetLine { + number: range.start + offset, + text: text.clone(), + }) + .collect::>(), + } +} + +fn render_text_snippets(snippets: &[Snippet]) -> String { + let mut rendered = String::new(); + + for (index, snippet) in snippets.iter().enumerate() { + if index > 0 { + rendered.push('\n'); + } + writeln!( + rendered, + "path={} lines={}:{} reason={}", + snippet.path, snippet.start_line, snippet.end_line, snippet.reason + ) + .expect("writing to a String cannot fail"); + for line in &snippet.lines { + writeln!(rendered, "{}: {}", line.number, line.text) + .expect("writing to a String cannot fail"); + } + } + + rendered +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format: common::InputFormat::Auto, + color: ColorChoice::Never, + quiet: false, + } + } + + fn source(lines: &[&str], extension: Option<&str>) -> Source { + Source { + label: "fixture".to_string(), + extension: extension.map(str::to_string), + lines: lines + .iter() + .map(|line| (*line).to_string()) + .collect::>(), + } + } + + #[test] + fn parse_range_accepts_single_lines_and_ranges() { + assert_eq!( + parse_range("4").expect("single line"), + LineRange { start: 4, end: 4 } + ); + assert_eq!( + parse_range("2:7").expect("range"), + LineRange { start: 2, end: 7 } + ); + + let error = parse_range("7:2").expect_err("reversed range should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("start must be less than or equal") + )); + } + + #[test] + fn symbol_patterns_and_block_expansion_cover_rust_and_csharp() { + let rust = source( + &[ + "pub fn run() {", + " println!(\"hi\");", + "}", + "fn helper() {}", + ], + Some("rs"), + ); + assert_eq!(find_symbol_line(&rust, "run"), Some(1)); + assert_eq!( + expand_symbol_range(&rust.lines, 1, 0), + LineRange { start: 1, end: 3 } + ); + + let csharp = source( + &[ + "public class PlayerController {", + " private int ComputeScore(int baseScore) {", + " return baseScore;", + " }", + "}", + ], + Some("cs"), + ); + assert_eq!(find_symbol_line(&csharp, "PlayerController"), Some(1)); + assert_eq!(find_symbol_line(&csharp, "ComputeScore"), Some(2)); + } + + #[test] + fn range_and_around_selection_render_compact_text() { + let source = source(&["alpha", "beta", "helper", "delta"], Some("txt")); + let range = + select_range_snippet(&source, LineRange { start: 2, end: 3 }).expect("range snippet"); + assert_eq!(range.start_line, 2); + assert_eq!(range.end_line, 3); + + let regex = Regex::new("hel.+er").expect("regex"); + let around = select_around_snippets(&source, "helper", ®ex, 1, 1); + assert_eq!(around.len(), 1); + assert_eq!(around[0].start_line, 2); + assert_eq!(around[0].end_line, 4); + + let text = render_text_snippets(&around); + assert!(text.contains("reason=around:helper")); + assert!(text.contains("3: helper")); + } + + #[test] + fn selector_builder_and_numeric_parsing_report_usage_errors() { + let error = build_selector(&Cli { + common: common_args(false), + lines: Some("1".to_string()), + around: None, + symbol: None, + context: 0, + max_matches: 0, + paths: Vec::new(), + }) + .expect_err("zero max matches should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("--max-matches must be greater than 0") + )); + + let error = build_selector(&Cli { + common: common_args(false), + lines: None, + around: Some("[".to_string()), + symbol: None, + context: 0, + max_matches: 1, + paths: Vec::new(), + }) + .expect_err("invalid regex should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("invalid --around regex") + )); + + assert!(matches!( + build_selector(&Cli { + common: common_args(false), + lines: None, + around: None, + symbol: Some("helper".to_string()), + context: 1, + max_matches: 1, + paths: Vec::new(), + }) + .expect("symbol selector"), + Selector::Symbol { .. } + )); + + let error = parse_positive("0", "--lines").expect_err("zero should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("greater than 0") + )); + + let error = build_selector(&Cli { + common: common_args(false), + lines: Some("1".to_string()), + around: Some("helper".to_string()), + symbol: None, + context: 1, + max_matches: 1, + paths: Vec::new(), + }) + .expect_err("multiple selectors should fail"); + assert!(matches!( + error, + CliError::Usage(message) + if message.contains("select exactly one") + )); + } + + #[test] + fn source_loading_and_range_selection_cover_file_and_error_paths() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.rs"); + fs::write(&path, "pub fn run() {}\n").expect("fixture"); + + let loaded = load_sources(&Cli { + common: common_args(false), + lines: Some("1".to_string()), + around: None, + symbol: None, + context: 0, + max_matches: 1, + paths: vec![path], + }) + .expect("sources"); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].extension.as_deref(), Some("rs")); + + let missing = load_sources(&Cli { + common: common_args(false), + lines: Some("1".to_string()), + around: None, + symbol: None, + context: 0, + max_matches: 1, + paths: Vec::new(), + }) + .expect_err("missing input should fail"); + assert!(matches!( + missing, + CliError::Usage(message) + if message.contains("provide at least one path") + )); + + let source = source(&["alpha"], Some("txt")); + assert!(select_range_snippet(&source, LineRange { start: 9, end: 9 }).is_none()); + } + + #[test] + fn source_loading_rejects_large_files() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("large.txt"); + fs::write(&path, vec![b'a'; 129]).expect("large file"); + + let error = + read_bounded_source_with_limit(&path, 128).expect_err("large input should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("source files are capped") + )); + } + + #[test] + fn symbol_lookup_handles_generic_matches_and_braceless_fallbacks() { + let generic = source(&["alpha helper beta", "omega"], None); + let snippet = select_symbol_snippet(&generic, "helper", 1).expect("generic symbol"); + assert_eq!(snippet.start_line, 1); + assert_eq!(snippet.end_line, 2); + + let lines = vec!["fn demo()".to_string(), "value".to_string()]; + assert_eq!( + expand_symbol_range(&lines, 1, 1), + LineRange { start: 1, end: 2 } + ); + + let unmatched = vec!["fn demo() {".to_string(), "value".to_string()]; + assert_eq!(find_block_end(&unmatched, 0), 1); + assert!(select_symbol_snippet(&generic, "missing", 0).is_none()); + } + + #[test] + fn symbol_lookup_handles_multiline_rust_signatures_before_open_brace() { + let source = source( + &[ + "fn parse_cli_from(", + " args: I,", + ") -> Result<(), CliError>", + "where", + " I: IntoIterator,", + " T: Into,", + "{", + " Ok(())", + "}", + ], + Some("rs"), + ); + let snippet = select_symbol_snippet(&source, "parse_cli_from", 0).expect("symbol"); + assert_eq!(snippet.start_line, 1); + assert_eq!(snippet.end_line, 9); + } + + #[test] + fn run_maps_success_and_no_results_for_text_and_json_modes() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.txt"); + fs::write(&path, "alpha\nbeta\nhelper\n").expect("fixture"); + + let success = run(&Cli { + common: common_args(true), + lines: None, + around: Some("helper".to_string()), + symbol: None, + context: 0, + max_matches: 1, + paths: vec![path.clone()], + }) + .expect("json run"); + assert_eq!(success, ExitCode::Success); + + let no_results = run(&Cli { + common: common_args(false), + lines: None, + around: Some("missing".to_string()), + symbol: None, + context: 0, + max_matches: 1, + paths: vec![path], + }) + .expect("text run"); + assert_eq!(no_results, ExitCode::NoResults); + } +} diff --git a/crates/snip/src/main.rs b/crates/snip/src/main.rs new file mode 100644 index 0000000..b5dc0b5 --- /dev/null +++ b/crates/snip/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `snip`. + +fn main() { + std::process::exit(snip::main_entry()); +} diff --git a/crates/snip/tests/snip_cli.rs b/crates/snip/tests/snip_cli.rs new file mode 100644 index 0000000..524297f --- /dev/null +++ b/crates/snip/tests/snip_cli.rs @@ -0,0 +1,159 @@ +//! Integration tests for the `snip` command. + +use std::fs; +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::{TempDir, tempdir}; + +const SAMPLE_RS: &str = "reading/sample.rs"; + +fn cargo_command() -> Command { + Command::cargo_bin("snip").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path) +} + +fn temp_file(name: &str, contents: impl AsRef<[u8]>) -> (TempDir, PathBuf) { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join(name); + fs::write(&path, contents).expect("fixture"); + (dir, path) +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +fn ps_quote(value: impl std::fmt::Display) -> String { + format!("'{}'", value.to_string().replace('\'', "''")) +} + +#[test] +fn extracts_requested_line_ranges() { + let mut command = cargo_command(); + command + .arg("--lines") + .arg("18:31") + .arg(fixture(SAMPLE_RS)) + .assert() + .success() + .stdout(predicate::str::contains("reason=lines")) + .stdout(predicate::str::contains("18: pub fn run")) + .stdout(predicate::str::contains("31: }")); +} + +#[test] +fn extracts_symbol_blocks() { + let mut command = cargo_command(); + command + .arg("--symbol") + .arg("ComputeScore") + .arg(fixture("reading/sample.cs")) + .assert() + .success() + .stdout(predicate::str::contains("reason=symbol:ComputeScore")) + .stdout(predicate::str::contains( + "9: private int ComputeScore", + )) + .stdout(predicate::str::contains("15: }")); +} + +#[test] +fn extracts_regex_matches_with_context_as_json() { + let mut command = cargo_command(); + command + .arg("--around") + .arg("Mode::Fast") + .arg("--context") + .arg("1") + .arg("--json") + .arg(fixture("reading/sample.rs")) + .assert() + .success() + .stdout(predicate::str::contains("\"reason\":\"around:Mode::Fast\"")) + .stdout(predicate::str::contains("\"start_line\":21")) + .stdout(predicate::str::contains("\"end_line\":23")); +} + +#[test] +fn supports_stdin_content_snipping() { + let binary = assert_cmd::cargo::cargo_bin("snip"); + let input = fixture(SAMPLE_RS); + let script = format!( + "[System.IO.File]::ReadLines({}) | & {} --around 'helper' --context 0", + ps_quote(input.display()), + ps_quote(binary.display()) + ); + + let mut command = pwsh_command(script); + command + .assert() + .success() + .stdout(predicate::str::contains("")) + .stdout(predicate::str::contains("fn helper")); +} + +#[test] +fn utf8_bom_stdin_does_not_pollute_first_snippet_line() { + let mut command = cargo_command(); + command + .args(["--lines", "1:1"]) + .write_stdin("\u{feff}fn main() {}\n") + .assert() + .success() + .stdout(predicate::str::contains("1: fn main() {}")); +} + +#[test] +fn utf8_bom_file_does_not_pollute_first_snippet_line() { + let (_dir, path) = temp_file("bom.rs", "\u{feff}fn main() {}\n"); + + let mut command = cargo_command(); + command + .arg("--lines") + .arg("1:1") + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("1: fn main() {}")); +} + +#[test] +fn invalid_utf8_file_reports_read_error() { + let (_dir, path) = temp_file("invalid-utf8.rs", [0x66, 0x6E, 0x80, 0x0A]); + + let mut command = cargo_command(); + command + .arg("--lines") + .arg("1:1") + .arg(&path) + .assert() + .failure() + .stderr(predicate::str::contains("failed to read")) + .stderr(predicate::str::contains("invalid-utf8.rs")); +} + +#[test] +fn help_includes_selector_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--lines")) + .stdout(predicate::str::contains("--symbol")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} diff --git a/crates/sqliteshape/Cargo.toml b/crates/sqliteshape/Cargo.toml new file mode 100644 index 0000000..9f967d7 --- /dev/null +++ b/crates/sqliteshape/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sqliteshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect SQLite schema and table shapes with compact CLI output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +rusqlite = { version = "0.37.0", default-features = false, features = ["bundled"] } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/sqliteshape/src/lib.rs b/crates/sqliteshape/src/lib.rs new file mode 100644 index 0000000..353b4aa --- /dev/null +++ b/crates/sqliteshape/src/lib.rs @@ -0,0 +1,1590 @@ +#![allow(clippy::multiple_crate_versions)] +//! The `sqliteshape` command inspects `SQLite` databases. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::time::Duration; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_error, print_json, print_quick_help_error, + print_structured, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; + +const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_millis(100); +const SQLITE_EXPRESSION_INDEX_COLUMN: &str = ""; + +const HELP: &str = "\ +Inspect SQLite schema and table stats without leaving the terminal. + +Usage: + sqliteshape [OPTIONS] [PATH...] + sqliteshape [OPTIONS] diff + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --table Limit output to one or more table names + --sample-rows Maximum rows to sample per table + --include-indexes Include index metadata in the summary + --count-rows Use exact COUNT(*) for each table instead of estimates/unknown + -h, --help Show this help text + -V, --version Show the command version + +Examples: + sqliteshape .\\fixtures\\sqliteshape\\sample.db + 'C:\\data\\events.db' | sqliteshape --input-format lines --json | ConvertFrom-Json + sqliteshape .\\fixtures\\sqliteshape\\sample.db --table users --include-indexes + sqliteshape diff before.db after.db --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: CommandMode, + tables: Vec, + sample_rows: usize, + include_indexes: bool, + count_rows: bool, + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandMode { + Summary, + Diff { before: PathBuf, after: PathBuf }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct DatabaseSummary { + path: String, + page_size: i64, + page_count: i64, + journal_mode: String, + table_count: usize, + requested_tables: Vec, + unmatched_tables: Vec, + tables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TableSelection { + matched: Vec, + unmatched: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct TableSummary { + name: String, + row_count: Option, + row_count_mode: String, + columns: Vec, + sample_rows: Vec, + indexes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ColumnSummary { + name: String, + declared_type: String, + not_null: bool, + primary_key_position: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct IndexSummary { + name: String, + unique: bool, + columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct DatabaseDiff { + path_before: String, + path_after: String, + page_size_before: i64, + page_size_after: i64, + page_count_before: i64, + page_count_after: i64, + journal_mode_before: String, + journal_mode_after: String, + added_tables: Vec, + removed_tables: Vec, + changed_tables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct TableDiff { + name: String, + row_count_before: Option, + row_count_after: Option, + row_count_mode_before: String, + row_count_mode_after: String, + added_columns: Vec, + removed_columns: Vec, + changed_columns: Vec, + added_indexes: Vec, + removed_indexes: Vec, +} + +const ROW_COUNT_MODE_EXACT: &str = "exact"; +const ROW_COUNT_MODE_ESTIMATED: &str = "estimated"; +const ROW_COUNT_MODE_UNKNOWN: &str = "unknown"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ColumnChange { + name: String, + declared_type_before: String, + declared_type_after: String, + not_null_before: bool, + not_null_after: bool, + primary_key_position_before: i64, + primary_key_position_after: i64, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("sqliteshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + match error { + CliError::Usage(_) => print_quick_help_error(&error, HELP), + CliError::Runtime(_) => print_error(&error), + } + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 3, + include_indexes: false, + count_rows: false, + paths: Vec::new(), + }; + let mut diff_paths = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("table") => cli + .tables + .push(parser_value_string(&mut parser, "--table")?), + Long("sample-rows") => { + cli.sample_rows = parse_usize_flag( + "--sample-rows", + &parser_value_string(&mut parser, "--sample-rows")?, + )?; + } + Long("include-indexes") => cli.include_indexes = true, + Long("count-rows") => cli.count_rows = true, + ArgValue(path) => { + if matches!(cli.command, CommandMode::Summary) + && cli.paths.is_empty() + && diff_paths.is_empty() + && path == "diff" + { + cli.command = CommandMode::Diff { + before: PathBuf::new(), + after: PathBuf::new(), + }; + } else if matches!(cli.command, CommandMode::Diff { .. }) { + diff_paths.push(PathBuf::from(path)); + } else { + cli.paths.push(PathBuf::from(path)); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + if matches!(cli.command, CommandMode::Diff { .. }) { + if diff_paths.len() != 2 { + return Err(CliError::usage("diff expects exactly two SQLite paths")); + } + cli.command = CommandMode::Diff { + before: diff_paths[0].clone(), + after: diff_paths[1].clone(), + }; + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + if cli.sample_rows == 0 { + return Err(CliError::usage("--sample-rows must be greater than 0")); + } + + match &cli.command { + CommandMode::Summary => { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one SQLite path or pipe paths into stdin", + )); + } + + let mut summaries = paths + .iter() + .map(|path| inspect_database(path, cli)) + .collect::, _>>()?; + + let summary_count = summaries.len(); + let matched_table_count = summaries + .iter() + .map(|summary| summary.table_count) + .sum::(); + + match cli.common.render_mode() { + RenderMode::Json => { + if summaries.len() == 1 { + print_json(&summaries.remove(0))?; + } else { + print_json(&summaries)?; + } + } + RenderMode::Toon => { + if summaries.len() == 1 { + print_structured(&summaries.remove(0), RenderMode::Toon)?; + } else { + print_structured(&summaries, RenderMode::Toon)?; + } + } + RenderMode::Text => { + for summary in &summaries { + print!("{}", render_summary(summary)); + } + } + } + + if !cli.tables.is_empty() && matched_table_count == 0 { + Ok(ExitCode::NoResults) + } else { + Ok(map_result_count(summary_count)) + } + } + CommandMode::Diff { before, after } => { + let before_path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(before), "sqliteshape")?, + "sqliteshape diff before", + )?; + let after_path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(after), "sqliteshape")?, + "sqliteshape diff after", + )?; + let before_summary = inspect_database(&before_path, cli)?; + let after_summary = inspect_database(&after_path, cli)?; + let diff = diff_databases(&before_summary, &after_summary); + + match cli.common.render_mode() { + RenderMode::Json => print_json(&diff)?, + RenderMode::Toon => print_structured(&diff, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_diff(&diff)), + } + + Ok(map_result_count( + diff.added_tables.len() + diff.removed_tables.len() + diff.changed_tables.len(), + )) + } + } +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let parsed = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !parsed.is_empty() { + return Ok(parsed); + } + } + + common::expand_input_patterns(&cli.paths, "sqliteshape") +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "sqliteshape")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn inspect_database(path: &PathBuf, cli: &Cli) -> Result { + if !path.exists() { + return Err(CliError::runtime(format!( + "database path does not exist: {}", + path.display() + ))); + } + + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| CliError::runtime(format!("failed to open {}: {error}", path.display())))?; + connection + .busy_timeout(SQLITE_BUSY_TIMEOUT) + .map_err(|error| { + CliError::runtime(format!( + "failed to configure sqlite busy timeout for {}: {error}", + path.display() + )) + })?; + + let page_size = pragma_i64(&connection, "page_size")?; + let page_count = pragma_i64(&connection, "page_count")?; + let journal_mode = pragma_string(&connection, "journal_mode")?; + + let table_selection = load_table_selection(&connection, &cli.tables)?; + let tables = table_selection + .matched + .iter() + .map(|name| inspect_table(&connection, name, cli)) + .collect::, _>>()?; + + Ok(DatabaseSummary { + path: path.display().to_string(), + page_size, + page_count, + journal_mode, + table_count: tables.len(), + requested_tables: cli.tables.clone(), + unmatched_tables: table_selection.unmatched, + tables, + }) +} + +fn pragma_i64(connection: &Connection, pragma: &str) -> Result { + connection + .query_row(&format!("PRAGMA {pragma};"), [], |row| row.get::<_, i64>(0)) + .map_err(|error| CliError::runtime(format!("failed to read PRAGMA {pragma}: {error}"))) +} + +fn pragma_string(connection: &Connection, pragma: &str) -> Result { + connection + .query_row(&format!("PRAGMA {pragma};"), [], |row| { + row.get::<_, String>(0) + }) + .map_err(|error| CliError::runtime(format!("failed to read PRAGMA {pragma}: {error}"))) +} + +fn load_table_selection( + connection: &Connection, + filter: &[String], +) -> Result { + let mut statement = connection + .prepare( + "SELECT name FROM sqlite_master \ + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \ + ORDER BY name", + ) + .map_err(|error| CliError::runtime(format!("failed to list tables: {error}")))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| CliError::runtime(format!("failed to query tables: {error}")))?; + + let mut names = Vec::new(); + for row in rows { + names.push( + row.map_err(|error| CliError::runtime(format!("failed to read table name: {error}")))?, + ); + } + if filter.is_empty() { + return Ok(TableSelection { + matched: names, + unmatched: Vec::new(), + }); + } + + let filtered = names + .iter() + .filter(|name| filter.iter().any(|wanted| wanted == *name)) + .cloned() + .collect::>(); + let unmatched = filter + .iter() + .filter(|wanted| !names.iter().any(|name| name == *wanted)) + .cloned() + .collect::>(); + Ok(TableSelection { + matched: filtered, + unmatched, + }) +} + +fn inspect_table( + connection: &Connection, + table: &str, + cli: &Cli, +) -> Result { + let columns = load_columns(connection, table)?; + let sample_rows = load_sample_rows(connection, table, cli.sample_rows)?; + let (row_count, row_count_mode) = if cli.count_rows { + ( + Some(count_rows(connection, table)?), + ROW_COUNT_MODE_EXACT.to_string(), + ) + } else { + let estimated = estimate_row_count(connection, table)?; + ( + estimated, + if estimated.is_some() { + ROW_COUNT_MODE_ESTIMATED.to_string() + } else { + ROW_COUNT_MODE_UNKNOWN.to_string() + }, + ) + }; + let indexes = if cli.include_indexes { + load_indexes(connection, table)? + } else { + Vec::new() + }; + + Ok(TableSummary { + name: table.to_string(), + row_count, + row_count_mode, + columns, + sample_rows, + indexes, + }) +} + +fn load_columns(connection: &Connection, table: &str) -> Result, CliError> { + let escaped = escape_identifier(table); + let sql = format!("PRAGMA table_info({escaped});"); + let mut statement = connection.prepare(&sql).map_err(|error| { + CliError::runtime(format!("failed to inspect columns for {table}: {error}")) + })?; + let rows = statement + .query_map([], |row| { + Ok(ColumnSummary { + name: row.get(1)?, + declared_type: row.get::<_, Option>(2)?.unwrap_or_default(), + not_null: row.get::<_, i64>(3)? != 0, + primary_key_position: row.get(5)?, + }) + }) + .map_err(|error| { + CliError::runtime(format!("failed to query columns for {table}: {error}")) + })?; + + let mut columns = Vec::new(); + for row in rows { + columns.push(row.map_err(|error| { + CliError::runtime(format!( + "failed to read column metadata for {table}: {error}" + )) + })?); + } + Ok(columns) +} + +fn load_sample_rows( + connection: &Connection, + table: &str, + sample_rows: usize, +) -> Result, CliError> { + let escaped = escape_identifier(table); + let sql = format!("SELECT * FROM {escaped} LIMIT {sample_rows}"); + let mut statement = connection.prepare(&sql).map_err(|error| { + CliError::runtime(format!("failed to sample rows for {table}: {error}")) + })?; + let names = statement + .column_names() + .iter() + .map(ToString::to_string) + .collect::>(); + + let rows = statement + .query_map([], |row| { + let mut object = serde_json::Map::new(); + for (index, name) in names.iter().enumerate() { + let value = row.get_ref(index)?; + object.insert(name.clone(), sqlite_value_to_json(value)); + } + Ok(serde_json::Value::Object(object)) + }) + .map_err(|error| { + CliError::runtime(format!("failed to read sample rows for {table}: {error}")) + })?; + + let mut samples = Vec::new(); + for row in rows { + samples.push(row.map_err(|error| { + CliError::runtime(format!("failed to decode sample row for {table}: {error}")) + })?); + } + Ok(samples) +} + +fn count_rows(connection: &Connection, table: &str) -> Result { + let escaped = escape_identifier(table); + connection + .query_row(&format!("SELECT COUNT(*) FROM {escaped}"), [], |row| { + row.get(0) + }) + .map_err(|error| CliError::runtime(format!("failed to count rows for {table}: {error}"))) +} + +fn estimate_row_count(connection: &Connection, table: &str) -> Result, CliError> { + let mut statement = + match connection.prepare("SELECT stat FROM sqlite_stat1 WHERE tbl = ?1 LIMIT 1") { + Ok(statement) => statement, + Err(rusqlite::Error::SqliteFailure(_, Some(message))) + if message.contains("no such table: sqlite_stat1") => + { + return Ok(None); + } + Err(error) => { + return Err(CliError::runtime(format!( + "failed to inspect sqlite_stat1: {error}" + ))); + } + }; + let result = statement.query_row([table], |row| row.get::<_, String>(0)); + match result { + Ok(stat) => Ok(stat + .split_whitespace() + .next() + .and_then(|part| part.parse::().ok())), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(rusqlite::Error::SqliteFailure(_, Some(message))) + if message.contains("no such table: sqlite_stat1") => + { + Ok(None) + } + Err(error) => Err(CliError::runtime(format!( + "failed to estimate rows for {table}: {error}" + ))), + } +} + +fn load_indexes(connection: &Connection, table: &str) -> Result, CliError> { + let escaped = escape_identifier(table); + let sql = format!("PRAGMA index_list({escaped});"); + let mut statement = connection.prepare(&sql).map_err(|error| { + CliError::runtime(format!("failed to inspect indexes for {table}: {error}")) + })?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0)) + }) + .map_err(|error| { + CliError::runtime(format!("failed to query indexes for {table}: {error}")) + })?; + + let mut indexes = Vec::new(); + for row in rows { + let (name, unique) = row.map_err(|error| { + CliError::runtime(format!( + "failed to read index metadata for {table}: {error}" + )) + })?; + indexes.push(IndexSummary { + columns: load_index_columns(connection, &name)?, + name, + unique, + }); + } + Ok(indexes) +} + +fn load_index_columns(connection: &Connection, index: &str) -> Result, CliError> { + let escaped = escape_identifier(index); + let sql = format!("PRAGMA index_info({escaped});"); + let mut statement = connection + .prepare(&sql) + .map_err(|error| CliError::runtime(format!("failed to inspect index {index}: {error}")))?; + let rows = statement + .query_map([], |row| { + let position = row.get::<_, i64>(0)?; + let name = row.get::<_, Option>(2)?; + Ok(( + position, + name.unwrap_or_else(|| SQLITE_EXPRESSION_INDEX_COLUMN.to_string()), + )) + }) + .map_err(|error| { + CliError::runtime(format!( + "failed to query index columns for {index}: {error}" + )) + })?; + + let mut columns = Vec::new(); + for row in rows { + columns.push(row.map_err(|error| { + CliError::runtime(format!("failed to read index column for {index}: {error}")) + })?); + } + columns.sort_by_key(|(position, _)| *position); + Ok(columns.into_iter().map(|(_, name)| name).collect()) +} + +fn escape_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn sqlite_value_to_json(value: rusqlite::types::ValueRef<'_>) -> serde_json::Value { + match value { + rusqlite::types::ValueRef::Null => serde_json::Value::Null, + rusqlite::types::ValueRef::Integer(number) => serde_json::Value::Number(number.into()), + rusqlite::types::ValueRef::Real(number) => serde_json::Number::from_f64(number) + .map_or(serde_json::Value::Null, serde_json::Value::Number), + rusqlite::types::ValueRef::Text(text) => { + serde_json::Value::String(String::from_utf8_lossy(text).to_string()) + } + rusqlite::types::ValueRef::Blob(blob) => { + serde_json::Value::String(format!("", blob.len())) + } + } +} + +fn render_summary(summary: &DatabaseSummary) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "path={} page_size={} page_count={} journal_mode={} tables={}", + summary.path, + summary.page_size, + summary.page_count, + summary.journal_mode, + summary.table_count + ) + .expect("writing to a String cannot fail"); + if !summary.requested_tables.is_empty() { + writeln!( + rendered, + "requested_tables={} unmatched_tables={}", + summary.requested_tables.join("|"), + summary.unmatched_tables.join("|") + ) + .expect("writing to a String cannot fail"); + } + for table in &summary.tables { + writeln!( + rendered, + "table={} rows={} columns={} sample_rows={} indexes={}", + table.name, + render_row_count(table.row_count, &table.row_count_mode), + table.columns.len(), + table.sample_rows.len(), + table.indexes.len() + ) + .expect("writing to a String cannot fail"); + for column in &table.columns { + writeln!( + rendered, + "column={} declared_type={} not_null={} pk={}", + column.name, column.declared_type, column.not_null, column.primary_key_position + ) + .expect("writing to a String cannot fail"); + } + } + rendered +} + +fn render_row_count(row_count: Option, row_count_mode: &str) -> String { + match (row_count_mode, row_count) { + (ROW_COUNT_MODE_EXACT, Some(count)) => format!("exact:{count}"), + (ROW_COUNT_MODE_ESTIMATED, Some(count)) => format!("estimate:{count}"), + _ => ROW_COUNT_MODE_UNKNOWN.to_string(), + } +} + +fn diff_databases(before: &DatabaseSummary, after: &DatabaseSummary) -> DatabaseDiff { + let before_tables = before + .tables + .iter() + .map(|table| (table.name.clone(), table)) + .collect::>(); + let after_tables = after + .tables + .iter() + .map(|table| (table.name.clone(), table)) + .collect::>(); + + let mut added_tables = Vec::new(); + let mut removed_tables = Vec::new(); + let mut changed_tables = Vec::new(); + + for name in before_tables.keys().chain(after_tables.keys()) { + match (before_tables.get(name), after_tables.get(name)) { + (None, Some(table)) => added_tables.push((*table).clone()), + (Some(table), None) => removed_tables.push((*table).clone()), + (Some(before_table), Some(after_table)) if before_table != after_table => { + changed_tables.push(diff_tables(before_table, after_table)); + } + _ => {} + } + } + + dedup_tables(&mut added_tables); + dedup_tables(&mut removed_tables); + dedup_table_diffs(&mut changed_tables); + + DatabaseDiff { + path_before: before.path.clone(), + path_after: after.path.clone(), + page_size_before: before.page_size, + page_size_after: after.page_size, + page_count_before: before.page_count, + page_count_after: after.page_count, + journal_mode_before: before.journal_mode.clone(), + journal_mode_after: after.journal_mode.clone(), + added_tables, + removed_tables, + changed_tables, + } +} + +fn diff_tables(before: &TableSummary, after: &TableSummary) -> TableDiff { + let before_columns = before + .columns + .iter() + .map(|column| (column.name.clone(), column)) + .collect::>(); + let after_columns = after + .columns + .iter() + .map(|column| (column.name.clone(), column)) + .collect::>(); + let before_indexes = before + .indexes + .iter() + .map(|index| (index.name.clone(), index)) + .collect::>(); + let after_indexes = after + .indexes + .iter() + .map(|index| (index.name.clone(), index)) + .collect::>(); + + let mut added_columns = Vec::new(); + let mut removed_columns = Vec::new(); + let mut changed_columns = Vec::new(); + let mut added_indexes = Vec::new(); + let mut removed_indexes = Vec::new(); + + for name in before_columns.keys().chain(after_columns.keys()) { + match (before_columns.get(name), after_columns.get(name)) { + (None, Some(column)) => added_columns.push((*column).clone()), + (Some(column), None) => removed_columns.push((*column).clone()), + (Some(before_column), Some(after_column)) if before_column != after_column => { + changed_columns.push(ColumnChange { + name: name.clone(), + declared_type_before: before_column.declared_type.clone(), + declared_type_after: after_column.declared_type.clone(), + not_null_before: before_column.not_null, + not_null_after: after_column.not_null, + primary_key_position_before: before_column.primary_key_position, + primary_key_position_after: after_column.primary_key_position, + }); + } + _ => {} + } + } + + for name in before_indexes.keys().chain(after_indexes.keys()) { + match (before_indexes.get(name), after_indexes.get(name)) { + (None, Some(index)) => added_indexes.push((*index).clone()), + (Some(index), None) => removed_indexes.push((*index).clone()), + _ => {} + } + } + + dedup_columns(&mut added_columns); + dedup_columns(&mut removed_columns); + dedup_column_changes(&mut changed_columns); + dedup_indexes(&mut added_indexes); + dedup_indexes(&mut removed_indexes); + + TableDiff { + name: before.name.clone(), + row_count_before: before.row_count, + row_count_after: after.row_count, + row_count_mode_before: before.row_count_mode.clone(), + row_count_mode_after: after.row_count_mode.clone(), + added_columns, + removed_columns, + changed_columns, + added_indexes, + removed_indexes, + } +} + +fn dedup_tables(tables: &mut Vec) { + let mut seen = std::collections::BTreeSet::::new(); + tables.retain(|table| seen.insert(table.name.clone())); +} + +fn dedup_table_diffs(tables: &mut Vec) { + let mut seen = std::collections::BTreeSet::::new(); + tables.retain(|table| seen.insert(table.name.clone())); +} + +fn dedup_columns(columns: &mut Vec) { + let mut seen = std::collections::BTreeSet::::new(); + columns.retain(|column| seen.insert(column.name.clone())); +} + +fn dedup_column_changes(columns: &mut Vec) { + let mut seen = std::collections::BTreeSet::::new(); + columns.retain(|column| seen.insert(column.name.clone())); +} + +fn dedup_indexes(indexes: &mut Vec) { + let mut seen = std::collections::BTreeSet::::new(); + indexes.retain(|index| seen.insert(index.name.clone())); +} + +fn render_diff(diff: &DatabaseDiff) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "before={} after={} page_size={}=>{} page_count={}=>{} journal_mode={}=>{} added_tables={} removed_tables={} changed_tables={}", + diff.path_before, + diff.path_after, + diff.page_size_before, + diff.page_size_after, + diff.page_count_before, + diff.page_count_after, + diff.journal_mode_before, + diff.journal_mode_after, + diff.added_tables.len(), + diff.removed_tables.len(), + diff.changed_tables.len() + ) + .expect("writing to a String cannot fail"); + for table in &diff.added_tables { + writeln!( + rendered, + "table={} change=added rows={} columns={} indexes={}", + table.name, + render_row_count(table.row_count, &table.row_count_mode), + table.columns.len(), + table.indexes.len() + ) + .expect("writing to a String cannot fail"); + } + for table in &diff.removed_tables { + writeln!( + rendered, + "table={} change=removed rows={} columns={} indexes={}", + table.name, + render_row_count(table.row_count, &table.row_count_mode), + table.columns.len(), + table.indexes.len() + ) + .expect("writing to a String cannot fail"); + } + for table in &diff.changed_tables { + writeln!( + rendered, + "table={} change=changed rows={}=>{} added_columns={} removed_columns={} changed_columns={} added_indexes={} removed_indexes={}", + table.name, + render_row_count(table.row_count_before, &table.row_count_mode_before), + render_row_count(table.row_count_after, &table.row_count_mode_after), + table.added_columns.len(), + table.removed_columns.len(), + table.changed_columns.len(), + table.added_indexes.len(), + table.removed_indexes.len() + ) + .expect("writing to a String cannot fail"); + } + rendered +} + +#[cfg(test)] +mod tests { + use std::fmt::Write as _; + use std::fs; + use std::time::Instant; + + use common::{ColorChoice, InputFormat}; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn path_parsing_supports_lines_jsonl_and_auto() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("one.db"); + let second = temp.path().join("two.db"); + fs::write(&first, "one").expect("first"); + fs::write(&second, "two").expect("second"); + + assert!(matches!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("json path"), + serde_json::to_string(&second.display().to_string()).expect("json path"), + ), + InputFormat::Jsonl, + ), + Ok(paths) if paths == vec![first.clone(), second.clone()] + )); + assert_eq!( + parse_paths_from_string( + &format!("{}\n{}\n", first.display(), second.display()), + InputFormat::Auto, + ) + .expect("paths"), + vec![first, second] + ); + } + + #[test] + fn inspect_database_reads_schema_and_samples() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); + CREATE UNIQUE INDEX idx_users_name ON users(name); + INSERT INTO users(name, city) VALUES ('Ada', 'London'), ('Bob', NULL);", + ) + .expect("schema"); + drop(connection); + + let summary = inspect_database( + &path, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 2, + include_indexes: true, + count_rows: true, + paths: Vec::new(), + }, + ) + .expect("summary"); + + assert_eq!(summary.table_count, 1); + assert_eq!(summary.tables.len(), 1); + assert_eq!(summary.tables[0].name, "users"); + assert_eq!(summary.tables[0].row_count, Some(2)); + assert_eq!(summary.tables[0].row_count_mode, ROW_COUNT_MODE_EXACT); + assert_eq!(summary.tables[0].columns[1].declared_type, "TEXT"); + assert_eq!(summary.tables[0].indexes[0].name, "idx_users_name"); + assert_eq!(summary.tables[0].sample_rows.len(), 2); + } + + #[test] + fn inspect_database_handles_many_tables_without_sampling_or_counts() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("many_tables.db"); + let connection = Connection::open(&path).expect("db"); + let mut schema = String::from("BEGIN;\n"); + for index in 0..96 { + writeln!( + schema, + "CREATE TABLE t_{index:03}(id INTEGER PRIMARY KEY, c1 TEXT, c2 INTEGER, c3 REAL, c4 BLOB, c5 TEXT);" + ) + .expect("write schema"); + } + schema.push_str("COMMIT;\n"); + connection.execute_batch(&schema).expect("schema"); + drop(connection); + + let summary = inspect_database( + &path, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 0, + include_indexes: false, + count_rows: false, + paths: Vec::new(), + }, + ) + .expect("summary"); + + assert_eq!(summary.table_count, 96); + assert_eq!(summary.tables.len(), 96); + assert_eq!(summary.tables[0].name, "t_000"); + assert_eq!(summary.tables[95].name, "t_095"); + assert_eq!(summary.tables[0].columns.len(), 6); + assert!( + summary + .tables + .iter() + .all(|table| table.sample_rows.is_empty()) + ); + assert!(summary.tables.iter().all(|table| table.indexes.is_empty())); + } + + #[test] + fn corrupt_sqlite_file_reports_database_parse_error() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("corrupt.db"); + fs::write(&path, b"not a sqlite database").expect("corrupt fixture"); + + let error = inspect_database( + &path, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 1, + include_indexes: false, + count_rows: false, + paths: Vec::new(), + }, + ) + .expect_err("corrupt sqlite should fail"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("file is not a database") || message.contains("database disk image is malformed")), + "unexpected corrupt database error: {error}" + ); + } + + #[test] + fn malicious_sqlite_catalog_null_table_name_fails_closed() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("malicious_catalog.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY); + PRAGMA writable_schema = ON; + UPDATE sqlite_schema SET name = NULL WHERE type = 'table' AND name = 'users'; + PRAGMA writable_schema = OFF;", + ) + .expect("malicious catalog"); + drop(connection); + + let error = inspect_database( + &path, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 1, + include_indexes: true, + count_rows: false, + paths: Vec::new(), + }, + ) + .expect_err("malicious sqlite catalog should fail closed"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("failed to read table name") || message.contains("malformed")), + "unexpected malicious catalog error: {error}" + ); + } + + #[test] + fn malicious_sqlite_catalog_invalid_table_sql_fails_closed() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("malicious_catalog_invalid_sql.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY); + PRAGMA writable_schema = ON; + UPDATE sqlite_schema + SET sql = 'CREATE TABLE users(' + WHERE type = 'table' AND name = 'users'; + PRAGMA writable_schema = OFF;", + ) + .expect("malicious catalog"); + drop(connection); + + let error = inspect_database( + &path, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 1, + include_indexes: true, + count_rows: false, + paths: Vec::new(), + }, + ) + .expect_err("malicious sqlite catalog should fail closed"); + + let message = error.to_string(); + assert!( + message.contains("malformed") + || message.contains("failed to list tables") + || message.contains("failed to inspect columns"), + "unexpected malicious catalog error: {error}" + ); + assert!( + message.len() < 512, + "unbounded sqlite catalog error: {error}" + ); + } + + #[test] + fn locked_sqlite_file_reports_busy_without_long_wait() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("locked.db"); + let writer = Connection::open(&path).expect("sqlite"); + writer + .execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY); BEGIN EXCLUSIVE;") + .expect("exclusive transaction"); + + let started = Instant::now(); + let error = inspect_database( + &path, + &Cli { + common: common_args(true, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 1, + include_indexes: false, + count_rows: false, + paths: Vec::new(), + }, + ) + .expect_err("locked sqlite should fail"); + + assert!( + started.elapsed().as_secs_f32() < 2.0, + "locked sqlite inspection waited too long" + ); + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("locked") || message.contains("busy")), + "unexpected locked database error: {error}" + ); + } + + #[test] + fn render_summary_is_compact() { + let text = render_summary(&DatabaseSummary { + path: "sample.db".to_string(), + page_size: 4096, + page_count: 2, + journal_mode: "delete".to_string(), + table_count: 1, + requested_tables: Vec::new(), + unmatched_tables: Vec::new(), + tables: vec![TableSummary { + name: "users".to_string(), + row_count: Some(2), + row_count_mode: ROW_COUNT_MODE_EXACT.to_string(), + columns: vec![ColumnSummary { + name: "id".to_string(), + declared_type: "INTEGER".to_string(), + not_null: false, + primary_key_position: 1, + }], + sample_rows: vec![], + indexes: vec![], + }], + }); + + assert!(text.contains("path=sample.db")); + assert!(text.contains("table=users rows=exact:2")); + assert!(text.contains("column=id declared_type=INTEGER")); + } + + #[test] + fn parse_cli_and_helpers_cover_indexes_counts_and_identifiers() { + let (outcome, _) = parse_cli_from(["sqliteshape", "--help"]).expect("help"); + assert_eq!(outcome, ParseOutcome::Help); + + let (_, cli) = parse_cli_from([ + "sqliteshape", + "--json", + "--table", + "users", + "--sample-rows", + "5", + "--include-indexes", + "--count-rows", + "sample.db", + ]) + .expect("cli"); + assert!(cli.common.json); + assert_eq!(cli.tables, vec!["users".to_string()]); + assert_eq!(cli.sample_rows, 5); + assert!(cli.include_indexes); + assert!(cli.count_rows); + assert_eq!(escape_identifier("a\"b"), "\"a\"\"b\""); + } + + #[test] + fn sqlite_helpers_cover_value_and_estimate_paths() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("estimate.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE items(id INTEGER PRIMARY KEY, score REAL, payload BLOB); + INSERT INTO items(score, payload) VALUES (1.5, x'0102'); + ANALYZE;", + ) + .expect("schema"); + + assert_eq!(count_rows(&connection, "items").expect("count"), 1); + assert!( + estimate_row_count(&connection, "items") + .expect("estimate") + .is_some() + ); + assert_eq!( + sqlite_value_to_json(rusqlite::types::ValueRef::Blob(&[1, 2])), + serde_json::Value::String("".to_string()) + ); + assert_eq!( + sqlite_value_to_json(rusqlite::types::ValueRef::Integer(7)), + serde_json::Value::Number(7.into()) + ); + assert_eq!( + sqlite_value_to_json(rusqlite::types::ValueRef::Null), + serde_json::Value::Null + ); + } + + #[test] + fn parse_cli_supports_diff_subcommand() { + let (_, cli) = parse_cli_from([ + "sqliteshape", + "--json", + "diff", + "--include-indexes", + "before.db", + "after.db", + ]) + .expect("cli"); + + assert!(cli.common.json); + assert!(cli.include_indexes); + assert_eq!( + cli.command, + CommandMode::Diff { + before: PathBuf::from("before.db"), + after: PathBuf::from("after.db"), + } + ); + } + + #[test] + fn diff_summary_reports_table_and_column_changes() { + let before = DatabaseSummary { + path: "before.db".to_string(), + page_size: 4096, + page_count: 2, + journal_mode: "delete".to_string(), + table_count: 1, + requested_tables: Vec::new(), + unmatched_tables: Vec::new(), + tables: vec![TableSummary { + name: "users".to_string(), + row_count: Some(2), + row_count_mode: ROW_COUNT_MODE_EXACT.to_string(), + columns: vec![ColumnSummary { + name: "name".to_string(), + declared_type: "TEXT".to_string(), + not_null: true, + primary_key_position: 0, + }], + sample_rows: vec![], + indexes: vec![], + }], + }; + let after = DatabaseSummary { + path: "after.db".to_string(), + page_size: 4096, + page_count: 3, + journal_mode: "wal".to_string(), + table_count: 2, + requested_tables: Vec::new(), + unmatched_tables: Vec::new(), + tables: vec![ + TableSummary { + name: "users".to_string(), + row_count: Some(3), + row_count_mode: ROW_COUNT_MODE_EXACT.to_string(), + columns: vec![ + ColumnSummary { + name: "name".to_string(), + declared_type: "TEXT".to_string(), + not_null: false, + primary_key_position: 0, + }, + ColumnSummary { + name: "city".to_string(), + declared_type: "TEXT".to_string(), + not_null: false, + primary_key_position: 0, + }, + ], + sample_rows: vec![], + indexes: vec![], + }, + TableSummary { + name: "events".to_string(), + row_count: Some(1), + row_count_mode: ROW_COUNT_MODE_EXACT.to_string(), + columns: vec![], + sample_rows: vec![], + indexes: vec![], + }, + ], + }; + + let diff = diff_databases(&before, &after); + + assert_eq!(diff.added_tables.len(), 1); + assert_eq!(diff.added_tables[0].name, "events"); + assert_eq!(diff.changed_tables.len(), 1); + assert_eq!(diff.changed_tables[0].name, "users"); + assert_eq!(diff.changed_tables[0].added_columns.len(), 1); + assert_eq!(diff.changed_tables[0].added_columns[0].name, "city"); + assert_eq!(diff.changed_tables[0].changed_columns.len(), 1); + assert_eq!(diff.changed_tables[0].changed_columns[0].name, "name"); + assert_eq!(diff.journal_mode_before, "delete"); + assert_eq!(diff.journal_mode_after, "wal"); + } + + #[test] + fn schema_helpers_cover_filters_indexes_and_rendering() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("helpers.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); + CREATE INDEX idx_users_city ON users(city); + CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT); + INSERT INTO users(name, city) VALUES ('Ada', 'London');", + ) + .expect("schema"); + + assert_eq!( + load_table_selection(&connection, &[]) + .expect("tables") + .matched, + vec!["events".to_string(), "users".to_string()] + ); + assert_eq!( + load_table_selection(&connection, &[String::from("users")]) + .expect("filtered") + .matched, + vec!["users".to_string()] + ); + + let columns = load_columns(&connection, "users").expect("columns"); + assert_eq!(columns.len(), 3); + assert!(columns.iter().any(|column| column.name == "city")); + + let indexes = load_indexes(&connection, "users").expect("indexes"); + assert_eq!(indexes.len(), 1); + assert_eq!(indexes[0].name, "idx_users_city"); + assert_eq!(indexes[0].columns, vec!["city".to_string()]); + + let table = inspect_table( + &connection, + "users", + &Cli { + common: common_args(false, InputFormat::Auto), + command: CommandMode::Summary, + tables: Vec::new(), + sample_rows: 1, + include_indexes: true, + count_rows: true, + paths: Vec::new(), + }, + ) + .expect("table"); + assert_eq!(table.row_count, Some(1)); + assert_eq!(table.sample_rows.len(), 1); + assert_eq!(table.indexes.len(), 1); + } + + #[test] + fn table_selection_tracks_unmatched_requested_tables() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("helpers.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);") + .expect("schema"); + let selection = load_table_selection( + &connection, + &[String::from("users"), String::from("missing")], + ) + .expect("selection"); + assert_eq!(selection.matched, vec!["users".to_string()]); + assert_eq!(selection.unmatched, vec!["missing".to_string()]); + } + + #[test] + fn diff_and_dedup_helpers_cover_removed_indexes_and_render_output() { + let before = TableSummary { + name: "users".to_string(), + row_count: Some(2), + row_count_mode: ROW_COUNT_MODE_EXACT.to_string(), + columns: vec![ + ColumnSummary { + name: "id".to_string(), + declared_type: "INTEGER".to_string(), + not_null: false, + primary_key_position: 1, + }, + ColumnSummary { + name: "name".to_string(), + declared_type: "TEXT".to_string(), + not_null: true, + primary_key_position: 0, + }, + ], + sample_rows: vec![], + indexes: vec![IndexSummary { + name: "idx_users_name".to_string(), + unique: true, + columns: vec!["name".to_string()], + }], + }; + let after = TableSummary { + name: "users".to_string(), + row_count: Some(2), + row_count_mode: ROW_COUNT_MODE_ESTIMATED.to_string(), + columns: vec![ColumnSummary { + name: "id".to_string(), + declared_type: "INTEGER".to_string(), + not_null: false, + primary_key_position: 1, + }], + sample_rows: vec![], + indexes: vec![], + }; + + let diff = diff_tables(&before, &after); + assert_eq!(diff.removed_columns.len(), 1); + assert_eq!(diff.removed_columns[0].name, "name"); + assert_eq!(diff.removed_indexes.len(), 1); + assert_eq!(diff.removed_indexes[0].name, "idx_users_name"); + + let mut duplicate_tables = vec![before.clone(), before]; + dedup_tables(&mut duplicate_tables); + assert_eq!(duplicate_tables.len(), 1); + + let mut duplicate_diffs = vec![diff.clone(), diff]; + dedup_table_diffs(&mut duplicate_diffs); + assert_eq!(duplicate_diffs.len(), 1); + + let rendered = render_diff(&DatabaseDiff { + path_before: "before.db".to_string(), + path_after: "after.db".to_string(), + page_size_before: 4096, + page_size_after: 4096, + page_count_before: 1, + page_count_after: 2, + journal_mode_before: "delete".to_string(), + journal_mode_after: "wal".to_string(), + added_tables: vec![], + removed_tables: vec![], + changed_tables: duplicate_diffs, + }); + assert!(rendered.contains("before=before.db")); + assert!(rendered.contains("table=users change=changed")); + } +} diff --git a/crates/sqliteshape/src/main.rs b/crates/sqliteshape/src/main.rs new file mode 100644 index 0000000..8bdc577 --- /dev/null +++ b/crates/sqliteshape/src/main.rs @@ -0,0 +1,6 @@ +#![allow(clippy::multiple_crate_versions)] +//! Binary entry point for `sqliteshape`. + +fn main() { + std::process::exit(sqliteshape::main_entry()); +} diff --git a/crates/sqliteshape/tests/sqliteshape_cli.rs b/crates/sqliteshape/tests/sqliteshape_cli.rs new file mode 100644 index 0000000..59b21a9 --- /dev/null +++ b/crates/sqliteshape/tests/sqliteshape_cli.rs @@ -0,0 +1,183 @@ +//! Integration tests for the `sqliteshape` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use rusqlite::Connection; +use serde_json::Value; +use std::fmt::Write as _; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("sqliteshape").expect("binary") +} + +#[test] +fn summarizes_sqlite_as_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); + INSERT INTO users(name, city) VALUES ('Ada', 'London'), ('Bob', NULL);", + ) + .expect("schema"); + drop(connection); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("\"tables\"")) + .stdout(predicate::str::contains("\"name\":\"users\"")) + .stdout(predicate::str::contains("\"declared_type\":\"TEXT\"")); +} + +#[test] +fn help_includes_sqlite_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "sqliteshape [OPTIONS] diff ", + )) + .stdout(predicate::str::contains("--table")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains("sqliteshape")); +} + +#[test] +fn diff_subcommand_reports_table_and_column_changes_as_json() { + let temp = tempdir().expect("tempdir"); + let before_path = temp.path().join("before.db"); + let after_path = temp.path().join("after.db"); + + let before = Connection::open(&before_path).expect("before db"); + before + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL); + INSERT INTO users(name) VALUES ('Ada');", + ) + .expect("before schema"); + drop(before); + + let after = Connection::open(&after_path).expect("after db"); + after + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); + CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT); + INSERT INTO users(name, city) VALUES ('Ada', 'London'), ('Bob', NULL); + CREATE INDEX idx_users_city ON users(city);", + ) + .expect("after schema"); + drop(after); + + let mut command = cargo_command(); + command + .arg("--json") + .arg("diff") + .arg("--include-indexes") + .arg(&before_path) + .arg(&after_path) + .assert() + .success() + .stdout(predicate::str::contains("\"path_before\"")) + .stdout(predicate::str::contains("\"path_after\"")) + .stdout(predicate::str::contains("\"added_tables\"")) + .stdout(predicate::str::contains("\"name\":\"events\"")) + .stdout(predicate::str::contains("\"changed_tables\"")) + .stdout(predicate::str::contains("\"name\":\"users\"")) + .stdout(predicate::str::contains("\"added_columns\"")) + .stdout(predicate::str::contains("\"city\"")) + .stdout(predicate::str::contains("\"added_indexes\"")) + .stdout(predicate::str::contains("\"idx_users_city\"")); +} + +#[test] +fn wide_table_with_expression_index_summarizes_without_index_column_decode_failure() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("wide_expression.db"); + let connection = Connection::open(&path).expect("db"); + let mut schema = + String::from("CREATE TABLE documents(id INTEGER PRIMARY KEY, title TEXT NOT NULL"); + for index in 0..96 { + write!(schema, ", c_{index:03} TEXT").expect("schema write"); + } + schema.push_str(");\n"); + schema.push_str( + "CREATE INDEX idx_documents_lower_title ON documents(lower(title)) WHERE title IS NOT NULL;", + ); + connection.execute_batch(&schema).expect("schema"); + drop(connection); + + let output = Command::cargo_bin("sqliteshape") + .expect("binary") + .args(["--json", "--include-indexes"]) + .arg(&path) + .output() + .expect("run sqliteshape"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "sqliteshape failed with {}:\n{stdout}\n{stderr}", + output.status + ); + let report: Value = serde_json::from_str(&stdout).expect("json report"); + let table = &report["tables"][0]; + + assert_eq!(table["name"], "documents"); + assert_eq!(table["columns"].as_array().expect("columns").len(), 98); + assert_eq!( + table["indexes"][0]["columns"].as_array().expect("columns")[0], + "" + ); +} + +#[test] +fn malicious_catalog_invalid_table_sql_exits_with_bounded_diagnostic() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("malicious_catalog_invalid_sql.db"); + let connection = Connection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY); + PRAGMA writable_schema = ON; + UPDATE sqlite_schema + SET sql = 'CREATE TABLE users(' + WHERE type = 'table' AND name = 'users'; + PRAGMA writable_schema = OFF;", + ) + .expect("malicious catalog"); + drop(connection); + + let output = Command::cargo_bin("sqliteshape") + .expect("binary") + .args(["--json", "--include-indexes"]) + .arg(&path) + .output() + .expect("run sqliteshape"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !output.status.success(), + "sqliteshape unexpectedly accepted malicious catalog:\n{stdout}\n{stderr}" + ); + assert!(stdout.trim().is_empty(), "unexpected stdout: {stdout}"); + assert!( + stderr.contains("malformed") + || stderr.contains("failed to list tables") + || stderr.contains("failed to inspect columns"), + "unexpected malicious catalog diagnostic:\n{stderr}" + ); + assert!( + !stderr.contains("panicked") && !stderr.contains("backtrace"), + "diagnostic should be fail-closed, not a panic:\n{stderr}" + ); + assert!(stderr.len() < 512, "unbounded diagnostic:\n{stderr}"); +} diff --git a/crates/sqlshape/Cargo.toml b/crates/sqlshape/Cargo.toml new file mode 100644 index 0000000..9d0d145 --- /dev/null +++ b/crates/sqlshape/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "sqlshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect multi-database SQL schema shapes with compact CLI output." +keywords.workspace = true +categories.workspace = true +build = "build.rs" + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +duckdb.workspace = true +lexopt.workspace = true +mysql.workspace = true +native-tls.workspace = true +postgres.workspace = true +postgres-native-tls.workspace = true +rusqlite = { version = "0.37.0", default-features = false, features = ["bundled"] } +serde.workspace = true +serde_json.workspace = true +tiberius.workspace = true +tokio.workspace = true +tokio-util.workspace = true +url.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/sqlshape/build.rs b/crates/sqlshape/build.rs new file mode 100644 index 0000000..9632583 --- /dev/null +++ b/crates/sqlshape/build.rs @@ -0,0 +1,10 @@ +//! Build script for `sqlshape`. + +fn main() { + let target_family = std::env::var("CARGO_CFG_TARGET_FAMILY").unwrap_or_default(); + let target = std::env::var("TARGET").unwrap_or_default(); + if target_family.split(',').any(|family| family == "windows") || target.contains("windows") { + println!("cargo:rustc-link-lib=Rstrtmgr"); + println!("cargo:rustc-link-arg-tests=Rstrtmgr.lib"); + } +} diff --git a/crates/sqlshape/src/adapters.rs b/crates/sqlshape/src/adapters.rs new file mode 100644 index 0000000..ea0a477 --- /dev/null +++ b/crates/sqlshape/src/adapters.rs @@ -0,0 +1,1767 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use common::CliError; + +use crate::model::{ + ColumnSummary, ConnectionSource, Engine, ForeignKeySummary, IndexSummary, InspectOptions, + SqlObjectKind, SqlObjectSummary, SqlShapeReport, +}; + +const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_millis(100); +const SQLITE_EXPRESSION_INDEX_COLUMN: &str = ""; + +pub(crate) fn inspect( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + match source.engine { + Engine::Sqlite => inspect_sqlite(source, options), + Engine::DuckDb => inspect_duckdb(source, options), + Engine::Postgres => inspect_postgres(source, options), + Engine::MySql | Engine::MariaDb => inspect_mysql(source, options), + Engine::SqlServer => inspect_sqlserver(source, options), + } +} + +fn report( + source: &ConnectionSource, + database: String, + mut tables: Vec, + warnings: Vec, +) -> SqlShapeReport { + tables.sort_by(|left, right| { + left.schema + .cmp(&right.schema) + .then_with(|| left.name.cmp(&right.name)) + }); + let schemas = tables + .iter() + .map(|table| table.schema.clone()) + .collect::>() + .into_iter() + .collect(); + SqlShapeReport { + engine: source.engine, + database, + source_redacted: source.source_redacted.clone(), + schemas, + tables, + warnings, + } +} + +fn inspect_sqlite( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + let path = local_database_path(&source.raw_url)?; + let connection = rusqlite::Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| { + CliError::runtime(format!( + "failed to open sqlite source {}: {error}", + source.source_redacted + )) + })?; + connection + .busy_timeout(options.connect_timeout.min(SQLITE_BUSY_TIMEOUT)) + .map_err(|error| { + CliError::runtime(format!( + "failed to configure sqlite busy timeout for {}: {error}", + source.source_redacted + )) + })?; + let mut statement = connection + .prepare( + "SELECT name, type FROM sqlite_master \ + WHERE type IN ('table', 'view') \ + ORDER BY name", + ) + .map_err(|error| CliError::runtime(format!("failed to query sqlite schema: {error}")))?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| CliError::runtime(format!("failed to query sqlite schema: {error}")))?; + + let mut tables = Vec::new(); + for row in rows { + let (name, kind) = row.map_err(|error| { + CliError::runtime(format!("failed to read sqlite schema row: {error}")) + })?; + if !options.include_system && name.starts_with("sqlite_") { + continue; + } + if !options.accepts_schema("main") || !options.accepts_table("main", &name) { + continue; + } + tables.push(SqlObjectSummary { + schema: "main".to_string(), + name: name.clone(), + kind: object_kind(&kind), + row_estimate: None, + columns: sqlite_columns(&connection, &name)?, + primary_key: sqlite_primary_key(&connection, &name)?, + foreign_keys: sqlite_foreign_keys(&connection, &name)?, + indexes: sqlite_indexes(&connection, &name)?, + }); + } + + Ok(report( + source, + database_name_from_path(&path), + tables, + Vec::new(), + )) +} + +fn sqlite_columns( + connection: &rusqlite::Connection, + table: &str, +) -> Result, CliError> { + let sql = format!("PRAGMA table_info({})", quote_sqlite_identifier(table)); + let mut statement = connection + .prepare(&sql) + .map_err(|error| CliError::runtime(format!("failed to query sqlite columns: {error}")))?; + let rows = statement + .query_map([], |row| { + let cid = row.get::<_, i64>(0)?; + let name = row.get::<_, String>(1)?; + let data_type = row.get::<_, String>(2)?; + let not_null = row.get::<_, i64>(3)? != 0; + let default = row.get::<_, Option>(4)?; + let pk = row.get::<_, i64>(5)?; + Ok(ColumnSummary { + name, + data_type, + nullable: !not_null && pk == 0, + default, + ordinal_position: cid + 1, + }) + }) + .map_err(|error| CliError::runtime(format!("failed to query sqlite columns: {error}")))?; + collect_sqlite_rows(rows, "sqlite columns") +} + +fn sqlite_primary_key( + connection: &rusqlite::Connection, + table: &str, +) -> Result, CliError> { + let sql = format!("PRAGMA table_info({})", quote_sqlite_identifier(table)); + let mut statement = connection.prepare(&sql).map_err(|error| { + CliError::runtime(format!("failed to query sqlite primary key: {error}")) + })?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?)) + }) + .map_err(|error| { + CliError::runtime(format!("failed to query sqlite primary key: {error}")) + })?; + let mut pk = rows + .filter_map(|row| match row { + Ok((name, position)) if position > 0 => Some(Ok((position, name))), + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::, _>>() + .map_err(|error| { + CliError::runtime(format!("failed to read sqlite primary key: {error}")) + })?; + if pk.is_empty() { + return Ok(Vec::new()); + } + pk.sort_by_key(|(position, _)| *position); + Ok(pk.into_iter().map(|(_, name)| name).collect()) +} + +fn sqlite_indexes( + connection: &rusqlite::Connection, + table: &str, +) -> Result, CliError> { + let sql = format!("PRAGMA index_list({})", quote_sqlite_identifier(table)); + let mut statement = connection + .prepare(&sql) + .map_err(|error| CliError::runtime(format!("failed to query sqlite indexes: {error}")))?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0)) + }) + .map_err(|error| CliError::runtime(format!("failed to query sqlite indexes: {error}")))?; + let mut indexes = Vec::new(); + for row in rows { + let (name, unique) = row + .map_err(|error| CliError::runtime(format!("failed to read sqlite index: {error}")))?; + indexes.push(IndexSummary { + columns: sqlite_index_columns(connection, &name)?, + name, + unique, + }); + } + indexes.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(indexes) +} + +fn sqlite_index_columns( + connection: &rusqlite::Connection, + index: &str, +) -> Result, CliError> { + let sql = format!("PRAGMA index_info({})", quote_sqlite_identifier(index)); + let mut statement = connection.prepare(&sql).map_err(|error| { + CliError::runtime(format!("failed to query sqlite index columns: {error}")) + })?; + let rows = statement + .query_map([], |row| { + let position = row.get::<_, i64>(0)?; + let name = row.get::<_, Option>(2)?; + Ok(( + position, + name.unwrap_or_else(|| SQLITE_EXPRESSION_INDEX_COLUMN.to_string()), + )) + }) + .map_err(|error| { + CliError::runtime(format!("failed to query sqlite index columns: {error}")) + })?; + let mut columns = collect_sqlite_rows(rows, "sqlite index columns")?; + columns.sort_by_key(|(position, _)| *position); + Ok(columns.into_iter().map(|(_, column)| column).collect()) +} + +fn sqlite_foreign_keys( + connection: &rusqlite::Connection, + table: &str, +) -> Result, CliError> { + let sql = format!( + "PRAGMA foreign_key_list({})", + quote_sqlite_identifier(table) + ); + let mut statement = connection.prepare(&sql).map_err(|error| { + CliError::runtime(format!("failed to query sqlite foreign keys: {error}")) + })?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(|error| { + CliError::runtime(format!("failed to query sqlite foreign keys: {error}")) + })?; + let mut grouped = BTreeMap::)>::new(); + for row in rows { + let (id, seq, referenced_table, from, to) = row.map_err(|error| { + CliError::runtime(format!("failed to read sqlite foreign key: {error}")) + })?; + grouped + .entry(id) + .or_insert_with(|| (referenced_table, Vec::new())) + .1 + .push((seq, from, to)); + } + Ok(grouped + .into_iter() + .map(|(id, (referenced_table, mut columns))| { + columns.sort_by_key(|(seq, _, _)| *seq); + ForeignKeySummary { + name: format!("fk_{table}_{id}"), + columns: columns.iter().map(|(_, from, _)| from.clone()).collect(), + referenced_schema: Some("main".to_string()), + referenced_table, + referenced_columns: columns.into_iter().map(|(_, _, to)| to).collect(), + } + }) + .collect()) +} + +fn collect_sqlite_rows( + rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result>, + context: &str, +) -> Result, CliError> { + rows.collect::, _>>() + .map_err(|error| CliError::runtime(format!("failed to read {context}: {error}"))) +} + +fn inspect_duckdb( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + let path = local_database_path(&source.raw_url)?; + let connection = duckdb::Connection::open(&path).map_err(|error| { + CliError::runtime(format!( + "failed to open duckdb source {}: {error}", + source.source_redacted + )) + })?; + let mut tables = duckdb_tables(&connection, options)?; + for table in &mut tables { + table.columns = duckdb_columns(&connection, &table.schema, &table.name)?; + table.primary_key = + duckdb_primary_key(&connection, &table.schema, &table.name).unwrap_or_default(); + table.indexes = duckdb_indexes(&connection, &table.schema, &table.name).unwrap_or_default(); + } + Ok(report( + source, + database_name_from_path(&path), + tables, + Vec::new(), + )) +} + +fn duckdb_tables( + connection: &duckdb::Connection, + options: &InspectOptions, +) -> Result, CliError> { + let mut statement = connection + .prepare( + "SELECT table_schema, table_name, table_type \ + FROM information_schema.tables \ + ORDER BY table_schema, table_name", + ) + .map_err(|error| CliError::runtime(format!("failed to query duckdb tables: {error}")))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|error| CliError::runtime(format!("failed to query duckdb tables: {error}")))?; + let mut tables = Vec::new(); + for row in rows { + let (schema, name, table_type) = row + .map_err(|error| CliError::runtime(format!("failed to read duckdb table: {error}")))?; + if !options.include_system && is_system_schema(Engine::DuckDb, &schema) { + continue; + } + if !options.accepts_schema(&schema) || !options.accepts_table(&schema, &name) { + continue; + } + tables.push(SqlObjectSummary { + schema, + name, + kind: object_kind(&table_type), + row_estimate: None, + columns: Vec::new(), + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + }); + } + Ok(tables) +} + +fn duckdb_columns( + connection: &duckdb::Connection, + schema: &str, + table: &str, +) -> Result, CliError> { + let sql = format!( + "SELECT column_name, data_type, is_nullable, column_default, ordinal_position \ + FROM information_schema.columns \ + WHERE table_schema = {} AND table_name = {} \ + ORDER BY ordinal_position", + sql_string(schema), + sql_string(table) + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| CliError::runtime(format!("failed to query duckdb columns: {error}")))?; + let rows = statement + .query_map([], |row| { + Ok(ColumnSummary { + name: row.get::<_, String>(0)?, + data_type: row.get::<_, String>(1)?, + nullable: row.get::<_, String>(2)?.eq_ignore_ascii_case("YES"), + default: row.get::<_, Option>(3)?, + ordinal_position: row.get::<_, i64>(4)?, + }) + }) + .map_err(|error| CliError::runtime(format!("failed to query duckdb columns: {error}")))?; + rows.collect::, _>>() + .map_err(|error| CliError::runtime(format!("failed to read duckdb columns: {error}"))) +} + +fn duckdb_primary_key( + connection: &duckdb::Connection, + schema: &str, + table: &str, +) -> Result, CliError> { + let sql = format!( + "SELECT kcu.column_name \ + FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage kcu \ + ON tc.constraint_name = kcu.constraint_name \ + AND tc.table_schema = kcu.table_schema \ + AND tc.table_name = kcu.table_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' \ + AND tc.table_schema = {} AND tc.table_name = {} \ + ORDER BY kcu.ordinal_position", + sql_string(schema), + sql_string(table) + ); + query_duckdb_strings(connection, &sql) +} + +fn duckdb_indexes( + connection: &duckdb::Connection, + schema: &str, + table: &str, +) -> Result, CliError> { + let sql = format!( + "SELECT index_name, is_unique, expressions \ + FROM duckdb_indexes() \ + WHERE schema_name = {} AND table_name = {} \ + ORDER BY index_name", + sql_string(schema), + sql_string(table) + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| CliError::runtime(format!("failed to query duckdb indexes: {error}")))?; + let rows = statement + .query_map([], |row| { + Ok(IndexSummary { + name: row.get::<_, String>(0)?, + unique: row.get::<_, bool>(1)?, + columns: split_csv_like(&row.get::<_, String>(2)?), + }) + }) + .map_err(|error| CliError::runtime(format!("failed to query duckdb indexes: {error}")))?; + rows.collect::, _>>() + .map_err(|error| CliError::runtime(format!("failed to read duckdb indexes: {error}"))) +} + +fn query_duckdb_strings( + connection: &duckdb::Connection, + sql: &str, +) -> Result, CliError> { + let mut statement = connection + .prepare(sql) + .map_err(|error| CliError::runtime(format!("failed to prepare duckdb query: {error}")))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| CliError::runtime(format!("failed to query duckdb values: {error}")))?; + rows.collect::, _>>() + .map_err(|error| CliError::runtime(format!("failed to read duckdb values: {error}"))) +} + +fn inspect_postgres( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + let connector = native_tls::TlsConnector::builder() + .build() + .map_err(|error| CliError::runtime(format!("failed to initialize native TLS: {error}")))?; + let tls = postgres_native_tls::MakeTlsConnector::new(connector); + let mut client = postgres::Client::connect(&source.raw_url, tls).map_err(|error| { + CliError::runtime(format!( + "failed to connect to postgres source {}: {error}", + source.source_redacted + )) + })?; + let database = client + .query_one("SELECT current_database()", &[]) + .map_err(|error| CliError::runtime(format!("failed to query postgres database: {error}")))? + .get::<_, String>(0); + let mut tables = postgres_tables(&mut client, options)?; + attach_postgres_columns(&mut client, &mut tables)?; + attach_postgres_constraints(&mut client, &mut tables)?; + attach_postgres_indexes(&mut client, &mut tables)?; + Ok(report(source, database, tables, Vec::new())) +} + +fn postgres_tables( + client: &mut postgres::Client, + options: &InspectOptions, +) -> Result, CliError> { + let rows = client + .query( + "SELECT t.table_schema, t.table_name, t.table_type, c.reltuples::bigint \ + FROM information_schema.tables t \ + LEFT JOIN pg_namespace n ON n.nspname = t.table_schema \ + LEFT JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.table_name \ + ORDER BY t.table_schema, t.table_name", + &[], + ) + .map_err(|error| CliError::runtime(format!("failed to query postgres tables: {error}")))?; + Ok(rows + .into_iter() + .filter_map(|row| { + let schema = row.get::<_, String>(0); + let name = row.get::<_, String>(1); + if (!options.include_system && is_system_schema(Engine::Postgres, &schema)) + || !options.accepts_schema(&schema) + || !options.accepts_table(&schema, &name) + { + return None; + } + Some(SqlObjectSummary { + schema, + name, + kind: object_kind(&row.get::<_, String>(2)), + row_estimate: row.get::<_, Option>(3), + columns: Vec::new(), + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + }) + }) + .collect()) +} + +fn attach_postgres_columns( + client: &mut postgres::Client, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + let by_id = table_index(tables); + let rows = client + .query( + "SELECT table_schema, table_name, column_name, data_type, is_nullable, column_default, ordinal_position::bigint \ + FROM information_schema.columns \ + ORDER BY table_schema, table_name, ordinal_position", + &[], + ) + .map_err(|error| CliError::runtime(format!("failed to query postgres columns: {error}")))?; + for row in rows { + let schema = row.get::<_, String>(0); + let table = row.get::<_, String>(1); + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index].columns.push(ColumnSummary { + name: row.get::<_, String>(2), + data_type: row.get::<_, String>(3), + nullable: row.get::<_, String>(4).eq_ignore_ascii_case("YES"), + default: row.get::<_, Option>(5), + ordinal_position: row.get::<_, i64>(6), + }); + } + } + Ok(()) +} + +fn attach_postgres_constraints( + client: &mut postgres::Client, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + let by_id = table_index(tables); + let pk_rows = client + .query( + "SELECT kcu.table_schema, kcu.table_name, kcu.column_name \ + FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage kcu \ + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' \ + ORDER BY kcu.table_schema, kcu.table_name, kcu.ordinal_position", + &[], + ) + .map_err(|error| CliError::runtime(format!("failed to query postgres primary keys: {error}")))?; + for row in pk_rows { + let schema = row.get::<_, String>(0); + let table = row.get::<_, String>(1); + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index].primary_key.push(row.get::<_, String>(2)); + } + } + + let fk_rows = client + .query( + "SELECT kcu.table_schema, kcu.table_name, kcu.constraint_name, kcu.column_name, ccu.table_schema, ccu.table_name, ccu.column_name, kcu.ordinal_position::bigint \ + FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage kcu \ + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name \ + JOIN information_schema.constraint_column_usage ccu \ + ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema \ + WHERE tc.constraint_type = 'FOREIGN KEY' \ + ORDER BY kcu.table_schema, kcu.table_name, kcu.constraint_name, kcu.ordinal_position", + &[], + ) + .map_err(|error| CliError::runtime(format!("failed to query postgres foreign keys: {error}")))?; + attach_foreign_key_rows( + tables, + &by_id, + fk_rows.into_iter().map(|row| ForeignKeyRow { + schema: row.get(0), + table: row.get(1), + name: row.get(2), + column: row.get(3), + referenced_schema: Some(row.get(4)), + referenced_table: row.get(5), + referenced_column: row.get(6), + ordinal: row.get(7), + }), + ); + Ok(()) +} + +fn attach_postgres_indexes( + client: &mut postgres::Client, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + let by_id = table_index(tables); + let rows = client + .query( + "SELECT ns.nspname, tbl.relname, idx.relname, ix.indisunique, string_agg(att.attname, ',' ORDER BY ord.ordinality) \ + FROM pg_index ix \ + JOIN pg_class tbl ON tbl.oid = ix.indrelid \ + JOIN pg_namespace ns ON ns.oid = tbl.relnamespace \ + JOIN pg_class idx ON idx.oid = ix.indexrelid \ + JOIN unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ordinality) ON ord.attnum > 0 \ + JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = ord.attnum \ + GROUP BY ns.nspname, tbl.relname, idx.relname, ix.indisunique \ + ORDER BY ns.nspname, tbl.relname, idx.relname", + &[], + ) + .map_err(|error| CliError::runtime(format!("failed to query postgres indexes: {error}")))?; + for row in rows { + let schema = row.get::<_, String>(0); + let table = row.get::<_, String>(1); + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index].indexes.push(IndexSummary { + name: row.get(2), + unique: row.get(3), + columns: split_csv(&row.get::<_, String>(4)), + }); + } + } + Ok(()) +} + +fn inspect_mysql( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + use mysql::prelude::Queryable; + + let opts = mysql::Opts::from_url(&source.raw_url).map_err(|error| { + CliError::runtime(format!( + "failed to parse mysql source {}: {error}", + source.source_redacted + )) + })?; + let pool = mysql::Pool::new(opts).map_err(|error| { + CliError::runtime(format!( + "failed to connect to mysql source {}: {error}", + source.source_redacted + )) + })?; + let mut conn = pool.get_conn().map_err(|error| { + CliError::runtime(format!( + "failed to open mysql connection {}: {error}", + source.source_redacted + )) + })?; + let database = conn + .query_first::, _>("SELECT DATABASE()") + .map_err(|error| CliError::runtime(format!("failed to query mysql database: {error}")))? + .flatten() + .unwrap_or_else(|| "unknown".to_string()); + let mut tables = mysql_tables(&mut conn, options, source.engine)?; + attach_mysql_columns(&mut conn, &mut tables)?; + attach_mysql_constraints(&mut conn, &mut tables)?; + attach_mysql_indexes(&mut conn, &mut tables)?; + Ok(report(source, database, tables, Vec::new())) +} + +fn mysql_tables( + conn: &mut mysql::PooledConn, + options: &InspectOptions, + engine: Engine, +) -> Result, CliError> { + use mysql::prelude::Queryable; + + let rows = conn + .query_map( + "SELECT table_schema, table_name, table_type, table_rows \ + FROM information_schema.tables \ + ORDER BY table_schema, table_name", + |(schema, name, table_type, rows): (String, String, String, Option)| { + (schema, name, table_type, rows) + }, + ) + .map_err(|error| CliError::runtime(format!("failed to query mysql tables: {error}")))?; + Ok(rows + .into_iter() + .filter_map(|(schema, name, table_type, rows)| { + if (!options.include_system && is_system_schema(engine, &schema)) + || !options.accepts_schema(&schema) + || !options.accepts_table(&schema, &name) + { + return None; + } + Some(SqlObjectSummary { + schema, + name, + kind: object_kind(&table_type), + row_estimate: rows.and_then(|value| i64::try_from(value).ok()), + columns: Vec::new(), + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + }) + }) + .collect()) +} + +fn attach_mysql_columns( + conn: &mut mysql::PooledConn, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + use mysql::prelude::Queryable; + + let by_id = table_index(tables); + let rows = conn + .query_map( + "SELECT table_schema, table_name, column_name, column_type, is_nullable, column_default, ordinal_position \ + FROM information_schema.columns \ + ORDER BY table_schema, table_name, ordinal_position", + |(schema, table, name, data_type, nullable, default, ordinal): ( + String, + String, + String, + String, + String, + Option, + u64, + )| { (schema, table, name, data_type, nullable, default, ordinal) }, + ) + .map_err(|error| CliError::runtime(format!("failed to query mysql columns: {error}")))?; + for (schema, table, name, data_type, nullable, default, ordinal) in rows { + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index].columns.push(ColumnSummary { + name, + data_type, + nullable: nullable.eq_ignore_ascii_case("YES"), + default, + ordinal_position: i64::try_from(ordinal).unwrap_or(i64::MAX), + }); + } + } + Ok(()) +} + +fn attach_mysql_constraints( + conn: &mut mysql::PooledConn, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + use mysql::prelude::Queryable; + + let by_id = table_index(tables); + let pk_rows = conn + .query_map( + "SELECT table_schema, table_name, column_name \ + FROM information_schema.key_column_usage \ + WHERE constraint_name = 'PRIMARY' \ + ORDER BY table_schema, table_name, ordinal_position", + |(schema, table, column): (String, String, String)| (schema, table, column), + ) + .map_err(|error| { + CliError::runtime(format!("failed to query mysql primary keys: {error}")) + })?; + for (schema, table, column) in pk_rows { + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index].primary_key.push(column); + } + } + let fk_rows = conn + .query_map( + "SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name, ordinal_position \ + FROM information_schema.key_column_usage \ + WHERE referenced_table_name IS NOT NULL \ + ORDER BY table_schema, table_name, constraint_name, ordinal_position", + |(schema, table, name, column, referenced_schema, referenced_table, referenced_column, ordinal): ( + String, + String, + String, + String, + Option, + String, + String, + u64, + )| { + ForeignKeyRow { + schema, + table, + name, + column, + referenced_schema, + referenced_table, + referenced_column, + ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX), + } + }, + ) + .map_err(|error| CliError::runtime(format!("failed to query mysql foreign keys: {error}")))?; + attach_foreign_key_rows(tables, &by_id, fk_rows); + Ok(()) +} + +fn attach_mysql_indexes( + conn: &mut mysql::PooledConn, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + use mysql::prelude::Queryable; + + let by_id = table_index(tables); + let rows = conn + .query_map( + "SELECT table_schema, table_name, index_name, non_unique, column_name, seq_in_index \ + FROM information_schema.statistics \ + ORDER BY table_schema, table_name, index_name, seq_in_index", + |(schema, table, name, non_unique, column, ordinal): ( + String, + String, + String, + u8, + String, + u64, + )| { (schema, table, name, non_unique == 0, column, ordinal) }, + ) + .map_err(|error| CliError::runtime(format!("failed to query mysql indexes: {error}")))?; + let mut grouped = BTreeMap::<(String, String, String), (bool, Vec<(u64, String)>)>::new(); + for (schema, table, name, unique, column, ordinal) in rows { + grouped + .entry((schema, table, name)) + .or_insert_with(|| (unique, Vec::new())) + .1 + .push((ordinal, column)); + } + for ((schema, table, name), (unique, mut columns)) in grouped { + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + columns.sort_by_key(|(ordinal, _)| *ordinal); + tables[index].indexes.push(IndexSummary { + name, + unique, + columns: columns.into_iter().map(|(_, column)| column).collect(), + }); + } + } + Ok(()) +} + +fn inspect_sqlserver( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .enable_time() + .build() + .map_err(|error| { + CliError::runtime(format!("failed to start sqlserver runtime: {error}")) + })?; + runtime.block_on(inspect_sqlserver_async(source, options)) +} + +async fn inspect_sqlserver_async( + source: &ConnectionSource, + options: &InspectOptions, +) -> Result { + use tokio_util::compat::TokioAsyncWriteCompatExt; + + let ado = sqlserver_ado_string(&source.raw_url)?; + let config = tiberius::Config::from_ado_string(&ado).map_err(|error| { + CliError::runtime(format!( + "failed to parse sqlserver source {}: {error}", + source.source_redacted + )) + })?; + let tcp = tokio::time::timeout( + options.connect_timeout, + tokio::net::TcpStream::connect(config.get_addr()), + ) + .await + .map_err(|_| { + CliError::runtime(format!( + "timed out connecting to {}", + source.source_redacted + )) + })? + .map_err(|error| { + CliError::runtime(format!( + "failed to connect to sqlserver source {}: {error}", + source.source_redacted + )) + })?; + tcp.set_nodelay(true).map_err(|error| { + CliError::runtime(format!("failed to configure sqlserver TCP: {error}")) + })?; + let mut client = tiberius::Client::connect(config, tcp.compat_write()) + .await + .map_err(|error| { + CliError::runtime(format!( + "failed to connect to sqlserver source {}: {error}", + source.source_redacted + )) + })?; + let database_rows = client + .simple_query("SELECT DB_NAME() AS database_name") + .await + .map_err(|error| CliError::runtime(format!("failed to query sqlserver database: {error}")))? + .into_first_result() + .await + .map_err(|error| { + CliError::runtime(format!("failed to read sqlserver database: {error}")) + })?; + let database = database_rows + .first() + .and_then(|row| row.get::<&str, _>("database_name")) + .unwrap_or("unknown") + .to_string(); + let mut tables = sqlserver_tables(&mut client, options).await?; + attach_sqlserver_columns(&mut client, &mut tables).await?; + attach_sqlserver_constraints(&mut client, &mut tables).await?; + attach_sqlserver_indexes(&mut client, &mut tables).await?; + Ok(report(source, database, tables, Vec::new())) +} + +async fn sqlserver_tables( + client: &mut tiberius::Client>, + options: &InspectOptions, +) -> Result, CliError> { + let rows = client + .simple_query( + "SELECT s.name AS schema_name, o.name AS object_name, o.type_desc AS type_desc, SUM(ps.row_count) AS row_estimate \ + FROM sys.objects o \ + JOIN sys.schemas s ON s.schema_id = o.schema_id \ + LEFT JOIN sys.dm_db_partition_stats ps ON ps.object_id = o.object_id AND ps.index_id IN (0,1) \ + WHERE o.type IN ('U','V') \ + GROUP BY s.name, o.name, o.type_desc \ + ORDER BY s.name, o.name", + ) + .await + .map_err(|error| CliError::runtime(format!("failed to query sqlserver tables: {error}")))? + .into_first_result() + .await + .map_err(|error| CliError::runtime(format!("failed to read sqlserver tables: {error}")))?; + let mut tables = Vec::new(); + for row in rows { + let schema = sqlserver_string(&row, "schema_name")?; + let name = sqlserver_string(&row, "object_name")?; + if (!options.include_system && is_system_schema(Engine::SqlServer, &schema)) + || !options.accepts_schema(&schema) + || !options.accepts_table(&schema, &name) + { + continue; + } + let kind_text = sqlserver_string(&row, "type_desc")?; + tables.push(SqlObjectSummary { + schema, + name, + kind: object_kind(&kind_text), + row_estimate: row.try_get::("row_estimate").ok().flatten(), + columns: Vec::new(), + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + }); + } + Ok(tables) +} + +async fn attach_sqlserver_columns( + client: &mut tiberius::Client>, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + let by_id = table_index(tables); + let rows = client + .simple_query( + "SELECT s.name AS schema_name, o.name AS object_name, c.name AS column_name, ty.name AS type_name, c.is_nullable, dc.definition, c.column_id \ + FROM sys.columns c \ + JOIN sys.objects o ON o.object_id = c.object_id \ + JOIN sys.schemas s ON s.schema_id = o.schema_id \ + JOIN sys.types ty ON ty.user_type_id = c.user_type_id \ + LEFT JOIN sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id \ + WHERE o.type IN ('U','V') \ + ORDER BY s.name, o.name, c.column_id", + ) + .await + .map_err(|error| CliError::runtime(format!("failed to query sqlserver columns: {error}")))? + .into_first_result() + .await + .map_err(|error| CliError::runtime(format!("failed to read sqlserver columns: {error}")))?; + for row in rows { + let schema = sqlserver_string(&row, "schema_name")?; + let table = sqlserver_string(&row, "object_name")?; + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index].columns.push(ColumnSummary { + name: sqlserver_string(&row, "column_name")?, + data_type: sqlserver_string(&row, "type_name")?, + nullable: row + .try_get::("is_nullable") + .ok() + .flatten() + .unwrap_or(false), + default: row + .try_get::<&str, _>("definition") + .ok() + .flatten() + .map(ToString::to_string), + ordinal_position: row + .try_get::("column_id") + .ok() + .flatten() + .map_or(0, i64::from), + }); + } + } + Ok(()) +} + +async fn attach_sqlserver_constraints( + client: &mut tiberius::Client>, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + let by_id = table_index(tables); + let pk_rows = client + .simple_query( + "SELECT s.name AS schema_name, t.name AS table_name, c.name AS column_name \ + FROM sys.key_constraints kc \ + JOIN sys.tables t ON t.object_id = kc.parent_object_id \ + JOIN sys.schemas s ON s.schema_id = t.schema_id \ + JOIN sys.index_columns ic ON ic.object_id = t.object_id AND ic.index_id = kc.unique_index_id \ + JOIN sys.columns c ON c.object_id = t.object_id AND c.column_id = ic.column_id \ + WHERE kc.type = 'PK' \ + ORDER BY s.name, t.name, ic.key_ordinal", + ) + .await + .map_err(|error| CliError::runtime(format!("failed to query sqlserver primary keys: {error}")))? + .into_first_result() + .await + .map_err(|error| CliError::runtime(format!("failed to read sqlserver primary keys: {error}")))?; + for row in pk_rows { + let schema = sqlserver_string(&row, "schema_name")?; + let table = sqlserver_string(&row, "table_name")?; + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + tables[index] + .primary_key + .push(sqlserver_string(&row, "column_name")?); + } + } + let fk_rows = client + .simple_query( + "SELECT ps.name AS schema_name, pt.name AS table_name, fk.name AS fk_name, pc.name AS column_name, rs.name AS ref_schema_name, rt.name AS ref_table_name, rc.name AS ref_column_name, fkc.constraint_column_id \ + FROM sys.foreign_keys fk \ + JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id \ + JOIN sys.tables pt ON pt.object_id = fk.parent_object_id \ + JOIN sys.schemas ps ON ps.schema_id = pt.schema_id \ + JOIN sys.columns pc ON pc.object_id = pt.object_id AND pc.column_id = fkc.parent_column_id \ + JOIN sys.tables rt ON rt.object_id = fk.referenced_object_id \ + JOIN sys.schemas rs ON rs.schema_id = rt.schema_id \ + JOIN sys.columns rc ON rc.object_id = rt.object_id AND rc.column_id = fkc.referenced_column_id \ + ORDER BY ps.name, pt.name, fk.name, fkc.constraint_column_id", + ) + .await + .map_err(|error| CliError::runtime(format!("failed to query sqlserver foreign keys: {error}")))? + .into_first_result() + .await + .map_err(|error| CliError::runtime(format!("failed to read sqlserver foreign keys: {error}")))?; + let rows = fk_rows + .into_iter() + .map(|row| { + Ok(ForeignKeyRow { + schema: sqlserver_string(&row, "schema_name")?, + table: sqlserver_string(&row, "table_name")?, + name: sqlserver_string(&row, "fk_name")?, + column: sqlserver_string(&row, "column_name")?, + referenced_schema: Some(sqlserver_string(&row, "ref_schema_name")?), + referenced_table: sqlserver_string(&row, "ref_table_name")?, + referenced_column: sqlserver_string(&row, "ref_column_name")?, + ordinal: row + .try_get::("constraint_column_id") + .ok() + .flatten() + .map_or(0, i64::from), + }) + }) + .collect::, CliError>>()?; + attach_foreign_key_rows(tables, &by_id, rows); + Ok(()) +} + +async fn attach_sqlserver_indexes( + client: &mut tiberius::Client>, + tables: &mut [SqlObjectSummary], +) -> Result<(), CliError> { + let by_id = table_index(tables); + let rows = client + .simple_query( + "SELECT s.name AS schema_name, t.name AS table_name, i.name AS index_name, i.is_unique, c.name AS column_name, ic.key_ordinal \ + FROM sys.indexes i \ + JOIN sys.tables t ON t.object_id = i.object_id \ + JOIN sys.schemas s ON s.schema_id = t.schema_id \ + JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \ + JOIN sys.columns c ON c.object_id = t.object_id AND c.column_id = ic.column_id \ + WHERE i.name IS NOT NULL AND ic.key_ordinal > 0 \ + ORDER BY s.name, t.name, i.name, ic.key_ordinal", + ) + .await + .map_err(|error| CliError::runtime(format!("failed to query sqlserver indexes: {error}")))? + .into_first_result() + .await + .map_err(|error| CliError::runtime(format!("failed to read sqlserver indexes: {error}")))?; + let mut grouped = BTreeMap::<(String, String, String), (bool, Vec<(i32, String)>)>::new(); + for row in rows { + let schema = sqlserver_string(&row, "schema_name")?; + let table = sqlserver_string(&row, "table_name")?; + let name = sqlserver_string(&row, "index_name")?; + let unique = row + .try_get::("is_unique") + .ok() + .flatten() + .unwrap_or(false); + let column = sqlserver_string(&row, "column_name")?; + let ordinal = row + .try_get::("key_ordinal") + .ok() + .flatten() + .unwrap_or(0); + grouped + .entry((schema, table, name)) + .or_insert_with(|| (unique, Vec::new())) + .1 + .push((ordinal, column)); + } + for ((schema, table, name), (unique, mut columns)) in grouped { + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + columns.sort_by_key(|(ordinal, _)| *ordinal); + tables[index].indexes.push(IndexSummary { + name, + unique, + columns: columns.into_iter().map(|(_, column)| column).collect(), + }); + } + } + Ok(()) +} + +#[derive(Debug)] +struct ForeignKeyRow { + schema: String, + table: String, + name: String, + column: String, + referenced_schema: Option, + referenced_table: String, + referenced_column: String, + ordinal: i64, +} + +fn attach_foreign_key_rows( + tables: &mut [SqlObjectSummary], + by_id: &BTreeMap, + rows: I, +) where + I: IntoIterator, +{ + let mut grouped = BTreeMap::<(String, String, String), Vec>::new(); + for row in rows { + grouped + .entry((row.schema.clone(), row.table.clone(), row.name.clone())) + .or_default() + .push(row); + } + for ((schema, table, name), mut rows) in grouped { + if let Some(index) = by_id.get(&identity(&schema, &table)).copied() { + rows.sort_by_key(|row| row.ordinal); + let Some(first) = rows.first() else { + continue; + }; + tables[index].foreign_keys.push(ForeignKeySummary { + name, + columns: rows.iter().map(|row| row.column.clone()).collect(), + referenced_schema: first.referenced_schema.clone(), + referenced_table: first.referenced_table.clone(), + referenced_columns: rows.into_iter().map(|row| row.referenced_column).collect(), + }); + } + } +} + +fn table_index(tables: &[SqlObjectSummary]) -> BTreeMap { + tables + .iter() + .enumerate() + .map(|(index, table)| (identity(&table.schema, &table.name), index)) + .collect() +} + +fn identity(schema: &str, table: &str) -> String { + format!("{schema}.{table}").to_ascii_lowercase() +} + +fn object_kind(value: &str) -> SqlObjectKind { + let normalized = value.to_ascii_lowercase(); + if normalized.contains("view") { + SqlObjectKind::View + } else { + SqlObjectKind::Table + } +} + +fn is_system_schema(engine: Engine, schema: &str) -> bool { + match engine { + Engine::Postgres => { + schema.eq_ignore_ascii_case("information_schema") + || schema.to_ascii_lowercase().starts_with("pg_") + } + Engine::MySql | Engine::MariaDb => matches!( + schema.to_ascii_lowercase().as_str(), + "information_schema" | "mysql" | "performance_schema" | "sys" + ), + Engine::Sqlite => false, + Engine::SqlServer => matches!( + schema.to_ascii_lowercase().as_str(), + "sys" | "information_schema" + ), + Engine::DuckDb => matches!( + schema.to_ascii_lowercase().as_str(), + "information_schema" | "pg_catalog" | "system" | "temp" + ), + } +} + +fn local_database_path(raw: &str) -> Result { + if let Ok(url) = url::Url::parse(raw) { + if url.scheme() == "file" { + return url + .to_file_path() + .map_err(|()| CliError::usage("file URL cannot be converted to a local path")); + } + if matches!(url.scheme(), "sqlite" | "duckdb") { + let path = url.path(); + if path.is_empty() { + return Err(CliError::usage("database URL path is empty")); + } + return Ok(PathBuf::from(path.trim_start_matches('/'))); + } + } + Ok(PathBuf::from(raw)) +} + +fn database_name_from_path(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("database") + .to_string() +} + +fn quote_sqlite_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn sql_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn split_csv(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn split_csv_like(value: &str) -> Vec { + value + .trim_matches(['[', ']']) + .split(',') + .map(|part| part.trim().trim_matches('"')) + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn sqlserver_ado_string(raw: &str) -> Result { + let Ok(url) = url::Url::parse(raw) else { + return Ok(raw.to_string()); + }; + if !matches!(url.scheme(), "sqlserver" | "mssql") { + return Ok(raw.to_string()); + } + let host = url + .host_str() + .ok_or_else(|| CliError::usage("sqlserver URL requires a host"))?; + let server = url.port().map_or_else( + || format!("tcp:{host}"), + |port| format!("tcp:{host},{port}"), + ); + let mut parts = vec![format!("server={server}")]; + if !url.username().is_empty() { + parts.push(format!("User ID={}", url.username())); + } + if let Some(password) = url.password() { + parts.push(format!("Password={password}")); + } + let database = url.path().trim_start_matches('/'); + if !database.is_empty() { + parts.push(format!("Database={database}")); + } + let trust_cert = url + .query_pairs() + .any(|(key, value)| key.eq_ignore_ascii_case("trust_cert") && value == "true"); + if trust_cert { + parts.push("TrustServerCertificate=true".to_string()); + } + Ok(parts.join(";")) +} + +fn sqlserver_string(row: &tiberius::Row, column: &str) -> Result { + row.try_get::<&str, _>(column) + .map_err(|error| { + CliError::runtime(format!("failed to read sqlserver column {column}: {error}")) + })? + .map(ToString::to_string) + .ok_or_else(|| CliError::runtime(format!("sqlserver column {column} was NULL"))) +} + +#[cfg(test)] +mod tests { + use std::fmt::Write as _; + use std::time::Duration; + + use tempfile::tempdir; + + use super::*; + use crate::model::TableFilter; + + fn source(engine: Engine, raw_url: impl Into) -> ConnectionSource { + let raw_url = raw_url.into(); + ConnectionSource { + engine, + source_redacted: raw_url.clone(), + raw_url, + } + } + + #[test] + fn sqlite_inspection_reports_keys_indexes_foreign_keys_and_filters() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("library.db"); + let connection = rusqlite::Connection::open(&path).expect("sqlite"); + connection + .execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE authors(id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE books( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'untitled', + author_id INTEGER NOT NULL, + FOREIGN KEY(author_id) REFERENCES authors(id) + ); + CREATE INDEX idx_books_author ON books(author_id);", + ) + .expect("schema"); + drop(connection); + + let options = InspectOptions { + schemas: vec!["MAIN".to_string()], + tables: vec![TableFilter::parse("main.books")], + include_system: false, + connect_timeout: Duration::from_secs(1), + }; + let report = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &options, + ) + .expect("sqlite report"); + + assert_eq!(report.engine, Engine::Sqlite); + assert_eq!(report.database, "library.db"); + assert_eq!(report.schemas, vec!["main"]); + assert_eq!(report.tables.len(), 1); + let table = &report.tables[0]; + assert_eq!(table.identity(), "main.books"); + assert_eq!(table.kind, SqlObjectKind::Table); + assert_eq!(table.primary_key, vec!["id"]); + assert_eq!( + table + .columns + .iter() + .map(|column| ( + column.name.as_str(), + column.nullable, + column.default.as_deref() + )) + .collect::>(), + vec![ + ("id", false, None), + ("title", false, Some("'untitled'")), + ("author_id", false, None), + ] + ); + assert_eq!(table.indexes.len(), 1); + assert_eq!(table.indexes[0].name, "idx_books_author"); + assert_eq!(table.indexes[0].columns, vec!["author_id"]); + assert_eq!(table.foreign_keys.len(), 1); + assert_eq!(table.foreign_keys[0].columns, vec!["author_id"]); + assert_eq!(table.foreign_keys[0].referenced_table, "authors"); + assert_eq!(table.foreign_keys[0].referenced_columns, vec!["id"]); + } + + #[test] + fn sqlite_corrupt_source_reports_clear_error() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("corrupt.db"); + std::fs::write(&path, b"not a sqlite database").expect("corrupt fixture"); + + let error = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &InspectOptions::default(), + ) + .expect_err("corrupt sqlite should fail"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("sqlite") && message.contains("database")), + "unexpected corrupt database error: {error}" + ); + } + + #[test] + fn sqlite_malicious_catalog_null_table_name_fails_closed() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("malicious_catalog.db"); + let connection = rusqlite::Connection::open(&path).expect("sqlite"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY); + PRAGMA writable_schema = ON; + UPDATE sqlite_schema SET name = NULL WHERE type = 'table' AND name = 'users'; + PRAGMA writable_schema = OFF;", + ) + .expect("malicious catalog"); + drop(connection); + + let error = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &InspectOptions::default(), + ) + .expect_err("malicious sqlite catalog should fail closed"); + + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("failed to read sqlite schema row") || message.contains("malformed")), + "unexpected malicious catalog error: {error}" + ); + } + + #[test] + fn sqlite_malicious_catalog_invalid_table_sql_fails_closed() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("malicious_catalog_invalid_sql.db"); + let connection = rusqlite::Connection::open(&path).expect("sqlite"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY); + PRAGMA writable_schema = ON; + UPDATE sqlite_schema + SET sql = 'CREATE TABLE users(' + WHERE type = 'table' AND name = 'users'; + PRAGMA writable_schema = OFF;", + ) + .expect("malicious catalog"); + drop(connection); + + let error = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &InspectOptions::default(), + ) + .expect_err("malicious sqlite catalog should fail closed"); + + let message = error.to_string(); + assert!( + message.contains("malformed") + || message.contains("failed to query sqlite schema") + || message.contains("failed to query sqlite columns"), + "unexpected malicious catalog error: {error}" + ); + assert!( + message.len() < 512, + "unbounded sqlite catalog error: {error}" + ); + } + + #[test] + fn sqlite_inspection_handles_many_tables_with_filters() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("many_tables.db"); + let connection = rusqlite::Connection::open(&path).expect("sqlite"); + let mut schema = String::from("BEGIN;\n"); + for index in 0..80 { + writeln!( + schema, + "CREATE TABLE t_{index:03}(id INTEGER PRIMARY KEY, name TEXT NOT NULL, value INTEGER);" + ) + .expect("write schema"); + } + schema.push_str("COMMIT;\n"); + connection.execute_batch(&schema).expect("schema"); + drop(connection); + + let report = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &InspectOptions { + tables: vec![ + TableFilter::parse("main.t_000"), + TableFilter::parse("t_079"), + ], + connect_timeout: Duration::from_secs(1), + ..InspectOptions::default() + }, + ) + .expect("sqlite report"); + + assert_eq!(report.tables.len(), 2); + assert_eq!(report.tables[0].identity(), "main.t_000"); + assert_eq!(report.tables[1].identity(), "main.t_079"); + assert!(report.tables.iter().all(|table| table.columns.len() == 3)); + assert!( + report + .tables + .iter() + .all(|table| table.primary_key == vec!["id"]) + ); + } + + #[test] + fn sqlite_inspection_handles_wide_tables_expression_indexes_and_composite_foreign_keys() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("pathological_schema.db"); + let connection = rusqlite::Connection::open(&path).expect("sqlite"); + let mut schema = String::from( + "PRAGMA foreign_keys = ON; + CREATE TABLE parents( + tenant_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY(tenant_id, parent_id) + ); + CREATE TABLE children( + tenant_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, + child_id INTEGER NOT NULL, + title TEXT NOT NULL", + ); + for index in 0..96 { + write!(schema, ", c_{index:03} TEXT").expect("schema write"); + } + schema.push_str( + ", + PRIMARY KEY(tenant_id, parent_id, child_id), + FOREIGN KEY(tenant_id, parent_id) REFERENCES parents(tenant_id, parent_id) + ); + CREATE INDEX idx_children_lower_title ON children(lower(title)) WHERE title IS NOT NULL;", + ); + connection.execute_batch(&schema).expect("schema"); + drop(connection); + + let report = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &InspectOptions::default(), + ) + .expect("sqlite report"); + + let table = report + .tables + .iter() + .find(|table| table.name == "children") + .expect("children table"); + assert_eq!(table.columns.len(), 100); + assert_eq!( + table.primary_key, + vec!["tenant_id", "parent_id", "child_id"] + ); + assert_eq!(table.foreign_keys.len(), 1); + assert_eq!( + table.foreign_keys[0].columns, + vec!["tenant_id", "parent_id"] + ); + assert_eq!( + table.foreign_keys[0].referenced_columns, + vec!["tenant_id", "parent_id"] + ); + let expression_index = table + .indexes + .iter() + .find(|index| index.name == "idx_children_lower_title") + .expect("expression index"); + assert_eq!(expression_index.columns, vec![""]); + } + + #[test] + fn sqlite_locked_source_reports_busy_without_long_wait() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("locked.db"); + let writer = rusqlite::Connection::open(&path).expect("sqlite"); + writer + .execute_batch( + "CREATE TABLE events(id INTEGER PRIMARY KEY); + BEGIN EXCLUSIVE; + INSERT INTO events(id) VALUES (1);", + ) + .expect("exclusive lock"); + + let started = std::time::Instant::now(); + let error = inspect( + &source(Engine::Sqlite, path.to_string_lossy().into_owned()), + &InspectOptions { + connect_timeout: Duration::from_secs(1), + ..InspectOptions::default() + }, + ) + .expect_err("locked sqlite should fail"); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_secs(1), + "locked sqlite inspection waited too long: {elapsed:?}" + ); + assert!( + matches!(&error, CliError::Runtime(message) if message.contains("locked") || message.contains("busy")), + "unexpected locked database error: {error}" + ); + } + + #[test] + fn duckdb_inspection_reports_tables_views_columns_and_honors_filters() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("warehouse.duckdb"); + let connection = duckdb::Connection::open(&path).expect("duckdb"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name VARCHAR NOT NULL); + CREATE VIEW active_users AS SELECT id, name FROM users;", + ) + .expect("schema"); + drop(connection); + + let report = inspect( + &source(Engine::DuckDb, path.to_string_lossy().into_owned()), + &InspectOptions::default(), + ) + .expect("duckdb report"); + + assert_eq!(report.engine, Engine::DuckDb); + assert_eq!(report.database, "warehouse.duckdb"); + assert!(report.schemas.iter().any(|schema| schema == "main")); + let users = report + .tables + .iter() + .find(|table| table.schema == "main" && table.name == "users") + .expect("users table"); + assert_eq!(users.kind, SqlObjectKind::Table); + assert_eq!(users.primary_key, vec!["id"]); + assert!( + users + .columns + .iter() + .any(|column| column.name == "name" && !column.nullable) + ); + let view = report + .tables + .iter() + .find(|table| table.schema == "main" && table.name == "active_users") + .expect("active_users view"); + assert_eq!(view.kind, SqlObjectKind::View); + + let filtered = inspect( + &source(Engine::DuckDb, path.to_string_lossy().into_owned()), + &InspectOptions { + tables: vec![TableFilter::parse("users")], + ..InspectOptions::default() + }, + ) + .expect("filtered report"); + assert_eq!(filtered.tables.len(), 1); + assert_eq!(filtered.tables[0].name, "users"); + } + + #[test] + fn helpers_normalize_identifiers_filters_connection_strings_and_foreign_keys() { + assert_eq!(quote_sqlite_identifier("weird\"name"), "\"weird\"\"name\""); + assert_eq!(sql_string("O'Hara"), "'O''Hara'"); + assert_eq!(split_csv(" id, name, , city "), vec!["id", "name", "city"]); + assert_eq!( + split_csv_like("[\"id\", \"name\", \"city\"]"), + vec!["id", "name", "city"] + ); + assert_eq!(object_kind("BASE TABLE"), SqlObjectKind::Table); + assert_eq!(object_kind("MATERIALIZED VIEW"), SqlObjectKind::View); + assert!(is_system_schema(Engine::Postgres, "pg_catalog")); + assert!(is_system_schema(Engine::MySql, "performance_schema")); + assert!(is_system_schema(Engine::MariaDb, "mysql")); + assert!(is_system_schema(Engine::SqlServer, "INFORMATION_SCHEMA")); + assert!(is_system_schema(Engine::DuckDb, "temp")); + assert!(!is_system_schema(Engine::Sqlite, "sqlite_master")); + assert_eq!( + local_database_path("sqlite:///C:/data/app.db") + .expect("sqlite path") + .to_string_lossy(), + "C:/data/app.db" + ); + assert_eq!( + database_name_from_path(Path::new(r"C:\data\app.db")), + "app.db" + ); + assert_eq!( + sqlserver_ado_string("sqlserver://sa:secret@example.test:1433/app?trust_cert=true") + .expect("ado"), + "server=tcp:example.test,1433;User ID=sa;Password=secret;Database=app;TrustServerCertificate=true" + ); + assert!(sqlserver_ado_string("sqlserver:///missing-host").is_err()); + + let mut tables = vec![SqlObjectSummary { + schema: "public".to_string(), + name: "orders".to_string(), + kind: SqlObjectKind::Table, + row_estimate: None, + columns: Vec::new(), + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + }]; + let by_id = table_index(&tables); + attach_foreign_key_rows( + &mut tables, + &by_id, + [ + ForeignKeyRow { + schema: "public".to_string(), + table: "orders".to_string(), + name: "fk_orders_users".to_string(), + column: "user_id".to_string(), + referenced_schema: Some("public".to_string()), + referenced_table: "users".to_string(), + referenced_column: "id".to_string(), + ordinal: 2, + }, + ForeignKeyRow { + schema: "public".to_string(), + table: "orders".to_string(), + name: "fk_orders_users".to_string(), + column: "tenant_id".to_string(), + referenced_schema: Some("public".to_string()), + referenced_table: "users".to_string(), + referenced_column: "tenant_id".to_string(), + ordinal: 1, + }, + ], + ); + assert_eq!(tables[0].foreign_keys.len(), 1); + assert_eq!( + tables[0].foreign_keys[0].columns, + vec!["tenant_id", "user_id"] + ); + assert_eq!( + tables[0].foreign_keys[0].referenced_columns, + vec!["tenant_id", "id"] + ); + } +} diff --git a/crates/sqlshape/src/cli.rs b/crates/sqlshape/src/cli.rs new file mode 100644 index 0000000..1d021ce --- /dev/null +++ b/crates/sqlshape/src/cli.rs @@ -0,0 +1,275 @@ +use std::ffi::OsString; +use std::time::Duration; + +use common::{ + CliError, ColorChoice, CommonArgs, RenderMode, parse_color_choice, parse_format_choice, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; + +use crate::connection::source_from_parts; +use crate::model::{ConnectionSource, Engine, InspectOptions, TableFilter}; + +#[derive(Debug, Clone)] +pub(crate) struct Cli { + pub(crate) common: CommonArgs, + pub(crate) command: CommandMode, + pub(crate) options: InspectOptions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CommandMode { + Summary { + source: ConnectionSource, + }, + Diff { + before: ConnectionSource, + after: ConnectionSource, + }, +} + +#[derive(Default)] +struct PendingSources { + engine: Option, + url: Option, + before_engine: Option, + after_engine: Option, + before_url: Option, + after_url: Option, +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs { + color: ColorChoice::Auto, + ..CommonArgs::default() + }; + let mut options = InspectOptions::default(); + let mut sources = PendingSources::default(); + let mut diff_mode = false; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, default_cli()?)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, default_cli()?)), + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + common.set_render_mode(parse_format_choice(&parser_value_string( + &mut parser, + "--format", + )?)?); + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("engine") => { + sources.engine = Some(parse_engine_value( + &parser_value_string(&mut parser, "--engine")?, + "--engine", + )?); + } + Long("url") => set_once( + &mut sources.url, + parser_value_string(&mut parser, "--url")?, + "--url", + )?, + Long("before-engine") => { + sources.before_engine = Some(parse_engine_value( + &parser_value_string(&mut parser, "--before-engine")?, + "--before-engine", + )?); + } + Long("after-engine") => { + sources.after_engine = Some(parse_engine_value( + &parser_value_string(&mut parser, "--after-engine")?, + "--after-engine", + )?); + } + Long("before-url") => set_once( + &mut sources.before_url, + parser_value_string(&mut parser, "--before-url")?, + "--before-url", + )?, + Long("after-url") => set_once( + &mut sources.after_url, + parser_value_string(&mut parser, "--after-url")?, + "--after-url", + )?, + Long("schema") => options + .schemas + .push(parser_value_string(&mut parser, "--schema")?), + Long("table") => options.tables.push(TableFilter::parse(&parser_value_string( + &mut parser, + "--table", + )?)), + Long("include-system") => options.include_system = true, + Long("connect-timeout") => { + options.connect_timeout = + parse_timeout(&parser_value_string(&mut parser, "--connect-timeout")?)?; + } + ArgValue(value) if value == "diff" => { + if diff_mode { + return Err(CliError::usage("duplicate diff subcommand")); + } + diff_mode = true; + } + ArgValue(value) => { + return Err(CliError::usage(format!( + "unexpected positional argument '{}'", + value.to_string_lossy() + ))); + } + Short(flag) => return Err(CliError::usage(format!("unsupported flag -{flag}"))), + Long(flag) => return Err(CliError::usage(format!("unsupported flag --{flag}"))), + } + } + + let command = if diff_mode { + if sources.url.is_some() || sources.engine.is_some() { + return Err(CliError::usage( + "diff expects --before-url/--after-url and optional --before-engine/--after-engine", + )); + } + let before = source_from_parts( + sources.before_engine, + sources + .before_url + .ok_or_else(|| CliError::usage("diff expects --before-url"))?, + )?; + let after = source_from_parts( + sources.after_engine, + sources + .after_url + .ok_or_else(|| CliError::usage("diff expects --after-url"))?, + )?; + CommandMode::Diff { before, after } + } else { + if sources.before_url.is_some() + || sources.after_url.is_some() + || sources.before_engine.is_some() + || sources.after_engine.is_some() + { + return Err(CliError::usage( + "summary mode expects --url and optional --engine, not diff source flags", + )); + } + let source = source_from_parts( + sources.engine, + sources + .url + .ok_or_else(|| CliError::usage("provide --url "))?, + )?; + CommandMode::Summary { source } + }; + + Ok(( + ParseOutcome::Run, + Cli { + common, + command, + options, + }, + )) +} + +fn default_cli() -> Result { + Ok(Cli { + common: CommonArgs::default(), + command: CommandMode::Summary { + source: source_from_parts(Some(Engine::Sqlite), ":memory:".to_string())?, + }, + options: InspectOptions::default(), + }) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|_| CliError::usage(format!("{flag} expects a value")))?; + Ok(value.to_string_lossy().into_owned()) +} + +fn parse_engine_value(value: &str, flag: &str) -> Result { + Engine::parse(value).ok_or_else(|| { + CliError::usage(format!( + "invalid {flag} value '{value}'; expected postgres, mysql, mariadb, sqlite, sqlserver, mssql, or duckdb" + )) + }) +} + +fn parse_timeout(value: &str) -> Result { + let seconds = value + .parse::() + .map_err(|_| CliError::usage("--connect-timeout expects a positive integer"))?; + if seconds == 0 { + return Err(CliError::usage( + "--connect-timeout expects a positive integer", + )); + } + Ok(Duration::from_secs(seconds)) +} + +fn set_once(target: &mut Option, value: String, flag: &str) -> Result<(), CliError> { + if target.replace(value).is_some() { + Err(CliError::usage(format!("duplicate {flag}"))) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_summary_and_diff_modes() { + let (_, summary) = parse_cli_from(["sqlshape", "--engine", "sqlite", "--url", "db.sqlite"]) + .expect("summary"); + assert!(matches!( + summary.command, + CommandMode::Summary { + source: ConnectionSource { + engine: Engine::Sqlite, + .. + } + } + )); + + let (_, diff) = parse_cli_from([ + "sqlshape", + "diff", + "--before-engine", + "sqlite", + "--before-url", + "a.db", + "--after-engine", + "duckdb", + "--after-url", + "b.duckdb", + ]) + .expect("diff"); + assert!(matches!(diff.command, CommandMode::Diff { .. })); + } + + #[test] + fn rejects_ambiguous_local_paths_without_engine() { + let error = parse_cli_from(["sqlshape", "--url", "db.sqlite"]).expect_err("usage error"); + assert!(error.to_string().contains("--engine")); + } +} diff --git a/crates/sqlshape/src/connection.rs b/crates/sqlshape/src/connection.rs new file mode 100644 index 0000000..06e53c0 --- /dev/null +++ b/crates/sqlshape/src/connection.rs @@ -0,0 +1,138 @@ +use common::CliError; +use url::Url; + +use crate::model::{ConnectionSource, Engine}; + +pub(crate) fn source_from_parts( + engine: Option, + raw_url: String, +) -> Result { + let source_redacted = redact_source(&raw_url); + let engine = engine.or_else(|| infer_engine(&raw_url)).ok_or_else(|| { + CliError::usage(format!( + "--engine is required for ambiguous SQL source '{source_redacted}'" + )) + })?; + Ok(ConnectionSource { + engine, + raw_url, + source_redacted, + }) +} + +pub(crate) fn infer_engine(raw_url: &str) -> Option { + Url::parse(raw_url) + .ok() + .and_then(|url| Engine::parse(url.scheme())) +} + +pub(crate) fn redact_source(raw: &str) -> String { + if let Ok(mut url) = Url::parse(raw) { + if url.password().is_some() { + let _ = url.set_password(Some("***")); + } + if url + .query_pairs() + .any(|(key, _)| is_sensitive_connection_key(&key)) + { + let pairs = url + .query_pairs() + .map(|(key, value)| { + let rendered = if is_sensitive_connection_key(&key) { + "***".into() + } else { + value + }; + (key.into_owned(), rendered.into_owned()) + }) + .collect::>(); + url.query_pairs_mut().clear().extend_pairs(pairs); + } + return url.to_string(); + } + redact_key_value_passwords(raw) +} + +fn redact_key_value_passwords(raw: &str) -> String { + let mut changed = false; + let parts = raw + .split(';') + .map(|part| { + let Some((key, value)) = part.split_once('=') else { + return part.to_string(); + }; + let key_trimmed = key.trim(); + if is_sensitive_connection_key(key_trimmed) { + changed = true; + format!("{key}=***") + } else { + format!("{key}={value}") + } + }) + .collect::>(); + if changed { + parts.join(";") + } else { + raw.to_string() + } +} + +fn is_sensitive_connection_key(key: &str) -> bool { + let normalized = key + .chars() + .filter(|character| !matches!(character, '_' | '-' | ' ')) + .flat_map(char::to_lowercase) + .collect::(); + matches!( + normalized.as_str(), + "password" + | "pwd" + | "token" + | "accesstoken" + | "apikey" + | "secret" + | "clientsecret" + | "credential" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_url_and_ado_passwords() { + assert_eq!( + redact_source("postgres://user:secret@example.test/db"), + "postgres://user:***@example.test/db" + ); + assert_eq!( + redact_source("server=tcp:host,1433;User ID=sa;Password=secret;Database=db"), + "server=tcp:host,1433;User ID=sa;Password=***;Database=db" + ); + } + + #[test] + fn redacts_url_query_and_key_value_secrets() { + assert_eq!( + redact_source( + "postgres://example.test/db?password=secret&access_token=abc&sslmode=require" + ), + "postgres://example.test/db?password=***&access_token=***&sslmode=require" + ); + assert_eq!( + redact_source("server=tcp:host,1433;User ID=sa;Access Token=abc;ApiKey=key"), + "server=tcp:host,1433;User ID=sa;Access Token=***;ApiKey=***" + ); + } + + #[test] + fn infers_engines_from_url_schemes_only() { + assert_eq!( + infer_engine("postgres://localhost/db"), + Some(Engine::Postgres) + ); + assert_eq!(infer_engine("duckdb://local/path"), Some(Engine::DuckDb)); + assert_eq!(infer_engine(r".\local.db"), None); + } +} diff --git a/crates/sqlshape/src/diff.rs b/crates/sqlshape/src/diff.rs new file mode 100644 index 0000000..27fd4a5 --- /dev/null +++ b/crates/sqlshape/src/diff.rs @@ -0,0 +1,297 @@ +use std::collections::BTreeMap; + +use crate::model::{ + ColumnChange, ColumnSummary, ForeignKeySummary, IndexSummary, SqlObjectSummary, SqlShapeDiff, + SqlShapeReport, TableChange, +}; + +pub(crate) fn diff_reports(before: SqlShapeReport, after: SqlShapeReport) -> SqlShapeDiff { + let before_tables = table_map(&before.tables); + let after_tables = table_map(&after.tables); + + let mut added_tables = Vec::new(); + let mut removed_tables = Vec::new(); + let mut changed_tables = Vec::new(); + + for (identity, table) in &after_tables { + if !before_tables.contains_key(identity) { + added_tables.push((*table).clone()); + } + } + for (identity, table) in &before_tables { + if !after_tables.contains_key(identity) { + removed_tables.push((*table).clone()); + } + } + for (identity, before_table) in &before_tables { + if let Some(after_table) = after_tables.get(identity) { + if let Some(change) = diff_table(before_table, after_table) { + changed_tables.push(change); + } + } + } + + SqlShapeDiff { + before, + after, + added_tables, + removed_tables, + changed_tables, + } +} + +fn table_map(tables: &[SqlObjectSummary]) -> BTreeMap<(String, String), &SqlObjectSummary> { + tables + .iter() + .map(|table| { + ( + ( + table.schema.to_ascii_lowercase(), + table.name.to_ascii_lowercase(), + ), + table, + ) + }) + .collect() +} + +fn diff_table(before: &SqlObjectSummary, after: &SqlObjectSummary) -> Option { + let added_columns = added_items(&before.columns, &after.columns, |column| &column.name); + let removed_columns = added_items(&after.columns, &before.columns, |column| &column.name); + let changed_columns = changed_columns(before, after); + let added_foreign_keys = added_items(&before.foreign_keys, &after.foreign_keys, foreign_key_id); + let removed_foreign_keys = + added_items(&after.foreign_keys, &before.foreign_keys, foreign_key_id); + let added_indexes = added_items(&before.indexes, &after.indexes, index_id); + let removed_indexes = added_items(&after.indexes, &before.indexes, index_id); + + let changed = before.kind != after.kind + || before.row_estimate != after.row_estimate + || before.primary_key != after.primary_key + || !added_columns.is_empty() + || !removed_columns.is_empty() + || !changed_columns.is_empty() + || !added_foreign_keys.is_empty() + || !removed_foreign_keys.is_empty() + || !added_indexes.is_empty() + || !removed_indexes.is_empty(); + changed.then(|| TableChange { + schema: after.schema.clone(), + name: after.name.clone(), + kind_before: before.kind, + kind_after: after.kind, + row_estimate_before: before.row_estimate, + row_estimate_after: after.row_estimate, + added_columns, + removed_columns, + changed_columns, + primary_key_before: before.primary_key.clone(), + primary_key_after: after.primary_key.clone(), + added_foreign_keys, + removed_foreign_keys, + added_indexes, + removed_indexes, + }) +} + +fn changed_columns(before: &SqlObjectSummary, after: &SqlObjectSummary) -> Vec { + let before_columns = before + .columns + .iter() + .map(|column| (column.name.to_ascii_lowercase(), column)) + .collect::>(); + after + .columns + .iter() + .filter_map(|after_column| { + let before_column = before_columns.get(&after_column.name.to_ascii_lowercase())?; + (column_changed(before_column, after_column)).then(|| ColumnChange { + name: after_column.name.clone(), + data_type_before: before_column.data_type.clone(), + data_type_after: after_column.data_type.clone(), + nullable_before: before_column.nullable, + nullable_after: after_column.nullable, + default_before: before_column.default.clone(), + default_after: after_column.default.clone(), + }) + }) + .collect() +} + +fn column_changed(before: &ColumnSummary, after: &ColumnSummary) -> bool { + before.data_type != after.data_type + || before.nullable != after.nullable + || before.default != after.default +} + +fn added_items(old: &[T], new: &[T], id: F) -> Vec +where + T: Clone, + F: Fn(&T) -> &str, +{ + let old_ids = old + .iter() + .map(|item| id(item).to_ascii_lowercase()) + .collect::>(); + new.iter() + .filter(|item| !old_ids.contains(&id(item).to_ascii_lowercase())) + .cloned() + .collect() +} + +fn foreign_key_id(foreign_key: &ForeignKeySummary) -> &str { + &foreign_key.name +} + +fn index_id(index: &IndexSummary) -> &str { + &index.name +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + ColumnSummary, Engine, ForeignKeySummary, IndexSummary, SqlObjectKind, SqlObjectSummary, + }; + + #[test] + fn detects_table_additions_removals_and_kind_changes() { + let before = report(vec![ + table( + "public", + "users", + SqlObjectKind::Table, + vec![column("id", "int", false, None)], + ), + table( + "public", + "legacy", + SqlObjectKind::Table, + vec![column("id", "int", false, None)], + ), + ]); + let after = report(vec![ + table( + "public", + "users", + SqlObjectKind::View, + vec![column("id", "int", false, None)], + ), + table( + "public", + "events", + SqlObjectKind::Table, + vec![column("id", "int", false, None)], + ), + ]); + + let diff = diff_reports(before, after); + + assert_eq!(diff.added_tables[0].name, "events"); + assert_eq!(diff.removed_tables[0].name, "legacy"); + assert_eq!(diff.changed_tables[0].name, "users"); + assert_eq!(diff.changed_tables[0].kind_before, SqlObjectKind::Table); + assert_eq!(diff.changed_tables[0].kind_after, SqlObjectKind::View); + } + + #[test] + fn detects_column_type_nullability_and_default_changes() { + let before = report(vec![table( + "public", + "users", + SqlObjectKind::Table, + vec![column("name", "text", false, Some("'old'"))], + )]); + let after = report(vec![table( + "public", + "users", + SqlObjectKind::Table, + vec![column("name", "varchar(128)", true, Some("'new'"))], + )]); + + let diff = diff_reports(before, after); + let change = &diff.changed_tables[0].changed_columns[0]; + + assert_eq!(change.name, "name"); + assert_eq!(change.data_type_before, "text"); + assert_eq!(change.data_type_after, "varchar(128)"); + assert!(!change.nullable_before); + assert!(change.nullable_after); + assert_eq!(change.default_before.as_deref(), Some("'old'")); + assert_eq!(change.default_after.as_deref(), Some("'new'")); + } + + #[test] + fn detects_foreign_key_and_index_changes() { + let before = report(vec![table( + "public", + "orders", + SqlObjectKind::Table, + vec![column("user_id", "int", false, None)], + )]); + let mut after_table = table( + "public", + "orders", + SqlObjectKind::Table, + vec![column("user_id", "int", false, None)], + ); + after_table.foreign_keys.push(ForeignKeySummary { + name: "fk_orders_users".to_string(), + columns: vec!["user_id".to_string()], + referenced_schema: Some("public".to_string()), + referenced_table: "users".to_string(), + referenced_columns: vec!["id".to_string()], + }); + after_table.indexes.push(IndexSummary { + name: "idx_orders_user_id".to_string(), + unique: false, + columns: vec!["user_id".to_string()], + }); + let after = report(vec![after_table]); + + let diff = diff_reports(before, after); + let change = &diff.changed_tables[0]; + + assert_eq!(change.added_foreign_keys[0].name, "fk_orders_users"); + assert_eq!(change.added_indexes[0].name, "idx_orders_user_id"); + } + + fn report(tables: Vec) -> SqlShapeReport { + SqlShapeReport { + engine: Engine::Postgres, + database: "db".to_string(), + source_redacted: "postgres://user:***@host/db".to_string(), + schemas: vec!["public".to_string()], + tables, + warnings: Vec::new(), + } + } + + fn table( + schema: &str, + name: &str, + kind: SqlObjectKind, + columns: Vec, + ) -> SqlObjectSummary { + SqlObjectSummary { + schema: schema.to_string(), + name: name.to_string(), + kind, + row_estimate: None, + columns, + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + } + } + + fn column(name: &str, data_type: &str, nullable: bool, default: Option<&str>) -> ColumnSummary { + ColumnSummary { + name: name.to_string(), + data_type: data_type.to_string(), + nullable, + default: default.map(str::to_string), + ordinal_position: 1, + } + } +} diff --git a/crates/sqlshape/src/lib.rs b/crates/sqlshape/src/lib.rs new file mode 100644 index 0000000..9ed90e5 --- /dev/null +++ b/crates/sqlshape/src/lib.rs @@ -0,0 +1,230 @@ +#![allow(clippy::multiple_crate_versions, clippy::redundant_pub_crate)] +//! The `sqlshape` command inspects normalized SQL database schemas. + +use common::{ + CliError, ExitCode, RenderMode, print_quick_help_error, print_structured, write_stdout, +}; + +/// Database catalog adapters for supported engines. +pub(crate) mod adapters; +/// Command-line parsing and validation. +pub(crate) mod cli; +/// Connection source inference and redaction. +pub(crate) mod connection; +/// Normalized schema diffing. +pub(crate) mod diff; +/// Normalized SQL schema model. +pub(crate) mod model; +/// Text rendering for reports and diffs. +pub(crate) mod render; + +const HELP: &str = "\ +Inspect multi-database SQL schema shape through read-only connections. + +Usage: + sqlshape [OPTIONS] --url + sqlshape [OPTIONS] diff --before-url --after-url + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --engine Engine: postgres, mysql, mariadb, sqlite, sqlserver, mssql, duckdb + --url Database URL or local database path + --before-engine Engine for diff --before-url + --after-engine Engine for diff --after-url + --before-url Before database URL or local database path for diff + --after-url After database URL or local database path for diff + --schema Limit output to one or more schemas + --table Limit output to one or more table names or schema.table names + --include-system Include system schemas and tables + --connect-timeout Connection timeout hint for service adapters + -h, --help Show this help text + -V, --version Show the command version + +Examples: + sqlshape --engine postgres --url \"postgres://user:pass@host:5432/db\" --json + sqlshape --engine sqlite --url .\\data\\app.db --toon + sqlshape --engine duckdb --url .\\data\\warehouse.duckdb + sqlshape diff --before-engine sqlite --before-url before.db --after-engine duckdb --after-url after.duckdb --json +"; + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match cli::parse_cli_from(std::env::args_os()) { + Ok((cli::ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((cli::ParseOutcome::Version, _)) => { + println!("sqlshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((cli::ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn run(cli: &cli::Cli) -> Result { + match &cli.command { + cli::CommandMode::Summary { source } => { + let report = adapters::inspect(source, &cli.options)?; + emit_value(cli, &report, &render::render_report(&report))?; + Ok(common::map_result_count(report.tables.len())) + } + cli::CommandMode::Diff { before, after } => { + let before_report = adapters::inspect(before, &cli.options)?; + let after_report = adapters::inspect(after, &cli.options)?; + let report = diff::diff_reports(before_report, after_report); + let count = report.added_tables.len() + + report.removed_tables.len() + + report.changed_tables.len(); + emit_value(cli, &report, &render::render_diff(&report))?; + Ok(common::map_result_count(count)) + } + } +} + +fn emit_value(cli: &cli::Cli, value: &T, text: &str) -> Result<(), CliError> +where + T: serde::Serialize, +{ + match cli.common.render_mode() { + RenderMode::Text => write_stdout(text), + RenderMode::Json | RenderMode::Toon => print_structured(value, cli.common.render_mode()), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + fn sqlite_fixture(name: &str, schema: &str) -> std::path::PathBuf { + let dir = tempdir().expect("tempdir").keep(); + let path = dir.join(name); + let connection = rusqlite::Connection::open(&path).expect("sqlite"); + connection.execute_batch(schema).expect("schema"); + drop(connection); + path + } + + #[test] + fn run_summary_emits_text_json_and_toon_for_sqlite_sources() { + let path = sqlite_fixture( + "summary.db", + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);", + ); + + for flag in [None, Some("--json"), Some("--toon")] { + let mut args = vec![ + "sqlshape".to_string(), + "--engine".to_string(), + "sqlite".to_string(), + "--url".to_string(), + path.display().to_string(), + ]; + if let Some(flag) = flag { + args.insert(1, flag.to_string()); + } + let (_, cli) = cli::parse_cli_from(args).expect("cli parses"); + assert_eq!(run(&cli).expect("run succeeds"), ExitCode::Success); + } + + let _ = fs::remove_file(path); + } + + #[test] + fn run_diff_maps_no_change_to_no_results_and_changes_to_success() { + let before = sqlite_fixture( + "before.db", + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);", + ); + let after_same = sqlite_fixture( + "after_same.db", + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);", + ); + let after_changed = sqlite_fixture( + "after_changed.db", + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);", + ); + + let (_, no_change) = cli::parse_cli_from([ + "sqlshape", + "diff", + "--before-engine", + "sqlite", + "--before-url", + &before.display().to_string(), + "--after-engine", + "sqlite", + "--after-url", + &after_same.display().to_string(), + ]) + .expect("no-change diff parses"); + assert_eq!( + run(&no_change).expect("no-change diff"), + ExitCode::NoResults + ); + + let (_, changed) = cli::parse_cli_from([ + "sqlshape", + "--json", + "diff", + "--before-engine", + "sqlite", + "--before-url", + &before.display().to_string(), + "--after-engine", + "sqlite", + "--after-url", + &after_changed.display().to_string(), + ]) + .expect("changed diff parses"); + assert_eq!(run(&changed).expect("changed diff"), ExitCode::Success); + + let _ = fs::remove_file(before); + let _ = fs::remove_file(after_same); + let _ = fs::remove_file(after_changed); + } + + #[test] + fn emit_value_respects_text_and_structured_render_modes() { + let report = model::SqlShapeReport { + engine: model::Engine::Sqlite, + database: "demo.db".to_string(), + source_redacted: "demo.db".to_string(), + schemas: vec!["main".to_string()], + tables: Vec::new(), + warnings: Vec::new(), + }; + + for args in [ + vec!["sqlshape", "--engine", "sqlite", "--url", "demo.db"], + vec![ + "sqlshape", "--json", "--engine", "sqlite", "--url", "demo.db", + ], + vec![ + "sqlshape", "--toon", "--engine", "sqlite", "--url", "demo.db", + ], + ] { + let (_, cli) = cli::parse_cli_from(args).expect("cli parses"); + emit_value(&cli, &report, "engine=sqlite\n").expect("emit succeeds"); + } + } +} diff --git a/crates/sqlshape/src/main.rs b/crates/sqlshape/src/main.rs new file mode 100644 index 0000000..effac1c --- /dev/null +++ b/crates/sqlshape/src/main.rs @@ -0,0 +1,6 @@ +#![allow(clippy::multiple_crate_versions)] +//! Binary entry point for `sqlshape`. + +fn main() { + std::process::exit(sqlshape::main_entry()); +} diff --git a/crates/sqlshape/src/model.rs b/crates/sqlshape/src/model.rs new file mode 100644 index 0000000..b5ebb03 --- /dev/null +++ b/crates/sqlshape/src/model.rs @@ -0,0 +1,329 @@ +use std::fmt; +use std::time::Duration; + +use serde::{Serialize, Serializer}; + +/// Supported SQL database engines. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Engine { + Postgres, + MySql, + MariaDb, + Sqlite, + SqlServer, + DuckDb, +} + +impl Serialize for Engine { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl Engine { + pub(crate) fn parse(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "postgres" | "postgresql" => Some(Self::Postgres), + "mysql" => Some(Self::MySql), + "mariadb" => Some(Self::MariaDb), + "sqlite" | "sqlite3" => Some(Self::Sqlite), + "sqlserver" | "mssql" => Some(Self::SqlServer), + "duckdb" => Some(Self::DuckDb), + _ => None, + } + } + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Postgres => "postgres", + Self::MySql => "mysql", + Self::MariaDb => "mariadb", + Self::Sqlite => "sqlite", + Self::SqlServer => "sqlserver", + Self::DuckDb => "duckdb", + } + } +} + +impl fmt::Display for Engine { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConnectionSource { + pub(crate) engine: Engine, + pub(crate) raw_url: String, + pub(crate) source_redacted: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InspectOptions { + pub(crate) schemas: Vec, + pub(crate) tables: Vec, + pub(crate) include_system: bool, + pub(crate) connect_timeout: Duration, +} + +impl Default for InspectOptions { + fn default() -> Self { + Self { + schemas: Vec::new(), + tables: Vec::new(), + include_system: false, + connect_timeout: Duration::from_secs(10), + } + } +} + +impl InspectOptions { + pub(crate) fn accepts_schema(&self, schema: &str) -> bool { + self.schemas.is_empty() + || self + .schemas + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(schema)) + } + + pub(crate) fn accepts_table(&self, schema: &str, table: &str) -> bool { + self.tables.is_empty() + || self + .tables + .iter() + .any(|candidate| candidate.matches(schema, table)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TableFilter { + pub(crate) schema: Option, + pub(crate) name: String, +} + +impl TableFilter { + pub(crate) fn parse(value: &str) -> Self { + if let Some((schema, name)) = value.rsplit_once('.') { + Self { + schema: Some(schema.to_string()), + name: name.to_string(), + } + } else { + Self { + schema: None, + name: value.to_string(), + } + } + } + + fn matches(&self, schema: &str, table: &str) -> bool { + self.name.eq_ignore_ascii_case(table) + && self + .schema + .as_ref() + .is_none_or(|expected| expected.eq_ignore_ascii_case(schema)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct SqlShapeReport { + pub(crate) engine: Engine, + pub(crate) database: String, + pub(crate) source_redacted: String, + pub(crate) schemas: Vec, + pub(crate) tables: Vec, + pub(crate) warnings: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SqlObjectKind { + Table, + View, +} + +impl SqlObjectKind { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Table => "table", + Self::View => "view", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct SqlObjectSummary { + pub(crate) schema: String, + pub(crate) name: String, + pub(crate) kind: SqlObjectKind, + pub(crate) row_estimate: Option, + pub(crate) columns: Vec, + pub(crate) primary_key: Vec, + pub(crate) foreign_keys: Vec, + pub(crate) indexes: Vec, +} + +impl SqlObjectSummary { + #[cfg(test)] + pub(crate) fn identity(&self) -> String { + format!("{}.{}", self.schema, self.name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ColumnSummary { + pub(crate) name: String, + pub(crate) data_type: String, + pub(crate) nullable: bool, + pub(crate) default: Option, + pub(crate) ordinal_position: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ForeignKeySummary { + pub(crate) name: String, + pub(crate) columns: Vec, + pub(crate) referenced_schema: Option, + pub(crate) referenced_table: String, + pub(crate) referenced_columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct IndexSummary { + pub(crate) name: String, + pub(crate) unique: bool, + pub(crate) columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct SqlShapeDiff { + pub(crate) before: SqlShapeReport, + pub(crate) after: SqlShapeReport, + pub(crate) added_tables: Vec, + pub(crate) removed_tables: Vec, + pub(crate) changed_tables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct TableChange { + pub(crate) schema: String, + pub(crate) name: String, + pub(crate) kind_before: SqlObjectKind, + pub(crate) kind_after: SqlObjectKind, + pub(crate) row_estimate_before: Option, + pub(crate) row_estimate_after: Option, + pub(crate) added_columns: Vec, + pub(crate) removed_columns: Vec, + pub(crate) changed_columns: Vec, + pub(crate) primary_key_before: Vec, + pub(crate) primary_key_after: Vec, + pub(crate) added_foreign_keys: Vec, + pub(crate) removed_foreign_keys: Vec, + pub(crate) added_indexes: Vec, + pub(crate) removed_indexes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ColumnChange { + pub(crate) name: String, + pub(crate) data_type_before: String, + pub(crate) data_type_after: String, + pub(crate) nullable_before: bool, + pub(crate) nullable_after: bool, + pub(crate) default_before: Option, + pub(crate) default_after: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_parse_display_and_serialization_cover_all_aliases() { + let cases = [ + ("postgres", Engine::Postgres, "postgres"), + ("postgresql", Engine::Postgres, "postgres"), + ("mysql", Engine::MySql, "mysql"), + ("mariadb", Engine::MariaDb, "mariadb"), + ("sqlite3", Engine::Sqlite, "sqlite"), + ("mssql", Engine::SqlServer, "sqlserver"), + ("duckdb", Engine::DuckDb, "duckdb"), + ]; + + for (input, engine, label) in cases { + assert_eq!(Engine::parse(input), Some(engine)); + assert_eq!(engine.as_str(), label); + assert_eq!(engine.to_string(), label); + assert_eq!( + serde_json::to_value(engine).expect("engine serializes"), + serde_json::Value::String(label.to_string()) + ); + } + assert_eq!(Engine::parse("oracle"), None); + } + + #[test] + fn inspect_options_match_schema_and_table_filters_case_insensitively() { + let options = InspectOptions { + schemas: vec!["Main".to_string()], + tables: vec![ + TableFilter::parse("main.Users"), + TableFilter::parse("orders"), + ], + include_system: true, + connect_timeout: Duration::from_secs(3), + }; + + assert!(options.accepts_schema("main")); + assert!(!options.accepts_schema("archive")); + assert!(options.accepts_table("MAIN", "users")); + assert!(options.accepts_table("archive", "ORDERS")); + assert!(!options.accepts_table("archive", "users")); + assert_eq!(options.connect_timeout, Duration::from_secs(3)); + + let unfiltered = InspectOptions::default(); + assert!(unfiltered.accepts_schema("anything")); + assert!(unfiltered.accepts_table("anything", "users")); + } + + #[test] + fn object_identity_and_kind_labels_are_stable() { + let table = SqlObjectSummary { + schema: "main".to_string(), + name: "users".to_string(), + kind: SqlObjectKind::Table, + row_estimate: Some(10), + columns: vec![ColumnSummary { + name: "id".to_string(), + data_type: "INTEGER".to_string(), + nullable: false, + default: None, + ordinal_position: 1, + }], + primary_key: vec!["id".to_string()], + foreign_keys: vec![ForeignKeySummary { + name: "fk_users_parent".to_string(), + columns: vec!["id".to_string()], + referenced_schema: Some("main".to_string()), + referenced_table: "parents".to_string(), + referenced_columns: vec!["id".to_string()], + }], + indexes: vec![IndexSummary { + name: "idx_users_id".to_string(), + unique: true, + columns: vec!["id".to_string()], + }], + }; + + assert_eq!(table.identity(), "main.users"); + assert_eq!(SqlObjectKind::Table.as_str(), "table"); + assert_eq!(SqlObjectKind::View.as_str(), "view"); + assert_eq!( + serde_json::to_value(&table).expect("table serializes")["kind"], + serde_json::Value::String("table".to_string()) + ); + } +} diff --git a/crates/sqlshape/src/render.rs b/crates/sqlshape/src/render.rs new file mode 100644 index 0000000..a2b5e18 --- /dev/null +++ b/crates/sqlshape/src/render.rs @@ -0,0 +1,229 @@ +use std::fmt::Write as _; + +use crate::model::{SqlShapeDiff, SqlShapeReport}; + +pub(crate) fn render_report(report: &SqlShapeReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "engine={} database={} source={} schemas={} tables={}", + report.engine, + report.database, + report.source_redacted, + report.schemas.len(), + report.tables.len() + ); + for table in &report.tables { + let rows = table.row_estimate.map_or_else( + || "unknown".to_string(), + |count| format!("estimate:{count}"), + ); + let _ = writeln!( + output, + "table={}.{} kind={} rows={} columns={} pk={} indexes={} fks={}", + table.schema, + table.name, + table.kind.as_str(), + rows, + table.columns.len(), + table.primary_key.len(), + table.indexes.len(), + table.foreign_keys.len() + ); + for column in &table.columns { + let default = column.default.as_deref().unwrap_or(""); + let _ = writeln!( + output, + " column={} type={} nullable={} default={}", + column.name, column.data_type, column.nullable, default + ); + } + } + for warning in &report.warnings { + let _ = writeln!(output, "warning={warning}"); + } + output +} + +pub(crate) fn render_diff(diff: &SqlShapeDiff) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "before={} after={} added_tables={} removed_tables={} changed_tables={}", + diff.before.source_redacted, + diff.after.source_redacted, + diff.added_tables.len(), + diff.removed_tables.len(), + diff.changed_tables.len() + ); + for table in &diff.added_tables { + let _ = writeln!(output, "added_table={}.{}", table.schema, table.name); + } + for table in &diff.removed_tables { + let _ = writeln!(output, "removed_table={}.{}", table.schema, table.name); + } + for table in &diff.changed_tables { + let _ = writeln!( + output, + "changed_table={}.{} added_columns={} removed_columns={} changed_columns={}", + table.schema, + table.name, + table.added_columns.len(), + table.removed_columns.len(), + table.changed_columns.len() + ); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + ColumnChange, ColumnSummary, ConnectionSource, Engine, ForeignKeySummary, IndexSummary, + SqlObjectKind, SqlObjectSummary, TableChange, + }; + + fn report(source: &str, tables: Vec) -> SqlShapeReport { + SqlShapeReport { + engine: Engine::Sqlite, + database: "demo.db".to_string(), + source_redacted: source.to_string(), + schemas: vec!["main".to_string()], + tables, + warnings: vec!["check permissions".to_string()], + } + } + + fn table(name: &str) -> SqlObjectSummary { + SqlObjectSummary { + schema: "main".to_string(), + name: name.to_string(), + kind: SqlObjectKind::Table, + row_estimate: Some(7), + columns: vec![ + ColumnSummary { + name: "id".to_string(), + data_type: "INTEGER".to_string(), + nullable: false, + default: None, + ordinal_position: 1, + }, + ColumnSummary { + name: "name".to_string(), + data_type: "TEXT".to_string(), + nullable: true, + default: Some("'unknown'".to_string()), + ordinal_position: 2, + }, + ], + primary_key: vec!["id".to_string()], + foreign_keys: vec![ForeignKeySummary { + name: "fk_demo".to_string(), + columns: vec!["id".to_string()], + referenced_schema: Some("main".to_string()), + referenced_table: "parents".to_string(), + referenced_columns: vec!["id".to_string()], + }], + indexes: vec![IndexSummary { + name: "idx_demo_name".to_string(), + unique: false, + columns: vec!["name".to_string()], + }], + } + } + + #[test] + fn render_report_lists_tables_columns_counts_and_warnings() { + let rendered = render_report(&report("sqlite://demo", vec![table("users")])); + + assert!(rendered.contains("engine=sqlite database=demo.db")); + assert!(rendered.contains("source=sqlite://demo")); + assert!(rendered.contains("table=main.users kind=table rows=estimate:7")); + assert!(rendered.contains("columns=2 pk=1 indexes=1 fks=1")); + assert!(rendered.contains("column=name type=TEXT nullable=true default='unknown'")); + assert!(rendered.contains("warning=check permissions")); + } + + #[test] + fn render_diff_lists_added_removed_and_changed_tables() { + let before = report("before", vec![table("users")]); + let after = report("after", vec![table("accounts")]); + let diff = SqlShapeDiff { + before, + after, + added_tables: vec![table("accounts")], + removed_tables: vec![table("users")], + changed_tables: vec![TableChange { + schema: "main".to_string(), + name: "orders".to_string(), + kind_before: SqlObjectKind::Table, + kind_after: SqlObjectKind::View, + row_estimate_before: Some(1), + row_estimate_after: Some(2), + added_columns: vec![ColumnSummary { + name: "created_at".to_string(), + data_type: "TEXT".to_string(), + nullable: true, + default: None, + ordinal_position: 3, + }], + removed_columns: vec![ColumnSummary { + name: "old".to_string(), + data_type: "TEXT".to_string(), + nullable: true, + default: None, + ordinal_position: 4, + }], + changed_columns: vec![ColumnChange { + name: "name".to_string(), + data_type_before: "TEXT".to_string(), + data_type_after: "VARCHAR".to_string(), + nullable_before: true, + nullable_after: false, + default_before: None, + default_after: Some("'unknown'".to_string()), + }], + primary_key_before: vec!["id".to_string()], + primary_key_after: vec!["id".to_string(), "created_at".to_string()], + added_foreign_keys: Vec::new(), + removed_foreign_keys: Vec::new(), + added_indexes: Vec::new(), + removed_indexes: Vec::new(), + }], + }; + + let rendered = render_diff(&diff); + + assert!(rendered.contains("before=before after=after")); + assert!(rendered.contains("added_table=main.accounts")); + assert!(rendered.contains("removed_table=main.users")); + assert!(rendered.contains( + "changed_table=main.orders added_columns=1 removed_columns=1 changed_columns=1" + )); + } + + #[test] + fn render_report_marks_unknown_row_estimates() { + let mut object = table("views"); + object.kind = SqlObjectKind::View; + object.row_estimate = None; + let rendered = render_report(&SqlShapeReport { + warnings: Vec::new(), + ..report("sqlite://demo", vec![object]) + }); + + assert!(rendered.contains("kind=view rows=unknown")); + } + + #[test] + fn connection_source_import_stays_used_for_render_tests() { + let source = ConnectionSource { + engine: Engine::DuckDb, + raw_url: "duckdb://warehouse".to_string(), + source_redacted: "duckdb://warehouse".to_string(), + }; + + assert_eq!(source.engine.as_str(), "duckdb"); + } +} diff --git a/crates/sqlshape/tests/sqlshape_cli.rs b/crates/sqlshape/tests/sqlshape_cli.rs new file mode 100644 index 0000000..7d2e061 --- /dev/null +++ b/crates/sqlshape/tests/sqlshape_cli.rs @@ -0,0 +1,501 @@ +//! Integration tests for the `sqlshape` command. + +use assert_cmd::Command; +use duckdb::Connection as DuckConnection; +use mysql::prelude::Queryable as _; +use predicates::prelude::*; +use rusqlite::Connection as SqliteConnection; +use serde_json::Value; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("sqlshape").expect("binary") +} + +#[test] +fn help_includes_engines_diff_and_shared_flags() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("sqlshape [OPTIONS] --url")) + .stdout(predicate::str::contains("sqlshape [OPTIONS] diff")) + .stdout(predicate::str::contains("--format ")) + .stdout(predicate::str::contains("--engine ")) + .stdout(predicate::str::contains("--before-url ")) + .stdout(predicate::str::contains("--include-system")) + .stdout(predicate::str::contains("postgres")) + .stdout(predicate::str::contains("duckdb")); +} + +#[test] +fn ambiguous_path_requires_engine() { + let mut command = cargo_command(); + command + .args(["--url", r".\data\app.db"]) + .assert() + .code(2) + .stderr(predicate::str::contains("--engine")) + .stderr(predicate::str::contains("ambiguous")); +} + +#[test] +fn invalid_engine_errors_do_not_echo_password() { + let mut command = cargo_command(); + command + .args([ + "--engine", + "oracle", + "--url", + "postgres://user:secret@localhost/db", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("oracle")) + .stderr(predicate::str::contains("secret").not()); +} + +#[test] +fn summarizes_sqlite_schema_as_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("sample.db"); + let connection = SqliteConnection::open(&path).expect("db"); + connection + .execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT DEFAULT 'Paris'); + CREATE TABLE orders(id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, + total REAL, FOREIGN KEY(user_id) REFERENCES users(id)); + CREATE INDEX idx_orders_user_id ON orders(user_id);", + ) + .expect("schema"); + drop(connection); + + let mut command = cargo_command(); + command + .args(["--engine", "sqlite", "--url"]) + .arg(&path) + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"engine\":\"sqlite\"")) + .stdout(predicate::str::contains("\"source_redacted\"")) + .stdout(predicate::str::contains("\"schema\":\"main\"")) + .stdout(predicate::str::contains("\"name\":\"users\"")) + .stdout(predicate::str::contains("\"primary_key\":[\"id\"]")) + .stdout(predicate::str::contains("\"foreign_keys\"")) + .stdout(predicate::str::contains("\"idx_orders_user_id\"")); +} + +#[test] +fn infers_sqlite_engine_from_url_scheme() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("scheme.db"); + let connection = SqliteConnection::open(&path).expect("db"); + connection + .execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);") + .expect("schema"); + drop(connection); + + let url = sqlite_url(&path); + let mut command = cargo_command(); + command + .args(["--url", &url, "--json"]) + .assert() + .success() + .stdout(predicate::str::contains("\"engine\":\"sqlite\"")) + .stdout(predicate::str::contains("\"name\":\"users\"")); +} + +#[test] +fn supports_format_json_and_toon_flags() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("formats.db"); + let connection = SqliteConnection::open(&path).expect("db"); + connection + .execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);") + .expect("schema"); + drop(connection); + + let mut json_command = cargo_command(); + json_command + .args(["--engine", "sqlite", "--url"]) + .arg(&path) + .args(["--format", "json"]) + .assert() + .success() + .stdout(predicate::str::contains("\"engine\":\"sqlite\"")); + + let mut toon_command = cargo_command(); + toon_command + .args(["--engine", "sqlite", "--url"]) + .arg(&path) + .arg("--toon") + .assert() + .success() + .stdout(predicate::str::contains("engine: sqlite")) + .stdout(predicate::str::contains("tables:")); +} + +#[test] +fn summarizes_duckdb_schema_as_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("warehouse.duckdb"); + let connection = DuckConnection::open(&path).expect("db"); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name VARCHAR NOT NULL); + CREATE VIEW active_users AS SELECT id, name FROM users;", + ) + .expect("schema"); + drop(connection); + + let mut command = cargo_command(); + command + .args(["--engine", "duckdb", "--url"]) + .arg(&path) + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"engine\":\"duckdb\"")) + .stdout(predicate::str::contains("\"schema\":\"main\"")) + .stdout(predicate::str::contains("\"name\":\"users\"")) + .stdout(predicate::str::contains("\"kind\":\"table\"")) + .stdout(predicate::str::contains("\"name\":\"active_users\"")) + .stdout(predicate::str::contains("\"kind\":\"view\"")); +} + +#[test] +fn diff_reports_table_and_column_changes() { + let temp = tempdir().expect("tempdir"); + let before_path = temp.path().join("before.db"); + let after_path = temp.path().join("after.db"); + + let before = SqliteConnection::open(&before_path).expect("before db"); + before + .execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL);") + .expect("before schema"); + drop(before); + + let after = SqliteConnection::open(&after_path).expect("after db"); + after + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT); + CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT);", + ) + .expect("after schema"); + drop(after); + + let mut command = cargo_command(); + command + .args(["diff", "--before-engine", "sqlite", "--before-url"]) + .arg(&before_path) + .args(["--after-engine", "sqlite", "--after-url"]) + .arg(&after_path) + .arg("--json") + .assert() + .success() + .stdout(predicate::str::contains("\"added_tables\"")) + .stdout(predicate::str::contains("\"name\":\"events\"")) + .stdout(predicate::str::contains("\"changed_tables\"")) + .stdout(predicate::str::contains("\"added_columns\"")) + .stdout(predicate::str::contains("\"city\"")); +} + +#[test] +fn live_postgres_smoke_when_configured() { + let Some(database_url) = env_url("SQLSHAPE_POSTGRES_URL") else { + return; + }; + let schema = format!("sqlshape_smoke_{}", std::process::id()); + let tls = postgres_native_tls::MakeTlsConnector::new( + native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("tls connector"), + ); + let mut client = postgres::Client::connect(&database_url, tls).expect("postgres connection"); + client + .batch_execute(&format!( + "DROP SCHEMA IF EXISTS {schema} CASCADE; + CREATE SCHEMA {schema}; + CREATE TABLE {schema}.users(id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE {schema}.orders( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES {schema}.users(id) + ); + CREATE INDEX idx_orders_user_id ON {schema}.orders(user_id);" + )) + .expect("postgres fixture"); + drop(client); + + let report = run_sqlshape_json(&[ + "sqlshape", + "--engine", + "postgres", + "--url", + &database_url, + "--schema", + &schema, + "--json", + ]); + assert_report_has_table(&report, "users"); + assert_report_has_table(&report, "orders"); + assert_report_has_index(&report, "orders", "idx_orders_user_id"); + assert_report_has_foreign_key(&report, "orders"); + + let tls = postgres_native_tls::MakeTlsConnector::new( + native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("tls connector"), + ); + let mut client = postgres::Client::connect(&database_url, tls).expect("postgres cleanup"); + client + .batch_execute(&format!("DROP SCHEMA IF EXISTS {schema} CASCADE;")) + .expect("postgres cleanup"); +} + +#[test] +fn live_mysql_smoke_when_configured() { + let Some(database_url) = env_url("SQLSHAPE_MYSQL_URL") else { + return; + }; + let suffix = std::process::id(); + let users = format!("sqlshape_users_{suffix}"); + let orders = format!("sqlshape_orders_{suffix}"); + let index = format!("idx_sqlshape_orders_user_id_{suffix}"); + let opts = mysql::Opts::from_url(&database_url).expect("mysql url"); + let pool = mysql::Pool::new(opts).expect("mysql pool"); + let mut connection = pool.get_conn().expect("mysql connection"); + connection + .query_drop(format!("DROP TABLE IF EXISTS `{orders}`")) + .expect("drop orders"); + connection + .query_drop(format!("DROP TABLE IF EXISTS `{users}`")) + .expect("drop users"); + connection + .query_drop(format!( + "CREATE TABLE `{users}`(id INT NOT NULL PRIMARY KEY, name VARCHAR(64) NOT NULL) ENGINE=InnoDB" + )) + .expect("create users"); + connection + .query_drop(format!( + "CREATE TABLE `{orders}`( + id INT NOT NULL PRIMARY KEY, + user_id INT NOT NULL, + CONSTRAINT fk_{orders}_users FOREIGN KEY (user_id) REFERENCES `{users}`(id) + ) ENGINE=InnoDB" + )) + .expect("create orders"); + connection + .query_drop(format!("CREATE INDEX `{index}` ON `{orders}`(user_id)")) + .expect("create index"); + drop(connection); + + let report = run_sqlshape_json(&[ + "sqlshape", + "--engine", + "mysql", + "--url", + &database_url, + "--table", + &users, + "--table", + &orders, + "--json", + ]); + assert_report_has_table(&report, &users); + assert_report_has_table(&report, &orders); + assert_report_has_index(&report, &orders, &index); + assert_report_has_foreign_key(&report, &orders); + + let mut connection = pool.get_conn().expect("mysql cleanup connection"); + connection + .query_drop(format!("DROP TABLE IF EXISTS `{orders}`")) + .expect("drop orders"); + connection + .query_drop(format!("DROP TABLE IF EXISTS `{users}`")) + .expect("drop users"); +} + +#[test] +fn live_sqlserver_smoke_when_configured() { + let Some(database_url) = env_url("SQLSHAPE_MSSQL_URL") else { + return; + }; + let schema = format!("sqlshape_smoke_{}", std::process::id()); + let create_sql = format!( + "IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA {schema}'); + EXEC(N'CREATE SCHEMA {schema}'); + CREATE TABLE {schema}.users(id INT NOT NULL PRIMARY KEY, name NVARCHAR(64) NOT NULL); + CREATE TABLE {schema}.orders( + id INT NOT NULL PRIMARY KEY, + user_id INT NOT NULL, + CONSTRAINT fk_{schema}_orders_users FOREIGN KEY(user_id) REFERENCES {schema}.users(id) + ); + CREATE INDEX idx_{schema}_orders_user_id ON {schema}.orders(user_id);" + ); + sqlserver_batch( + &database_url, + &format!( + "DROP TABLE IF EXISTS {schema}.orders; DROP TABLE IF EXISTS {schema}.users; IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA {schema}'); {create_sql}" + ), + ); + + let report = run_sqlshape_json(&[ + "sqlshape", + "--engine", + "sqlserver", + "--url", + &database_url, + "--schema", + &schema, + "--json", + ]); + assert_report_has_table(&report, "users"); + assert_report_has_table(&report, "orders"); + assert_report_has_index(&report, "orders", &format!("idx_{schema}_orders_user_id")); + assert_report_has_foreign_key(&report, "orders"); + + sqlserver_batch( + &database_url, + &format!( + "DROP TABLE IF EXISTS {schema}.orders; + DROP TABLE IF EXISTS {schema}.users; + IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA {schema}');" + ), + ); +} + +fn env_url(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn sqlite_url(path: &std::path::Path) -> String { + format!("sqlite:///{}", path.to_string_lossy().replace('\\', "/")) +} + +fn run_sqlshape_json(args: &[&str]) -> Value { + let source_url = args + .windows(2) + .find_map(|pair| (pair[0] == "--url").then_some(pair[1])) + .unwrap_or(""); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_sqlshape")) + .args(&args[1..]) + .output() + .expect("run sqlshape"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "sqlshape failed with {}:\n{}", + output.status, + sanitize_output(&format!("{stdout}\n{stderr}"), source_url) + ); + serde_json::from_str(&stdout).unwrap_or_else(|error| { + panic!( + "invalid JSON: {error}\n{}", + sanitize_output(&stdout, source_url) + ) + }) +} + +fn sanitize_output(text: &str, source_url: &str) -> String { + let mut redacted = text.replace(source_url, ""); + if let Ok(url) = url::Url::parse(source_url) { + if let Some(password) = url.password() { + redacted = redacted.replace(password, "***"); + } + } + redacted +} + +fn assert_report_has_table(report: &Value, name: &str) { + assert!( + report["tables"] + .as_array() + .expect("tables array") + .iter() + .any(|table| table["name"] == name), + "expected table {name} in {report}" + ); +} + +fn assert_report_has_index(report: &Value, table_name: &str, index_name: &str) { + let table = report["tables"] + .as_array() + .expect("tables array") + .iter() + .find(|table| table["name"] == table_name) + .expect("table"); + assert!( + table["indexes"] + .as_array() + .expect("indexes array") + .iter() + .any(|index| index["name"] == index_name), + "expected index {index_name} on {table_name} in {report}" + ); +} + +fn assert_report_has_foreign_key(report: &Value, table_name: &str) { + let table = report["tables"] + .as_array() + .expect("tables array") + .iter() + .find(|table| table["name"] == table_name) + .expect("table"); + assert!( + !table["foreign_keys"] + .as_array() + .expect("foreign keys array") + .is_empty(), + "expected foreign key on {table_name} in {report}" + ); +} + +fn sqlserver_batch(database_url: &str, sql: &str) { + let runtime = tokio::runtime::Runtime::new().expect("sqlserver runtime"); + runtime + .block_on(async { + use tokio_util::compat::TokioAsyncWriteCompatExt as _; + + let ado = sqlserver_ado_string(database_url); + let config = tiberius::Config::from_ado_string(&ado)?; + let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; + tcp.set_nodelay(true)?; + let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?; + client.simple_query(sql).await?.into_results().await?; + Ok::<_, Box>(()) + }) + .unwrap_or_else(|error| panic!("sqlserver fixture failed: {error}")); +} + +fn sqlserver_ado_string(raw: &str) -> String { + let url = url::Url::parse(raw).expect("sqlserver url"); + let host = url.host_str().expect("sqlserver host"); + let server = url.port().map_or_else( + || format!("tcp:{host}"), + |port| format!("tcp:{host},{port}"), + ); + let mut parts = vec![format!("server={server}")]; + if !url.username().is_empty() { + parts.push(format!("User ID={}", url.username())); + } + if let Some(password) = url.password() { + parts.push(format!("Password={password}")); + } + let database = url.path().trim_start_matches('/'); + if !database.is_empty() { + parts.push(format!("Database={database}")); + } + let trust_cert = url + .query_pairs() + .any(|(key, value)| key.eq_ignore_ascii_case("trust_cert") && value == "true"); + parts.push(format!("TrustServerCertificate={trust_cert}")); + parts.join(";") +} diff --git a/crates/stringscan/Cargo.toml b/crates/stringscan/Cargo.toml new file mode 100644 index 0000000..64703d4 --- /dev/null +++ b/crates/stringscan/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "stringscan" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Extract and classify high-signal strings from opaque files." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/stringscan/src/lib.rs b/crates/stringscan/src/lib.rs new file mode 100644 index 0000000..fd7a4f9 --- /dev/null +++ b/crates/stringscan/src/lib.rs @@ -0,0 +1,1247 @@ +//! The `stringscan` command extracts and classifies strings from files. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; + +const HELP: &str = "\ +Extract and classify high-signal strings from opaque files. + +Usage: + stringscan [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --min-len Minimum printable run length to keep + --kind Restrict matches to a specific category + --unique Deduplicate repeated strings within each file + --limit Maximum number of matches to emit per file + --details Emit per-string matches in text mode + -h, --help Show this help text + -V, --version Show the command version + +Examples: + stringscan .\\fixtures\\binaries\\stringscan-sample.bin + stringscan .\\fixtures\\binaries\\stringscan-sample.bin --details --kind bepinex + fd -e bin -e exe -e dll . .\\fixtures\\binaries | stringscan --input-format lines --json | ConvertFrom-Json +"; +const MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024; + +/// CLI arguments for the `stringscan` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Minimum printable run length to keep. + min_len: usize, + /// Restrict matches to a specific category. + kind: KindFilter, + /// Deduplicate repeated strings within each file. + unique: bool, + /// Maximum number of matches to emit per file. + limit: usize, + /// Emit per-string matches after the summary line in text mode. + details: bool, + /// Explicit files to inspect when stdin is empty. + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +enum KindFilter { + /// Keep matches of every category. + All, + /// HTTP or HTTPS URLs. + Url, + /// Filesystem-looking paths. + Path, + /// Standalone DLL names. + Dll, + /// Namespace-like dotted identifiers. + Namespace, + /// Unity-related markers. + Unity, + /// .NET-related markers. + Dotnet, + /// IL2CPP-related markers. + Il2cpp, + /// `BepInEx` or `Doorstop` markers. + Bepinex, + /// Printable strings that do not fit another category. + Generic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +enum StringKind { + Url, + Path, + Dll, + Namespace, + Unity, + Dotnet, + Il2cpp, + Bepinex, + Generic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum EncodingKind { + Ascii, + Utf16Le, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExtractedString { + offset: usize, + encoding: EncodingKind, + value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClassifiedString { + offset: usize, + encoding: EncodingKind, + kind: StringKind, + value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct StringMatch { + offset: usize, + encoding: EncodingKind, + kind: StringKind, + value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct FileReport { + path: PathBuf, + is_binary: bool, + string_count: usize, + match_count: usize, + categories: Vec, + matches: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("stringscan {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + min_len: 4, + kind: KindFilter::All, + unique: false, + limit: 10, + details: false, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("min-len") => { + cli.min_len = + parse_usize_flag("--min-len", &parser_value_string(&mut parser, "--min-len")?)?; + } + Long("kind") => { + cli.kind = parse_kind_filter(&parser_value_string(&mut parser, "--kind")?)?; + } + Long("unique") => cli.unique = true, + Long("limit") => { + cli.limit = + parse_usize_flag("--limit", &parser_value_string(&mut parser, "--limit")?)?; + } + Long("details") => cli.details = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + let parsed = value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))?; + if parsed == 0 { + return Err(CliError::usage(format!("{flag} must be greater than 0"))); + } + Ok(parsed) +} + +fn parse_kind_filter(value: &str) -> Result { + match value { + "all" => Ok(KindFilter::All), + "url" => Ok(KindFilter::Url), + "path" => Ok(KindFilter::Path), + "dll" => Ok(KindFilter::Dll), + "namespace" => Ok(KindFilter::Namespace), + "unity" => Ok(KindFilter::Unity), + "dotnet" => Ok(KindFilter::Dotnet), + "il2cpp" => Ok(KindFilter::Il2cpp), + "bepinex" => Ok(KindFilter::Bepinex), + "generic" => Ok(KindFilter::Generic), + other => Err(CliError::usage(format!( + "invalid --kind value '{other}'; expected all, url, path, dll, namespace, unity, dotnet, il2cpp, bepinex, or generic" + ))), + } +} + +fn run(cli: &Cli) -> Result { + let paths = collect_paths(cli)?; + if paths.is_empty() { + return Err(CliError::usage( + "provide at least one path or pipe paths into stdin", + )); + } + + let reports = paths + .iter() + .map(|path| inspect_path(path, cli)) + .collect::, _>>()?; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&reports)?, + RenderMode::Toon => print_structured(&reports, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_reports(&reports, cli.details)), + } + + let total_matches = reports.iter().map(|report| report.match_count).sum(); + Ok(map_result_count(total_matches)) +} + +fn collect_paths(cli: &Cli) -> Result, CliError> { + if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let paths = parse_paths_from_string(&buffer, cli.common.input_format)?; + if !paths.is_empty() { + return Ok(paths); + } + } + + common::expand_input_patterns(&cli.paths, "stringscan") +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "stringscan")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn inspect_path(path: &Path, cli: &Cli) -> Result { + let bytes = read_scan_bytes(path)?; + let extraction_limit = + (cli.details && !cli.unique && cli.kind == KindFilter::All).then_some(cli.limit); + let extracted = extract_strings_with_limit(&bytes, cli.min_len, extraction_limit); + let strings = if cli.unique { + dedupe_strings(extracted) + } else { + extracted + }; + let string_count = strings.len(); + let classified = classify_extracted_strings(strings); + let categories = collect_categories(&classified); + let filtered_matches = collect_filtered_matches(classified, cli.kind); + let match_count = filtered_matches.len(); + let matches = select_display_matches(filtered_matches, cli.kind, cli.details, cli.limit); + + Ok(FileReport { + path: path.to_path_buf(), + is_binary: is_binary(&bytes), + string_count, + match_count, + categories, + matches, + }) +} + +fn read_scan_bytes(path: &Path) -> Result, CliError> { + let path_text = path.display().to_string(); + let file = fs::File::open(path) + .map_err(|error| CliError::runtime(format!("failed to read {path_text}: {error}")))?; + let mut bytes = Vec::new(); + file.take(MAX_SCAN_BYTES) + .read_to_end(&mut bytes) + .map_err(|error| CliError::runtime(format!("failed to read {path_text}: {error}")))?; + Ok(bytes) +} + +#[cfg(test)] +fn extract_strings(bytes: &[u8], min_len: usize) -> Vec { + extract_strings_with_limit(bytes, min_len, None) +} + +fn extract_strings_with_limit( + bytes: &[u8], + min_len: usize, + max_strings: Option, +) -> Vec { + let mut extracted = extract_ascii_strings(bytes, min_len, max_strings); + if max_strings.is_none_or(|limit| extracted.len() < limit) { + extracted.extend(extract_utf16le_strings( + bytes, + min_len, + max_strings.map(|limit| limit.saturating_sub(extracted.len())), + )); + } + extracted.sort_by_key(|item| item.offset); + if let Some(limit) = max_strings { + extracted.truncate(limit); + } + extracted +} + +fn extract_ascii_strings( + bytes: &[u8], + min_len: usize, + max_strings: Option, +) -> Vec { + let mut extracted = Vec::new(); + let mut start = None; + + for (index, byte) in bytes.iter().enumerate() { + if max_strings.is_some_and(|limit| extracted.len() >= limit) { + break; + } + if is_ascii_printable(*byte) { + if start.is_none() { + start = Some(index); + } + continue; + } + + if let Some(run_start) = start.take() { + push_ascii_run(&mut extracted, bytes, run_start, index, min_len); + } + } + + if max_strings.is_none_or(|limit| extracted.len() < limit) + && let Some(run_start) = start + { + push_ascii_run(&mut extracted, bytes, run_start, bytes.len(), min_len); + } + + extracted +} + +fn push_ascii_run( + extracted: &mut Vec, + bytes: &[u8], + start: usize, + end: usize, + min_len: usize, +) { + if end.saturating_sub(start) < min_len { + return; + } + + let slice = &bytes[start..end]; + let value = slice + .iter() + .map(|byte| char::from(*byte)) + .collect::(); + extracted.push(ExtractedString { + offset: start, + encoding: EncodingKind::Ascii, + value, + }); +} + +fn extract_utf16le_strings( + bytes: &[u8], + min_len: usize, + max_strings: Option, +) -> Vec { + let mut extracted = Vec::new(); + let mut index = 0; + + while index + 1 < bytes.len() { + if max_strings.is_some_and(|limit| extracted.len() >= limit) { + break; + } + if !is_utf16le_printable(bytes, index) { + index += 1; + continue; + } + + let start = index; + while index + 1 < bytes.len() && is_utf16le_printable(bytes, index) { + index += 2; + } + + let run_len = (index - start) / 2; + if run_len >= min_len { + let mut value = String::with_capacity(run_len); + for pair in bytes[start..index].chunks_exact(2) { + value.push(char::from(pair[0])); + } + extracted.push(ExtractedString { + offset: start, + encoding: EncodingKind::Utf16Le, + value, + }); + } + } + + extracted +} + +fn dedupe_strings(strings: Vec) -> Vec { + let mut seen = BTreeSet::::new(); + let mut unique = Vec::new(); + + for item in strings { + if seen.insert(item.value.clone()) { + unique.push(item); + } + } + + unique +} + +fn classify_extracted_strings(strings: Vec) -> Vec { + strings + .into_iter() + .map(|item| ClassifiedString { + offset: item.offset, + encoding: item.encoding, + kind: classify_string(&item.value), + value: item.value, + }) + .collect() +} + +fn collect_categories(strings: &[ClassifiedString]) -> Vec { + let mut categories = strings + .iter() + .map(|item| item.kind) + .collect::>() + .into_iter() + .collect::>(); + categories.sort_by_key(|kind| kind.label()); + categories +} + +fn collect_filtered_matches( + strings: Vec, + filter: KindFilter, +) -> Vec { + strings + .into_iter() + .filter(|item| filter.matches(item.kind)) + .map(|item| StringMatch { + offset: item.offset, + encoding: item.encoding, + kind: item.kind, + value: item.value, + }) + .collect() +} + +#[cfg(test)] +fn build_match(item: &ExtractedString, filter: KindFilter) -> Option { + let kind = classify_string(&item.value); + filter.matches(kind).then(|| StringMatch { + offset: item.offset, + encoding: item.encoding, + kind, + value: item.value.clone(), + }) +} + +fn select_display_matches( + matches: Vec, + filter: KindFilter, + details: bool, + limit: usize, +) -> Vec { + let should_suppress_generic = filter == KindFilter::All && !details; + let high_signal = matches.iter().any(|item| item.kind != StringKind::Generic); + + let mut filtered = matches + .into_iter() + .filter(|item| { + !(should_suppress_generic && high_signal && item.kind == StringKind::Generic) + }) + .collect::>(); + if !details { + filtered.sort_by_key(sample_sort_key); + } + filtered.truncate(limit); + filtered +} + +fn sample_sort_key(item: &StringMatch) -> (u8, usize, usize) { + ( + sample_kind_rank(item.kind), + sample_noise_score(&item.value), + item.offset, + ) +} + +const fn sample_kind_rank(kind: StringKind) -> u8 { + match kind { + StringKind::Bepinex => 0, + StringKind::Url => 1, + StringKind::Dll => 2, + StringKind::Unity => 3, + StringKind::Il2cpp => 4, + StringKind::Dotnet => 5, + StringKind::Namespace => 6, + StringKind::Path => 7, + StringKind::Generic => 8, + } +} + +fn sample_noise_score(value: &str) -> usize { + let mut alphanumeric = 0_usize; + let mut signal = 0_usize; + let mut total = 0_usize; + + for character in value.chars() { + total += 1; + if character.is_ascii_alphanumeric() { + alphanumeric += 1; + signal += 1; + continue; + } + if matches!( + character, + '.' | '_' | '-' | '\\' | '/' | ':' | ' ' | '(' | ')' | '[' | ']' + ) { + signal += 1; + } + } + + total.saturating_sub(signal) * 4 + + usize::from(alphanumeric < 3) * 8 + + usize::from(total < 6) * 4 +} + +fn classify_string(value: &str) -> StringKind { + let bytes = value.as_bytes(); + + if starts_with_ascii_case_insensitive(bytes, b"http://") + || starts_with_ascii_case_insensitive(bytes, b"https://") + { + return StringKind::Url; + } + if is_path_like(value) { + return StringKind::Path; + } + if contains_ascii_case_insensitive(bytes, b"bepinex") + || contains_ascii_case_insensitive(bytes, b"doorstop") + { + return StringKind::Bepinex; + } + if contains_ascii_case_insensitive(bytes, b"unityengine") + || contains_ascii_case_insensitive(bytes, b"unityplayer") + || contains_ascii_case_insensitive(bytes, b"assembly-csharp") + { + return StringKind::Unity; + } + if contains_ascii_case_insensitive(bytes, b"il2cpp") + || contains_ascii_case_insensitive(bytes, b"gameassembly") + || contains_ascii_case_insensitive(bytes, b"global-metadata") + || contains_ascii_case_insensitive(bytes, b"libil2cpp") + { + return StringKind::Il2cpp; + } + if bytes.eq_ignore_ascii_case(b"mscorlib") + || contains_ascii_case_insensitive(bytes, b".net") + || contains_ascii_case_insensitive(bytes, b"system.private.corelib") + || contains_ascii_case_insensitive(bytes, b"netstandard") + { + return StringKind::Dotnet; + } + if has_dll_extension(value) && !contains_path_separator(value) { + return StringKind::Dll; + } + if is_namespace_like(value) { + return StringKind::Namespace; + } + + StringKind::Generic +} + +fn starts_with_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .get(..needle.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(needle)) +} + +fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +fn render_reports(reports: &[FileReport], details: bool) -> String { + let mut rendered = String::new(); + + for report in reports { + rendered.push_str(&render_summary(report)); + rendered.push('\n'); + + if details { + for item in &report.matches { + rendered.push_str(&render_detail(item)); + rendered.push('\n'); + } + } else { + for item in report.matches.iter().take(3) { + rendered.push_str(&render_sample(item)); + rendered.push('\n'); + } + } + } + + rendered +} + +fn render_summary(report: &FileReport) -> String { + let categories = if report.categories.is_empty() { + "none".to_string() + } else { + report + .categories + .iter() + .map(|kind| kind.label()) + .collect::>() + .join(",") + }; + + format!( + "path={} binary={} strings={} matches={} categories={categories}", + report.path.display(), + report.is_binary, + report.string_count, + report.match_count + ) +} + +fn render_detail(item: &StringMatch) -> String { + format!( + "offset={} encoding={} kind={} value={}", + item.offset, + item.encoding.label(), + item.kind.label(), + item.value + ) +} + +fn render_sample(item: &StringMatch) -> String { + format!( + "sample kind={} value={}", + item.kind.label(), + compact_value(&item.value, 120) + ) +} + +fn compact_value(value: &str, max_chars: usize) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + let mut output = String::new(); + for (count, character) in escaped.chars().enumerate() { + if count >= max_chars { + output.push_str("..."); + return output; + } + output.push(character); + } + output +} + +fn is_binary(bytes: &[u8]) -> bool { + bytes.contains(&0) || bytes.iter().any(|byte| !is_text_byte(*byte)) +} + +const fn is_ascii_printable(byte: u8) -> bool { + matches!(byte, b' '..=b'~') +} + +const fn is_text_byte(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | b'\r' | b' '..=b'~') +} + +fn is_utf16le_printable(bytes: &[u8], index: usize) -> bool { + index + 1 < bytes.len() && is_ascii_printable(bytes[index]) && bytes[index + 1] == 0 +} + +fn is_path_like(value: &str) -> bool { + value.contains(":\\") + || value.starts_with("\\\\") + || (value.contains('/') && !value.starts_with("http://") && !value.starts_with("https://")) +} + +fn contains_path_separator(value: &str) -> bool { + value.contains('\\') || value.contains('/') +} + +fn has_dll_extension(value: &str) -> bool { + Path::new(value) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) +} + +fn is_namespace_like(value: &str) -> bool { + if value.contains(':') + || value.contains('\\') + || value.contains('/') + || value.contains(' ') + || !value.contains('.') + { + return false; + } + + value + .split('.') + .all(|segment| !segment.is_empty() && is_identifier_like(segment)) +} + +fn is_identifier_like(segment: &str) -> bool { + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !(first.is_ascii_alphabetic() || first == '_') { + return false; + } + + chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +impl KindFilter { + fn matches(self, kind: StringKind) -> bool { + match self { + Self::All => true, + Self::Url => kind == StringKind::Url, + Self::Path => kind == StringKind::Path, + Self::Dll => kind == StringKind::Dll, + Self::Namespace => kind == StringKind::Namespace, + Self::Unity => kind == StringKind::Unity, + Self::Dotnet => kind == StringKind::Dotnet, + Self::Il2cpp => kind == StringKind::Il2cpp, + Self::Bepinex => kind == StringKind::Bepinex, + Self::Generic => kind == StringKind::Generic, + } + } +} + +impl StringKind { + const fn label(self) -> &'static str { + match self { + Self::Url => "url", + Self::Path => "path", + Self::Dll => "dll", + Self::Namespace => "namespace", + Self::Unity => "unity", + Self::Dotnet => "dotnet", + Self::Il2cpp => "il2cpp", + Self::Bepinex => "bepinex", + Self::Generic => "generic", + } + } +} + +impl EncodingKind { + const fn label(self) -> &'static str { + match self { + Self::Ascii => "ascii", + Self::Utf16Le => "utf16le", + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use common::ColorChoice; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + #[test] + fn extract_strings_reads_ascii_and_utf16le_runs() { + let bytes = [ + 0, b'h', b't', b't', b'p', 0, 255, b'D', 0, b'o', 0, b'o', 0, b'r', 0, b's', 0, b't', + 0, b'o', 0, b'p', 0, 0, + ]; + let strings = extract_strings(&bytes, 4); + + assert_eq!(strings[0].value, "http"); + assert_eq!(strings[0].encoding, EncodingKind::Ascii); + assert_eq!(strings[1].value, "Doorstop"); + assert_eq!(strings[1].encoding, EncodingKind::Utf16Le); + } + + #[test] + fn classify_string_covers_high_signal_categories() { + assert_eq!( + classify_string("https://mods.example.com/api"), + StringKind::Url + ); + assert_eq!( + classify_string("C:\\Games\\Mercury\\BepInEx\\core\\winhttp.dll"), + StringKind::Path + ); + assert_eq!(classify_string("winhttp.dll"), StringKind::Dll); + assert_eq!( + classify_string("System.Collections.Generic"), + StringKind::Namespace + ); + assert_eq!(classify_string("UnityEngine.CoreModule"), StringKind::Unity); + assert_eq!(classify_string("mscorlib"), StringKind::Dotnet); + assert_eq!(classify_string("global-metadata.dat"), StringKind::Il2cpp); + assert_eq!(classify_string("BepInEx.Core"), StringKind::Bepinex); + assert_eq!(classify_string("plain-text"), StringKind::Generic); + } + + #[test] + fn classify_extracted_strings_attaches_kinds_once_for_filtering() { + let classified = classify_extracted_strings(vec![ + ExtractedString { + offset: 1, + encoding: EncodingKind::Ascii, + value: "BepInEx.Core".to_string(), + }, + ExtractedString { + offset: 20, + encoding: EncodingKind::Utf16Le, + value: "plain-text".to_string(), + }, + ]); + + assert_eq!(classified[0].kind, StringKind::Bepinex); + assert_eq!(classified[1].kind, StringKind::Generic); + assert_eq!( + collect_categories(&classified), + vec![StringKind::Bepinex, StringKind::Generic] + ); + + let matches = collect_filtered_matches(classified, KindFilter::Bepinex); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].offset, 1); + assert_eq!(matches[0].encoding, EncodingKind::Ascii); + assert_eq!(matches[0].kind, StringKind::Bepinex); + assert_eq!(matches[0].value, "BepInEx.Core"); + } + + #[test] + fn parse_paths_supports_lines_and_jsonl() { + let temp = tempdir().expect("tempdir"); + let sample = temp.path().join("sample.bin"); + let other = temp.path().join("other.bin"); + fs::write(&sample, "sample").expect("sample"); + fs::write(&other, "other").expect("other"); + + assert_eq!( + parse_paths_from_string(&format!("{}\n", sample.display()), InputFormat::Lines) + .expect("lines"), + vec![sample.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&sample.display().to_string()).expect("sample path"), + serde_json::to_string(&other.display().to_string()).expect("other path") + ), + InputFormat::Jsonl, + ) + .expect("jsonl"), + vec![other, sample] + ); + } + + #[test] + fn render_summary_and_detail_are_compact() { + let report = FileReport { + path: PathBuf::from("C:\\tools\\sample.bin"), + is_binary: true, + string_count: 9, + match_count: 2, + categories: vec![StringKind::Bepinex, StringKind::Unity], + matches: vec![StringMatch { + offset: 1, + encoding: EncodingKind::Ascii, + kind: StringKind::Bepinex, + value: "BepInEx.Core".to_string(), + }], + }; + + assert!(render_summary(&report).contains("categories=bepinex,unity")); + assert!(render_detail(&report.matches[0]).contains("encoding=ascii")); + assert!( + render_reports(std::slice::from_ref(&report), false).contains("sample kind=bepinex") + ); + assert!(compact_value("line1\nline2", 30).contains("\\n")); + } + + #[test] + fn default_all_filter_prefers_high_signal_matches_over_generic_noise() { + let matches = select_display_matches( + vec![ + StringMatch { + offset: 1, + encoding: EncodingKind::Ascii, + kind: StringKind::Generic, + value: "lorem".to_string(), + }, + StringMatch { + offset: 2, + encoding: EncodingKind::Ascii, + kind: StringKind::Dll, + value: "winhttp.dll".to_string(), + }, + StringMatch { + offset: 3, + encoding: EncodingKind::Ascii, + kind: StringKind::Generic, + value: "ipsum".to_string(), + }, + ], + KindFilter::All, + false, + 10, + ); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].kind, StringKind::Dll); + } + + #[test] + fn default_sample_order_prefers_readable_high_signal_values() { + let matches = select_display_matches( + vec![ + StringMatch { + offset: 1, + encoding: EncodingKind::Ascii, + kind: StringKind::Path, + value: ",/(W".to_string(), + }, + StringMatch { + offset: 2, + encoding: EncodingKind::Ascii, + kind: StringKind::Path, + value: "C:\\Games\\Mercury\\BepInEx\\core\\winhttp.dll".to_string(), + }, + StringMatch { + offset: 3, + encoding: EncodingKind::Ascii, + kind: StringKind::Dll, + value: "winhttp.dll".to_string(), + }, + ], + KindFilter::All, + false, + 3, + ); + + assert_eq!(matches[0].value, "winhttp.dll"); + assert_eq!( + matches[1].value, + "C:\\Games\\Mercury\\BepInEx\\core\\winhttp.dll" + ); + assert_eq!(matches[2].value, ",/(W"); + assert!(sample_noise_score(",/(W") > sample_noise_score("winhttp.dll")); + } + + #[test] + fn path_parsing_and_helper_filters_cover_remaining_branches() { + let temp = tempdir().expect("tempdir"); + let sample = temp.path().join("sample.bin"); + fs::write(&sample, "sample").expect("sample"); + + assert_eq!( + parse_paths_from_string(&format!("{}\n", sample.display()), InputFormat::Auto) + .expect("auto string"), + vec![sample] + ); + + assert!( + parse_paths_from_string("123\n", InputFormat::Auto) + .expect("auto number") + .is_empty() + ); + + assert!( + build_match( + &ExtractedString { + offset: 1, + encoding: EncodingKind::Ascii, + value: "BepInEx.Core".to_string(), + }, + KindFilter::Unity, + ) + .is_none() + ); + assert!( + build_match( + &ExtractedString { + offset: 1, + encoding: EncodingKind::Ascii, + value: "BepInEx.Core".to_string(), + }, + KindFilter::Bepinex, + ) + .is_some() + ); + assert!(is_path_like("/opt/mods/BepInEx/core/winhttp.dll")); + assert!(!is_path_like("https://mods.example.com/api")); + assert!(contains_path_separator("a/b")); + assert!(has_dll_extension("WINHTTP.DLL")); + assert!(is_namespace_like("Mercury.Toolbox.Core")); + assert!(!is_namespace_like("Mercury Toolbox")); + assert!(is_identifier_like("_private42")); + assert!(!is_identifier_like("42private")); + assert!(!is_binary(b"plain-text\n")); + assert!(is_binary(&[0xff, 0x00])); + } + + #[test] + fn parse_cli_rejects_zero_numeric_flags() { + let min_len_error = parse_cli_from(["stringscan", "--min-len", "0", "sample.bin"]) + .expect_err("zero min length should fail"); + assert!(matches!( + min_len_error, + CliError::Usage(message) if message.contains("--min-len must be greater than 0") + )); + + let limit_error = parse_cli_from(["stringscan", "--limit", "0", "sample.bin"]) + .expect_err("zero limit should fail"); + assert!(matches!( + limit_error, + CliError::Usage(message) if message.contains("--limit must be greater than 0") + )); + } + + #[test] + fn inspect_path_and_run_cover_unique_limits_and_no_results() { + let temp = tempdir().expect("tempdir"); + let sample = temp.path().join("sample.bin"); + fs::write( + &sample, + b"BepInEx.Core\0BepInEx.Core\0WINHTTP.DLL\0System.Private.CoreLib\0", + ) + .expect("sample"); + + let cli = Cli { + common: common_args(false, InputFormat::Auto), + min_len: 4, + kind: KindFilter::All, + unique: true, + limit: 2, + details: true, + paths: vec![sample.clone()], + }; + let report = inspect_path(&sample, &cli).expect("report"); + assert!(report.is_binary); + assert_eq!(report.string_count, 3); + assert_eq!(report.match_count, 3); + assert_eq!(report.matches.len(), 2); + assert_eq!( + report.categories, + vec![StringKind::Bepinex, StringKind::Dll, StringKind::Dotnet] + ); + + let rendered = render_reports(std::slice::from_ref(&report), true); + assert!(rendered.contains("matches=3")); + assert!(rendered.contains("kind=bepinex")); + assert!(rendered.contains("encoding=ascii")); + + let no_result_exit = run(&Cli { + common: common_args(false, InputFormat::Auto), + min_len: 4, + kind: KindFilter::Url, + unique: false, + limit: 10, + details: false, + paths: vec![sample], + }) + .expect("no-result run"); + assert_eq!(no_result_exit, ExitCode::NoResults); + } + + #[test] + fn inspect_path_stops_after_detail_limit_matches() { + let temp = tempdir().expect("tempdir"); + let sample = temp.path().join("large-ish.bin"); + fs::write(&sample, b"FIRST_VALUE\0SECOND_VALUE\0THIRD_VALUE\0").expect("sample"); + + let report = inspect_path( + &sample, + &Cli { + common: common_args(false, InputFormat::Auto), + min_len: 4, + kind: KindFilter::All, + unique: false, + limit: 1, + details: true, + paths: Vec::new(), + }, + ) + .expect("report"); + + assert_eq!(report.string_count, 1); + assert_eq!(report.match_count, 1); + assert_eq!(report.matches.len(), 1); + assert_eq!(report.matches[0].value, "FIRST_VALUE"); + } + + #[test] + fn inspect_path_hides_generic_matches_when_signal_exists() { + let temp = tempdir().expect("tempdir"); + let sample = temp.path().join("sample.bin"); + fs::write( + &sample, + b"plain-text\0winhttp.dll\0another-generic\0System.Private.CoreLib\0", + ) + .expect("sample"); + + let report = inspect_path( + &sample, + &Cli { + common: common_args(true, InputFormat::Auto), + min_len: 4, + kind: KindFilter::All, + unique: false, + limit: 10, + details: false, + paths: vec![sample.clone()], + }, + ) + .expect("report"); + + assert_eq!(report.match_count, 4); + assert!( + report + .matches + .iter() + .all(|item| item.kind != StringKind::Generic) + ); + } + + #[test] + fn parse_usize_flag_reports_invalid_text_and_zero_values() { + assert!(matches!( + parse_usize_flag("--min-len", "nope"), + Err(CliError::Usage(message)) if message.contains("invalid --min-len") + )); + assert!(matches!( + parse_usize_flag("--limit", "0"), + Err(CliError::Usage(message)) if message.contains("--limit must be greater than 0") + )); + } +} diff --git a/crates/stringscan/src/main.rs b/crates/stringscan/src/main.rs new file mode 100644 index 0000000..8c85e40 --- /dev/null +++ b/crates/stringscan/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `stringscan`. + +fn main() { + std::process::exit(stringscan::main_entry()); +} diff --git a/crates/stringscan/tests/stringscan_cli.rs b/crates/stringscan/tests/stringscan_cli.rs new file mode 100644 index 0000000..b365ed2 --- /dev/null +++ b/crates/stringscan/tests/stringscan_cli.rs @@ -0,0 +1,125 @@ +//! Integration tests for the `stringscan` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("stringscan").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn fixture_relative(path: &str) -> PathBuf { + PathBuf::from("fixtures").join(path) +} + +fn fixture(path: &str) -> PathBuf { + workspace_root().join(fixture_relative(path)) +} + +fn relative_to_workspace(path: &str) -> String { + fixture_relative(path).display().to_string() +} + +fn pwsh_command(script: impl AsRef) -> Command { + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script.as_ref()); + command +} + +#[test] +fn summarizes_high_signal_categories_in_text_mode() { + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg(fixture("binaries/stringscan-sample.bin")) + .assert() + .success() + .stdout(predicate::str::contains("binary=true")) + .stdout(predicate::str::contains( + "categories=bepinex,dll,dotnet,il2cpp,namespace,path,unity,url", + )) + .stdout(predicate::str::contains("matches=")); +} + +#[test] +fn filters_detail_output_by_kind() { + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg("--details") + .arg("--kind") + .arg("bepinex") + .arg("--limit") + .arg("2") + .arg(fixture("binaries/stringscan-sample.bin")) + .assert() + .success() + .stdout(predicate::str::contains("kind=bepinex")) + .stdout(predicate::str::contains("BepInEx.Core")) + .stdout(predicate::str::contains("Doorstop.Entrypoint")) + .stdout(predicate::str::contains("kind=unity").not()); +} + +#[test] +fn emits_json_output_shape_for_filtered_matches() { + let mut command = cargo_command(); + command + .current_dir(workspace_root()) + .arg("--json") + .arg("--kind") + .arg("dll") + .arg("--limit") + .arg("1") + .arg(fixture("binaries/stringscan-sample.bin")) + .assert() + .success() + .stdout(predicate::str::contains("\"is_binary\":true")) + .stdout(predicate::str::contains("\"string_count\":")) + .stdout(predicate::str::contains("\"categories\":[")) + .stdout(predicate::str::contains("\"kind\":\"dll\"")) + .stdout(predicate::str::contains("\"value\":\"winhttp.dll\"")) + .stdout(predicate::str::contains("\"encoding\":\"ascii\"")); +} + +#[test] +fn supports_powershell_path_pipeline() { + let binary = assert_cmd::cargo::cargo_bin("stringscan"); + let input = relative_to_workspace("binaries/stringscan-sample.bin"); + let script = format!("'{input}' | & '{}' --json --kind unity", binary.display()); + + let mut command = pwsh_command(script); + command + .current_dir(workspace_root()) + .assert() + .success() + .stdout(predicate::str::contains("\"kind\":\"unity\"")) + .stdout(predicate::str::contains( + "\"value\":\"UnityEngine.CoreModule\"", + )); +} + +#[test] +fn help_includes_detail_and_powershell_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--details")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains( + "stringscan .\\fixtures\\binaries\\stringscan-sample.bin", + )); +} diff --git a/crates/sysshape/Cargo.toml b/crates/sysshape/Cargo.toml new file mode 100644 index 0000000..97505c2 --- /dev/null +++ b/crates/sysshape/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "sysshape" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Capture a compact local system and tool inventory for AI-friendly triage." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Registry", "Win32_System_SystemInformation", "Wdk_System_SystemServices"] } + +[target.'cfg(not(windows))'.dependencies] +sysinfo.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/sysshape/src/lib.rs b/crates/sysshape/src/lib.rs new file mode 100644 index 0000000..45e27b7 --- /dev/null +++ b/crates/sysshape/src/lib.rs @@ -0,0 +1,2073 @@ +//! The `sysshape` command captures a compact local system and tool inventory. +#![allow(clippy::multiple_crate_versions)] + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, + print_json, print_quick_help_error, print_structured, +}; +use lexopt::prelude::{Long, Short}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::fs; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; +#[cfg(not(windows))] +use sysinfo::{MemoryRefreshKind, RefreshKind, System}; +#[cfg(windows)] +use windows_sys::Wdk::System::SystemServices::RtlGetVersion; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Registry::{ + HKEY_LOCAL_MACHINE, KEY_READ, RegCloseKey, RegOpenKeyExW, RegQueryValueExW, +}; +#[cfg(windows)] +use windows_sys::Win32::System::SystemInformation::{ + GetLogicalProcessorInformationEx, GlobalMemoryStatusEx, MEMORYSTATUSEX, OSVERSIONINFOW, + RelationProcessorCore, +}; + +const HELP: &str = "\ +Capture a compact local system and tool inventory for AI-friendly triage. + +Usage: + sysshape [OPTIONS] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --env Environment detail level: none, safe, all + --probe-timeout-ms Maximum time to wait for each tool version probe + --group Tool group: core, dev, build, shell, package, container, gpu, reverse, all + -h, --help Show this help text + -V, --version Show the command version + +Examples: + sysshape --json --env safe --group all + sysshape --group shell + +JSON fields: + system.default_shell, system.session_shell, environment, path_like[].segment_count, path_like[].duplicate_count, path_like[].omitted_segments, path_like[].omitted_duplicate_segments, path_like[].segments_are_previews, path_like[].duplicate_segments_are_previews, path_like[].segments, path_like[].duplicate_segments, tools[].path, tools[].version, tools[].probe_status, tools[].error +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + env_mode: EnvMode, + probe_timeout: Duration, + group: ToolGroup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnvMode { + None, + Safe, + All, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ToolGroup { + Core, + Dev, + Build, + Shell, + Package, + Container, + Gpu, + Reverse, + All, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SystemSummary { + os_name: Option, + os_version: Option, + kernel_version: Option, + architecture: String, + hostname: Option, + cpu_model: Option, + logical_cores: usize, + physical_cores: Option, + total_memory_bytes: u64, + available_memory_bytes: u64, + default_shell: Option, + session_shell: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PathLikeSummary { + name: String, + segment_count: usize, + duplicate_count: usize, + omitted_segments: usize, + omitted_duplicate_segments: usize, + segments_are_previews: bool, + duplicate_segments_are_previews: bool, + segments: Vec, + duplicate_segments: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ToolProbeStatus { + Ok, + Failed, + TimedOut, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolProbeOutcome { + version: Option, + status: ToolProbeStatus, + error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ToolInfo { + name: String, + group: ToolGroup, + path: String, + version: Option, + probe_status: ToolProbeStatus, + error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SysshapeReport { + system: SystemSummary, + environment: BTreeMap, + path_like: Vec, + tools: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct ToolSpec { + name: &'static str, + group: ToolGroup, + version_args: &'static [&'static str], +} + +const TOOL_SPECS: &[ToolSpec] = &[ + ToolSpec { + name: "git", + group: ToolGroup::Core, + version_args: &["--version"], + }, + ToolSpec { + name: "rg", + group: ToolGroup::Core, + version_args: &["--version"], + }, + ToolSpec { + name: "fd", + group: ToolGroup::Core, + version_args: &["--version"], + }, + ToolSpec { + name: "jq", + group: ToolGroup::Core, + version_args: &["--version"], + }, + ToolSpec { + name: "bat", + group: ToolGroup::Core, + version_args: &["--version"], + }, + ToolSpec { + name: "eza", + group: ToolGroup::Core, + version_args: &["--version"], + }, + ToolSpec { + name: "cargo", + group: ToolGroup::Dev, + version_args: &["--version"], + }, + ToolSpec { + name: "rustc", + group: ToolGroup::Dev, + version_args: &["--version"], + }, + ToolSpec { + name: "dotnet", + group: ToolGroup::Dev, + version_args: &["--version"], + }, + ToolSpec { + name: "python", + group: ToolGroup::Dev, + version_args: &["--version"], + }, + ToolSpec { + name: "python3", + group: ToolGroup::Dev, + version_args: &["--version"], + }, + ToolSpec { + name: "node", + group: ToolGroup::Dev, + version_args: &["--version"], + }, + ToolSpec { + name: "go", + group: ToolGroup::Dev, + version_args: &["version"], + }, + ToolSpec { + name: "cmake", + group: ToolGroup::Build, + version_args: &["--version"], + }, + ToolSpec { + name: "ninja", + group: ToolGroup::Build, + version_args: &["--version"], + }, + ToolSpec { + name: "clang", + group: ToolGroup::Build, + version_args: &["--version"], + }, + ToolSpec { + name: "pwsh", + group: ToolGroup::Shell, + version_args: &[ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ], + }, + ToolSpec { + name: "powershell", + group: ToolGroup::Shell, + version_args: &[ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ], + }, + ToolSpec { + name: "cmd", + group: ToolGroup::Shell, + version_args: &["/d", "/c", "ver"], + }, + ToolSpec { + name: "bash", + group: ToolGroup::Shell, + version_args: &["--version"], + }, + ToolSpec { + name: "npm", + group: ToolGroup::Package, + version_args: &["--version"], + }, + ToolSpec { + name: "pnpm", + group: ToolGroup::Package, + version_args: &["--version"], + }, + ToolSpec { + name: "bun", + group: ToolGroup::Package, + version_args: &["--version"], + }, + ToolSpec { + name: "scoop", + group: ToolGroup::Package, + version_args: &["--version"], + }, + ToolSpec { + name: "winget", + group: ToolGroup::Package, + version_args: &["--version"], + }, + ToolSpec { + name: "uv", + group: ToolGroup::Package, + version_args: &["--version"], + }, + ToolSpec { + name: "docker", + group: ToolGroup::Container, + version_args: &["--version"], + }, + ToolSpec { + name: "podman", + group: ToolGroup::Container, + version_args: &["--version"], + }, + ToolSpec { + name: "kubectl", + group: ToolGroup::Container, + version_args: &["version", "--client=true", "--short"], + }, + ToolSpec { + name: "nvidia-smi", + group: ToolGroup::Gpu, + version_args: &["--version"], + }, + ToolSpec { + name: "nvcc", + group: ToolGroup::Gpu, + version_args: &["--version"], + }, + ToolSpec { + name: "ida64", + group: ToolGroup::Reverse, + version_args: &["-h"], + }, + ToolSpec { + name: "ilspycmd", + group: ToolGroup::Reverse, + version_args: &["--version"], + }, + ToolSpec { + name: "adb", + group: ToolGroup::Reverse, + version_args: &["version"], + }, + ToolSpec { + name: "frida", + group: ToolGroup::Reverse, + version_args: &["--version"], + }, +]; + +const SAFE_ENV_NAMES: &[&str] = &[ + "COMSPEC", + "TERM", + "SHELL", + "USERPROFILE", + "HOME", + "TEMP", + "TMP", + "CARGO_HOME", + "RUSTUP_HOME", + "DOTNET_ROOT", + "JAVA_HOME", + "GOPATH", + "GOROOT", + "PYTHONPATH", + "VIRTUAL_ENV", + "CONDA_PREFIX", + "CUDA_PATH", + "CUDA_HOME", +]; + +const PATH_LIKE_NAMES: &[&str] = &["PATH", "PATHEXT", "PSMODULEPATH"]; +const PATH_LIKE_SEGMENT_PREVIEW_LIMIT: usize = 12; +const PATH_LIKE_DUPLICATE_PREVIEW_LIMIT: usize = 8; + +#[cfg(windows)] +#[derive(Debug, Clone, Copy)] +#[repr(C)] +struct LogicalProcessorInfoHeader { + relationship: u32, + size: u32, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("sysshape {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut env_mode = EnvMode::Safe; + let mut probe_timeout = Duration::from_millis(750); + let mut group = ToolGroup::All; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + env_mode, + probe_timeout, + group, + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + env_mode, + probe_timeout, + group, + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("env") => env_mode = parse_env_mode(&parser_value_string(&mut parser, "--env")?)?, + Long("probe-timeout-ms") => { + probe_timeout = Duration::from_millis(parse_u64_flag( + "--probe-timeout-ms", + &parser_value_string(&mut parser, "--probe-timeout-ms")?, + )?); + } + Long("group") => { + group = parse_tool_group(&parser_value_string(&mut parser, "--group")?)?; + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok(( + ParseOutcome::Run, + Cli { + common, + env_mode, + probe_timeout, + group, + }, + )) +} + +fn run(cli: &Cli) -> Result { + let report = build_report(cli)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => print!("{}", render_text(&report)), + } + Ok(ExitCode::Success) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_u64_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_env_mode(value: &str) -> Result { + match value { + "none" => Ok(EnvMode::None), + "safe" => Ok(EnvMode::Safe), + "all" => Ok(EnvMode::All), + other => Err(CliError::usage(format!( + "invalid --env value '{other}'; expected none, safe, or all" + ))), + } +} + +fn parse_tool_group(value: &str) -> Result { + match value { + "core" => Ok(ToolGroup::Core), + "dev" => Ok(ToolGroup::Dev), + "build" => Ok(ToolGroup::Build), + "shell" => Ok(ToolGroup::Shell), + "package" => Ok(ToolGroup::Package), + "container" => Ok(ToolGroup::Container), + "gpu" => Ok(ToolGroup::Gpu), + "reverse" => Ok(ToolGroup::Reverse), + "all" => Ok(ToolGroup::All), + other => Err(CliError::usage(format!( + "invalid --group value '{other}'; expected core, dev, build, shell, package, container, gpu, reverse, or all" + ))), + } +} + +fn build_report(cli: &Cli) -> Result { + #[cfg(not(windows))] + let system = System::new_with_specifics( + RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram()), + ); + + Ok(SysshapeReport { + #[cfg(not(windows))] + system: capture_system_summary(&system), + #[cfg(windows)] + system: capture_system_summary(), + environment: capture_environment(cli.env_mode), + path_like: capture_path_like(), + tools: probe_tools(cli.group, cli.probe_timeout)?, + }) +} + +#[cfg(not(windows))] +fn capture_system_summary(system: &System) -> SystemSummary { + SystemSummary { + os_name: System::name(), + os_version: System::os_version(), + kernel_version: System::kernel_version(), + architecture: std::env::consts::ARCH.to_string(), + hostname: System::host_name(), + cpu_model: cpu_model_hint(), + logical_cores: std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get), + physical_cores: System::physical_core_count(), + total_memory_bytes: system.total_memory(), + available_memory_bytes: system.available_memory(), + default_shell: default_shell_hint(), + session_shell: current_shell_hint(), + } +} + +#[cfg(windows)] +fn capture_system_summary() -> SystemSummary { + let (os_version, kernel_version) = windows_version_fields(); + let (total_memory_bytes, available_memory_bytes) = memory_snapshot_windows(); + + SystemSummary { + os_name: Some("Windows".to_string()), + os_version, + kernel_version, + architecture: std::env::consts::ARCH.to_string(), + hostname: hostname_hint_windows(), + cpu_model: cpu_model_hint(), + logical_cores: std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get), + physical_cores: physical_core_count_windows(), + total_memory_bytes, + available_memory_bytes, + default_shell: default_shell_hint(), + session_shell: current_shell_hint(), + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn windows_version_fields() -> (Option, Option) { + let mut version = OSVERSIONINFOW { + dwOSVersionInfoSize: u32::try_from(std::mem::size_of::()) + .expect("OSVERSIONINFOW size always fits in u32"), + ..OSVERSIONINFOW::default() + }; + let status = unsafe { RtlGetVersion(&raw mut version) }; + if status < 0 { + return (None, None); + } + + let build = version.dwBuildNumber; + let marketing = if version.dwMajorVersion == 10 { + if build >= 22_000 { + "11".to_string() + } else { + "10".to_string() + } + } else { + format!("{}.{}", version.dwMajorVersion, version.dwMinorVersion) + }; + + ( + Some(format!("{marketing} ({build})")), + Some(build.to_string()), + ) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn memory_snapshot_windows() -> (u64, u64) { + let mut memory = MEMORYSTATUSEX { + dwLength: u32::try_from(std::mem::size_of::()) + .expect("MEMORYSTATUSEX size always fits in u32"), + ..MEMORYSTATUSEX::default() + }; + if unsafe { GlobalMemoryStatusEx(&raw mut memory) } == 0 { + return (0, 0); + } + + (memory.ullTotalPhys, memory.ullAvailPhys) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn physical_core_count_windows() -> Option { + let mut needed = 0_u32; + unsafe { + let _ = GetLogicalProcessorInformationEx( + RelationProcessorCore, + std::ptr::null_mut(), + &raw mut needed, + ); + } + if needed == 0 { + return None; + } + + let word_size = std::mem::size_of::(); + let mut buffer = vec![0_usize; usize::try_from(needed).ok()?.div_ceil(word_size)]; + if unsafe { + GetLogicalProcessorInformationEx( + RelationProcessorCore, + buffer.as_mut_ptr().cast(), + &raw mut needed, + ) + } == 0 + { + return None; + } + + let mut count = 0_usize; + let mut offset = 0_usize; + let limit = usize::try_from(needed).ok()?; + let buffer_ptr = buffer.as_ptr().cast::(); + while offset < limit { + let info = unsafe { + std::ptr::read_unaligned(buffer_ptr.add(offset).cast::()) + }; + let size = usize::try_from(info.size).ok()?; + if size == 0 { + return None; + } + if info.relationship == u32::try_from(RelationProcessorCore).ok()? { + count += 1; + } + offset += size; + } + + Some(count) +} + +#[cfg(windows)] +fn hostname_hint_windows() -> Option { + std::env::var("COMPUTERNAME") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn cpu_model_hint() -> Option { + for name in ["PROCESSOR_IDENTIFIER", "CPU", "HOSTTYPE"] { + if let Ok(value) = std::env::var(name) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn default_shell_hint() -> Option { + std::env::var("SHELL") + .ok() + .or_else(|| std::env::var("COMSPEC").ok()) + .map(|value| { + Path::new(&value) + .file_name() + .and_then(|name| name.to_str()) + .and_then(normalize_shell_name) + .map(str::to_string) + .unwrap_or(value) + }) + .or_else(|| { + std::env::var("PSModulePath") + .ok() + .map(|_| "powershell".to_string()) + }) +} + +fn current_shell_hint() -> Option { + if let Some(shell) = current_shell_hint_from_environment() { + return Some(shell); + } + default_shell_hint() +} + +fn current_shell_hint_from_environment() -> Option { + if std::env::var_os("POWERSHELL_DISTRIBUTION_CHANNEL").is_some() { + return Some("pwsh".to_string()); + } + if std::env::var_os("NU_VERSION").is_some() { + return Some("nu".to_string()); + } + if std::env::var_os("BASH_VERSION").is_some() { + return Some("bash".to_string()); + } + if std::env::var_os("FISH_VERSION").is_some() { + return Some("fish".to_string()); + } + None +} + +fn normalize_shell_name(name: &str) -> Option<&'static str> { + match name { + "pwsh" | "pwsh.exe" => Some("pwsh"), + "powershell" | "powershell.exe" => Some("powershell"), + "cmd" | "cmd.exe" => Some("cmd"), + "bash" | "bash.exe" => Some("bash"), + "zsh" => Some("zsh"), + "fish" => Some("fish"), + "nu" | "nu.exe" => Some("nu"), + _ => None, + } +} + +fn capture_environment(mode: EnvMode) -> BTreeMap { + match mode { + EnvMode::None => BTreeMap::new(), + EnvMode::Safe => SAFE_ENV_NAMES + .iter() + .filter_map(|name| { + std::env::var(name) + .ok() + .map(|value| ((*name).to_string(), value)) + }) + .collect(), + EnvMode::All => std::env::vars().collect(), + } +} + +fn capture_path_like() -> Vec { + PATH_LIKE_NAMES + .iter() + .filter_map(|name| { + std::env::var(name).ok().map(|value| { + let segments = split_path_like(name, &value); + let duplicate_segments = duplicate_segments(&segments); + let segment_count = segments.len(); + let duplicate_count = duplicate_segments.len(); + let (segments, omitted_segments) = + truncate_preview(&segments, PATH_LIKE_SEGMENT_PREVIEW_LIMIT); + let (duplicate_segments, omitted_duplicate_segments) = + truncate_preview(&duplicate_segments, PATH_LIKE_DUPLICATE_PREVIEW_LIMIT); + PathLikeSummary { + name: (*name).to_string(), + segment_count, + duplicate_count, + omitted_segments, + omitted_duplicate_segments, + segments_are_previews: true, + duplicate_segments_are_previews: true, + segments, + duplicate_segments, + } + }) + }) + .collect() +} + +fn split_path_like(name: &str, value: &str) -> Vec { + let separator = if cfg!(windows) || name.eq_ignore_ascii_case("PATHEXT") { + ';' + } else { + ':' + }; + value + .split(separator) + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect() +} + +fn duplicate_segments(segments: &[String]) -> Vec { + let mut seen = BTreeSet::new(); + let mut duplicates = BTreeSet::new(); + for segment in segments { + let normalized = segment.to_ascii_lowercase(); + if !seen.insert(normalized) { + duplicates.insert(segment.clone()); + } + } + duplicates.into_iter().collect() +} + +fn truncate_preview(segments: &[String], limit: usize) -> (Vec, usize) { + let preview = segments + .iter() + .take(limit) + .map(|segment| compact_path_like_segment(segment)) + .collect::>(); + (preview, segments.len().saturating_sub(limit)) +} + +fn probe_tools(group: ToolGroup, timeout: Duration) -> Result, CliError> { + let specs = TOOL_SPECS + .iter() + .copied() + .filter(|spec| matches_group(spec.group, group)) + .collect::>(); + let resolver = CommandResolver::new(); + + let mut tools = thread::scope(|scope| { + let handles = specs + .into_iter() + .map(|spec| { + let resolver = resolver.clone(); + scope.spawn(move || -> Result, CliError> { + let Some(path) = resolver.resolve(spec.name) else { + return Ok(None); + }; + let probe = probe_tool_version(&path, spec.name, spec.version_args, timeout)?; + Ok(Some(ToolInfo { + name: spec.name.to_string(), + group: spec.group, + path: path.display().to_string(), + version: probe.version, + probe_status: probe.status, + error: probe.error, + })) + }) + }) + .collect::>(); + + let mut collected = Vec::new(); + for handle in handles { + let result = handle + .join() + .map_err(|_| CliError::runtime("tool probe worker panicked"))?; + if let Some(tool) = result? { + collected.push(tool); + } + } + Ok::, CliError>(collected) + })?; + + tools.sort_by(|left, right| { + (group_order(left.group), left.name.as_str()) + .cmp(&(group_order(right.group), right.name.as_str())) + }); + Ok(tools) +} + +#[derive(Debug, Clone)] +struct CommandResolver { + comspec: Option, + paths: Vec, + extensions: Vec, +} + +impl CommandResolver { + fn new() -> Self { + Self { + comspec: std::env::var_os("COMSPEC") + .map(PathBuf::from) + .filter(|path| path.exists()), + paths: std::env::var_os("PATH") + .map(|value| std::env::split_paths(&value).collect()) + .unwrap_or_default(), + extensions: executable_extensions("tool"), + } + } + + fn resolve(&self, name: &str) -> Option { + if cfg!(windows) + && name.eq_ignore_ascii_case("cmd") + && let Some(comspec) = &self.comspec + { + return Some(comspec.clone()); + } + + let candidate_path = Path::new(name); + if candidate_path.components().count() > 1 && candidate_path.exists() { + return Some(candidate_path.to_path_buf()); + } + + let extensions = if Path::new(name).extension().is_some() || !cfg!(windows) { + vec![String::new()] + } else { + self.extensions.clone() + }; + for directory in &self.paths { + for extension in &extensions { + let candidate = if extension.is_empty() { + directory.join(name) + } else { + directory.join(format!("{name}{extension}")) + }; + if candidate.exists() { + return Some(candidate); + } + } + } + None + } +} + +fn matches_group(tool_group: ToolGroup, selected: ToolGroup) -> bool { + selected == ToolGroup::All || tool_group == selected +} + +const fn group_order(group: ToolGroup) -> u8 { + match group { + ToolGroup::Core => 0, + ToolGroup::Dev => 1, + ToolGroup::Build => 2, + ToolGroup::Shell => 3, + ToolGroup::Package => 4, + ToolGroup::Container => 5, + ToolGroup::Gpu => 6, + ToolGroup::Reverse => 7, + ToolGroup::All => 8, + } +} + +#[cfg(test)] +fn resolve_command_path(name: &str) -> Option { + CommandResolver::new().resolve(name) +} + +fn executable_extensions(name: &str) -> Vec { + if Path::new(name).extension().is_some() || !cfg!(windows) { + return vec![String::new()]; + } + + std::env::var("PATHEXT") + .ok() + .map(|value| { + value + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>() + }) + .filter(|entries| !entries.is_empty()) + .unwrap_or_else(|| vec![".exe".to_string(), ".cmd".to_string(), ".bat".to_string()]) +} + +fn probe_tool_version( + path: &Path, + name: &str, + args: &[&str], + timeout: Duration, +) -> Result { + if let Some(version) = tool_version_hint(name, path) { + return Ok(ToolProbeOutcome { + version: Some(version), + status: ToolProbeStatus::Ok, + error: None, + }); + } + + let mut child = Command::new(path) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + CliError::runtime(format!("failed to launch {}: {error}", path.display())) + })?; + + let started = Instant::now(); + loop { + if child + .try_wait() + .map_err(|error| { + CliError::runtime(format!("failed to poll {}: {error}", path.display())) + })? + .is_some() + { + let output = child.wait_with_output().map_err(|error| { + CliError::runtime(format!("failed to read {} output: {error}", path.display())) + })?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let first_stdout = first_non_empty_line(&stdout); + let first_stderr = first_non_empty_line(&stderr); + if !output.status.success() { + return Ok(ToolProbeOutcome { + version: None, + status: ToolProbeStatus::Failed, + error: first_stderr.or(first_stdout), + }); + } + return Ok(ToolProbeOutcome { + version: first_stdout.or(first_stderr), + status: ToolProbeStatus::Ok, + error: None, + }); + } + + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Ok(ToolProbeOutcome { + version: None, + status: ToolProbeStatus::TimedOut, + error: None, + }); + } + thread::sleep(probe_poll_interval(started.elapsed(), timeout)); + } +} + +fn tool_version_hint(name: &str, path: &Path) -> Option { + #[cfg(windows)] + { + if let Some(version) = windows_shell_version_hint(name, path) { + return Some(version); + } + if name.eq_ignore_ascii_case("pwsh") + && let Some(version) = powershell_core_version_from_registry(path) + { + return Some(version); + } + if name.eq_ignore_ascii_case("powershell") + && let Some(version) = classic_powershell_version_from_registry() + { + return Some(version); + } + if name.eq_ignore_ascii_case("winget") + && let Some(version) = file_version_string(path) + { + return Some(version); + } + } + + if name.eq_ignore_ascii_case("npm") { + return npm_version_hint(path); + } + if name.eq_ignore_ascii_case("pnpm") { + return pnpm_version_hint(path); + } + if name.eq_ignore_ascii_case("scoop") { + return scoop_version_hint(path); + } + None +} + +fn probe_poll_interval(elapsed: Duration, timeout: Duration) -> Duration { + let preferred = if elapsed < Duration::from_millis(20) { + Duration::from_millis(1) + } else if elapsed < Duration::from_millis(100) { + Duration::from_millis(4) + } else { + Duration::from_millis(10) + }; + let remaining = timeout.saturating_sub(elapsed); + if remaining.is_zero() { + Duration::from_millis(1) + } else { + preferred.min(remaining) + } +} + +fn npm_version_hint(path: &Path) -> Option { + package_json_version( + &path + .parent()? + .join("node_modules") + .join("npm") + .join("package.json"), + ) +} + +fn pnpm_version_hint(path: &Path) -> Option { + version_from_path_segment(path, "pnpm-exe").or_else(|| { + fs::read_to_string(path) + .ok() + .and_then(|script| pnpm_wrapper_version(&script)) + }) +} + +fn scoop_version_hint(path: &Path) -> Option { + scoop_repo_root(path).and_then(|repo_root| git_describe_tags(&repo_root)) +} + +fn package_json_version(path: &Path) -> Option { + let source = fs::read_to_string(path).ok()?; + json_string_field(&source, "version") +} + +fn version_from_path_segment(path: &Path, marker: &str) -> Option { + let mut return_next = false; + for component in path.components() { + let text = component.as_os_str().to_string_lossy(); + if return_next { + return Some(text.into_owned()); + } + return_next = text.eq_ignore_ascii_case(marker); + } + None +} + +fn pnpm_wrapper_version(script: &str) -> Option { + script.lines().find_map(|line| { + let marker = "pnpm-exe\\"; + let start = line.find(marker)? + marker.len(); + let tail = &line[start..]; + let end = tail.find("\\pnpm.exe")?; + let version = tail[..end].trim(); + (!version.is_empty()).then_some(version.to_string()) + }) +} + +fn scoop_repo_root(path: &Path) -> Option { + let parent = path.parent()?; + if parent + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("shims")) + { + let root = parent.parent()?.join("apps").join("scoop").join("current"); + return root.exists().then_some(root); + } + let source = fs::read_to_string(path).ok()?; + source.lines().find_map(|line| { + let trimmed = line.trim_start(); + if !trimmed.starts_with("@rem ") { + return None; + } + let script = PathBuf::from(trimmed.trim_start_matches("@rem ").trim()); + let repo_root = script.parent()?.parent()?.to_path_buf(); + repo_root.exists().then_some(repo_root) + }) +} + +fn git_describe_tags(repo_root: &Path) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["describe", "--tags", "--always"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + first_non_empty_line(&String::from_utf8_lossy(&output.stdout)) +} + +fn json_string_field(source: &str, key: &str) -> Option { + let needle = format!("\"{key}\""); + let start = source.find(&needle)? + needle.len(); + let tail = source.get(start..)?.trim_start(); + let colon_offset = tail.find(':')?; + let value = tail.get(colon_offset + 1..)?.trim_start(); + let (parsed, _) = parse_json_string(value)?; + Some(parsed) +} + +fn parse_json_string(value: &str) -> Option<(String, usize)> { + let mut chars = value.char_indices(); + let (_, opening) = chars.next()?; + if opening != '"' { + return None; + } + + let mut parsed = String::new(); + let mut escaped = false; + for (index, character) in chars { + if escaped { + match character { + '"' | '\\' | '/' => parsed.push(character), + 'b' => parsed.push('\u{0008}'), + 'f' => parsed.push('\u{000C}'), + 'n' => parsed.push('\n'), + 'r' => parsed.push('\r'), + 't' => parsed.push('\t'), + _ => return None, + } + escaped = false; + continue; + } + match character { + '\\' => escaped = true, + '"' => return Some((parsed, index + character.len_utf8())), + _ => parsed.push(character), + } + } + None +} + +#[cfg(windows)] +fn windows_shell_version_hint(name: &str, path: &Path) -> Option { + if name.eq_ignore_ascii_case("pwsh") { + return file_version_string(path); + } + if name.eq_ignore_ascii_case("powershell") { + return registry_string_value( + r"SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine", + "PowerShellVersion", + ) + .or_else(|| file_version_string(path)); + } + if name.eq_ignore_ascii_case("cmd") { + return file_version_string(path); + } + None +} + +#[cfg(not(windows))] +fn windows_shell_version_hint(_name: &str, _path: &Path) -> Option { + None +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn file_version_string(path: &Path) -> Option { + let path_wide = wide_null(path.as_os_str()); + let mut unused_handle = 0_u32; + let version_size = + unsafe { GetFileVersionInfoSizeW(path_wide.as_ptr(), &raw mut unused_handle) }; + if version_size == 0 { + return None; + } + + let mut buffer = vec![0_u8; usize::try_from(version_size).ok()?]; + if unsafe { + GetFileVersionInfoW( + path_wide.as_ptr(), + 0, + version_size, + buffer.as_mut_ptr().cast(), + ) + } == 0 + { + return None; + } + + let slash = wide_null(Path::new("\\").as_os_str()); + let mut version_ptr = std::ptr::null_mut(); + let mut version_len = 0_u32; + if unsafe { + VerQueryValueW( + buffer.as_ptr().cast(), + slash.as_ptr(), + &raw mut version_ptr, + &raw mut version_len, + ) + } == 0 + || usize::try_from(version_len).ok()? < std::mem::size_of::() + { + return None; + } + + let version = unsafe { &*version_ptr.cast::() }; + Some(format_version_components( + version.dwFileVersionMS >> 16, + version.dwFileVersionMS & 0xffff, + version.dwFileVersionLS >> 16, + version.dwFileVersionLS & 0xffff, + )) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn registry_string_value(subkey: &str, value_name: &str) -> Option { + let subkey_wide = wide_null(Path::new(subkey).as_os_str()); + let value_wide = wide_null(Path::new(value_name).as_os_str()); + let mut key = std::ptr::null_mut(); + if unsafe { + RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + KEY_READ, + &raw mut key, + ) + } != 0 + { + return None; + } + + let mut data_len = 0_u32; + let query_result = unsafe { + RegQueryValueExW( + key, + value_wide.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &raw mut data_len, + ) + }; + if query_result != 0 || data_len == 0 { + let _ = unsafe { RegCloseKey(key) }; + return None; + } + + let mut buffer = vec![0_u16; usize::try_from(data_len / 2).ok()?]; + let read_result = unsafe { + RegQueryValueExW( + key, + value_wide.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + buffer.as_mut_ptr().cast(), + &raw mut data_len, + ) + }; + let _ = unsafe { RegCloseKey(key) }; + if read_result != 0 { + return None; + } + + let nul_index = buffer + .iter() + .position(|item| *item == 0) + .unwrap_or(buffer.len()); + let value = String::from_utf16(&buffer[..nul_index]).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[cfg(windows)] +fn wide_null(value: &std::ffi::OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() +} + +#[cfg(windows)] +fn format_version_components(major: u32, minor: u32, patch: u32, build: u32) -> String { + let mut parts = vec![major, minor, patch, build]; + while parts.len() > 3 && parts.last().is_some_and(|part| *part == 0) { + let _ = parts.pop(); + } + parts + .into_iter() + .map(|part| part.to_string()) + .collect::>() + .join(".") +} + +fn powershell_core_version_from_registry(path: &Path) -> Option { + let install_root = normalize_registry_path(path.parent()?); + let mut fallback = None; + for registry_path in [ + r"HKLM\SOFTWARE\Microsoft\PowerShellCore\InstalledVersions", + r"HKLM\SOFTWARE\WOW6432Node\Microsoft\PowerShellCore\InstalledVersions", + ] { + let output = Command::new("reg") + .args(["query", registry_path, "/s"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + continue; + } + if let Ok(stdout) = String::from_utf8(output.stdout) { + if let Some(version) = parse_powershell_core_registry_output(&stdout, &install_root) { + return Some(version); + } + if fallback.is_none() { + fallback = first_registry_semantic_version(&stdout); + } + } + } + fallback +} + +fn parse_powershell_core_registry_output(output: &str, install_root: &str) -> Option { + let mut current_install_dir = None; + let mut current_version = None; + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("HKEY_") { + if current_install_dir.as_deref() == Some(install_root) && current_version.is_some() { + return current_version; + } + current_install_dir = None; + current_version = None; + continue; + } + if let Some((name, value)) = parse_registry_value_line(trimmed) { + match name.as_str() { + "InstallDir" | "InstallLocation" => { + current_install_dir = Some(normalize_registry_path(Path::new(&value))); + } + "SemanticVersion" => current_version = Some(value), + _ => {} + } + } + if current_install_dir.as_deref() == Some(install_root) && current_version.is_some() { + return current_version; + } + } + if current_install_dir.as_deref() == Some(install_root) { + return current_version; + } + None +} + +fn first_registry_semantic_version(output: &str) -> Option { + output.lines().find_map(|line| { + let trimmed = line.trim(); + let (name, value) = parse_registry_value_line(trimmed)?; + (name == "SemanticVersion").then_some(value) + }) +} + +fn parse_registry_value_line(line: &str) -> Option<(String, String)> { + let mut parts = line.split_whitespace(); + let name = parts.next()?.to_string(); + let _kind = parts.next()?; + let value = parts.collect::>().join(" "); + if value.is_empty() { + return None; + } + Some((name, value)) +} + +fn normalize_registry_path(path: &Path) -> String { + path.to_string_lossy() + .trim_end_matches(['\\', '/']) + .to_ascii_lowercase() +} + +fn classic_powershell_version_from_registry() -> Option { + let output = Command::new("reg") + .args([ + "query", + r"HKLM\SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine", + "/v", + "PowerShellVersion", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok().and_then(|stdout| { + stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with("PowerShellVersion")) + .and_then(|line| line.split_whitespace().last()) + .map(str::to_string) + }) +} + +fn first_non_empty_line(text: &str) -> Option { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) +} + +fn render_text(report: &SysshapeReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "system: os={} version={} kernel={} arch={} host={} default_shell={} session_shell={}", + report.system.os_name.as_deref().unwrap_or("?"), + report.system.os_version.as_deref().unwrap_or("?"), + report.system.kernel_version.as_deref().unwrap_or("?"), + report.system.architecture, + report.system.hostname.as_deref().unwrap_or("?"), + report.system.default_shell.as_deref().unwrap_or("?"), + report.system.session_shell.as_deref().unwrap_or("?"), + ); + let _ = writeln!( + output, + "cpu: model={} logical={} physical={} memory={} available={}", + report.system.cpu_model.as_deref().unwrap_or("?"), + report.system.logical_cores, + report + .system + .physical_cores + .map_or_else(|| "?".to_string(), |value| value.to_string()), + format_bytes_human(report.system.total_memory_bytes), + format_bytes_human(report.system.available_memory_bytes), + ); + if let Some(path_like) = render_path_like_summary(&report.path_like) { + let _ = writeln!(output, "path_like: {path_like}"); + } + if let Some(env_text) = render_environment_summary(&report.environment) { + let _ = writeln!(output, "env: {env_text}"); + } + render_tool_groups(&mut output, &report.tools); + output +} + +fn render_path_like_summary(path_like: &[PathLikeSummary]) -> Option { + if path_like.is_empty() { + return None; + } + + Some( + path_like + .iter() + .map(|entry| { + if entry.duplicate_count == 0 { + if entry.omitted_segments == 0 { + format!("{}({})", entry.name, entry.segment_count) + } else { + format!( + "{}({} preview={})", + entry.name, + entry.segment_count, + entry.segments.len() + ) + } + } else { + let mut summary = format!( + "{}({} dup={} first_dup={}", + entry.name, + entry.segment_count, + entry.duplicate_count, + entry + .duplicate_segments + .first() + .cloned() + .unwrap_or_else(|| "?".to_string()), + ); + if entry.omitted_segments > 0 { + let _ = write!(summary, " preview={}", entry.segments.len()); + } + if entry.omitted_duplicate_segments > 0 { + let _ = write!(summary, " dup_preview={}", entry.duplicate_segments.len()); + } + summary.push(')'); + summary + } + }) + .collect::>() + .join(", "), + ) +} + +fn format_bytes_human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = u128::from(bytes); + let mut unit_index = 0_usize; + while value >= 1024 && unit_index + 1 < UNITS.len() { + value /= 1024; + unit_index += 1; + } + if unit_index == 0 { + format!("{bytes}{}", UNITS[unit_index]) + } else { + let divisor = 1024_u128.pow(u32::try_from(unit_index).expect("unit index should fit")); + let whole = u128::from(bytes) / divisor; + let remainder = u128::from(bytes) % divisor; + let tenths = (remainder * 10) / divisor; + format!("{whole}.{tenths}{}", UNITS[unit_index]) + } +} + +fn render_environment_summary(environment: &BTreeMap) -> Option { + if environment.is_empty() { + return None; + } + + Some( + environment + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("; "), + ) +} + +fn render_tool_groups(output: &mut String, tools: &[ToolInfo]) { + for group in [ + ToolGroup::Core, + ToolGroup::Dev, + ToolGroup::Build, + ToolGroup::Shell, + ToolGroup::Package, + ToolGroup::Container, + ToolGroup::Gpu, + ToolGroup::Reverse, + ] { + let group_tools = tools + .iter() + .filter(|tool| tool.group == group) + .map(render_tool_summary) + .collect::>(); + if !group_tools.is_empty() { + let _ = writeln!( + output, + "tools[{}]: {}", + tool_group_label(group), + group_tools.join("; ") + ); + } + } +} + +fn render_tool_summary(tool: &ToolInfo) -> String { + match (&tool.version, tool.probe_status, &tool.error) { + (Some(version), _, _) => format!("{} {}", tool.name, version), + (None, ToolProbeStatus::Failed, Some(error)) => { + format!("{} [probe_failed={}]", tool.name, error) + } + (None, ToolProbeStatus::TimedOut, _) => format!("{} [probe_timed_out]", tool.name), + _ => tool.name.clone(), + } +} + +fn compact_path_like_segment(segment: &str) -> String { + const MAX_SEGMENT_CHARS: usize = 48; + if segment.chars().count() <= MAX_SEGMENT_CHARS { + return segment.to_string(); + } + let head = segment.chars().take(24).collect::(); + let tail = segment + .chars() + .rev() + .take(16) + .collect::() + .chars() + .rev() + .collect::(); + format!("{head}...{tail}") +} + +const fn tool_group_label(group: ToolGroup) -> &'static str { + match group { + ToolGroup::Core => "core", + ToolGroup::Dev => "dev", + ToolGroup::Build => "build", + ToolGroup::Shell => "shell", + ToolGroup::Package => "package", + ToolGroup::Container => "container", + ToolGroup::Gpu => "gpu", + ToolGroup::Reverse => "reverse", + ToolGroup::All => "all", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn split_path_like_and_group_filters_are_stable() { + assert_eq!( + split_path_like("PATH", r"C:\A;C:\B"), + vec![r"C:\A", r"C:\B"] + ); + assert!(matches_group(ToolGroup::Core, ToolGroup::All)); + assert!(matches_group(ToolGroup::Shell, ToolGroup::Shell)); + assert!(!matches_group(ToolGroup::Shell, ToolGroup::Build)); + } + + #[test] + fn safe_environment_capture_omits_unknowns() { + let env = capture_environment(EnvMode::Safe); + assert!(!env.contains_key("THIS_SHOULD_NOT_EXIST")); + assert!(!env.contains_key("PATH")); + } + + #[test] + fn probe_tool_version_reads_batch_output() { + let temp = tempdir().expect("tempdir"); + let script = temp.path().join("demo.cmd"); + fs::write(&script, "@echo off\r\necho demo 1.2.3\r\n").expect("script"); + let probe = probe_tool_version(&script, "demo", &["--version"], Duration::from_secs(3)) + .expect("probe"); + assert_eq!(probe.version.as_deref(), Some("demo 1.2.3")); + assert_eq!(probe.status, ToolProbeStatus::Ok); + assert_eq!(probe.error, None); + } + + #[test] + fn probe_tool_version_reports_failed_output() { + let temp = tempdir().expect("tempdir"); + let script = temp.path().join("bad.cmd"); + fs::write( + &script, + "@echo off\r\necho noisy failure 1>&2\r\nexit /b 1\r\n", + ) + .expect("script"); + let probe = probe_tool_version(&script, "bad", &["--version"], Duration::from_secs(3)) + .expect("probe"); + assert_eq!(probe.version, None); + assert_eq!(probe.status, ToolProbeStatus::Failed); + assert_eq!(probe.error.as_deref(), Some("noisy failure")); + } + + #[test] + fn current_shell_hint_environment_shortcuts_cover_common_shells() { + let current = current_shell_hint_from_environment(); + assert!( + current.is_none() + || matches!(current.as_deref(), Some("pwsh" | "nu" | "bash" | "fish")) + ); + let fallback = current_shell_hint(); + assert!(fallback.is_none() || !fallback.as_deref().unwrap_or_default().is_empty()); + } + + #[test] + fn classic_powershell_registry_probe_is_parseable_when_present() { + let version = classic_powershell_version_from_registry(); + assert!(version.is_none_or(|value| value.chars().any(|character| character == '.'))); + } + + #[test] + fn registry_parsing_helpers_handle_paths_with_spaces() { + let sample = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\PowerShellCore\\InstalledVersions\\demo + SemanticVersion REG_SZ 7.6.1 + InstallDir REG_SZ C:\\Program Files (x86)\\PowerShell\\7\\ +"; + assert_eq!( + parse_registry_value_line( + "InstallDir REG_SZ C:\\Program Files (x86)\\PowerShell\\7\\" + ), + Some(( + "InstallDir".to_string(), + "C:\\Program Files (x86)\\PowerShell\\7\\".to_string() + )) + ); + assert_eq!( + first_registry_semantic_version(sample).as_deref(), + Some("7.6.1") + ); + assert_eq!( + parse_powershell_core_registry_output(sample, "c:\\program files (x86)\\powershell\\7") + .as_deref(), + Some("7.6.1") + ); + assert_eq!( + normalize_registry_path(Path::new("C:\\Program Files (x86)\\PowerShell\\7\\")), + "c:\\program files (x86)\\powershell\\7" + ); + } + + #[test] + fn package_and_wrapper_version_helpers_extract_expected_values() { + let temp = tempdir().expect("tempdir"); + let package_json = temp.path().join("package.json"); + fs::write( + &package_json, + "{\n \"name\": \"npm\",\n \"version\": \"11.12.1\"\n}\n", + ) + .expect("package.json"); + assert_eq!( + package_json_version(&package_json).as_deref(), + Some("11.12.1") + ); + assert_eq!( + json_string_field("{\"name\":\"npm\",\"version\":\"11.12.1\"}", "version").as_deref(), + Some("11.12.1") + ); + assert_eq!( + pnpm_wrapper_version( + "@SETLOCAL\r\n@\"%~dp0\\.tools\\pnpm-exe\\10.28.1\\pnpm.exe\" %*\r\n" + ) + .as_deref(), + Some("10.28.1") + ); + assert_eq!( + version_from_path_segment( + Path::new( + "C:\\Users\\example\\AppData\\Local\\pnpm\\.tools\\pnpm-exe\\10.28.1\\pnpm.exe" + ), + "pnpm-exe", + ) + .as_deref(), + Some("10.28.1") + ); + } + + #[test] + fn scoop_root_helper_prefers_shim_layout_and_comment_layout() { + let temp = tempdir().expect("tempdir"); + let shims = temp.path().join("shims"); + let repo_root = temp.path().join("apps").join("scoop").join("current"); + fs::create_dir_all(&shims).expect("shims"); + fs::create_dir_all(&repo_root).expect("repo"); + let shim = shims.join("scoop.cmd"); + fs::write(&shim, "@echo off\r\n").expect("shim"); + assert_eq!(scoop_repo_root(&shim).as_deref(), Some(repo_root.as_path())); + + let alt = temp.path().join("alt-scoop.cmd"); + let comment_target = repo_root.join("bin").join("scoop.ps1"); + fs::create_dir_all(comment_target.parent().expect("bin dir")).expect("bin"); + fs::write( + &alt, + format!("@rem {}\r\n@echo off\r\n", comment_target.display()), + ) + .expect("alt shim"); + assert_eq!(scoop_repo_root(&alt).as_deref(), Some(repo_root.as_path())); + } + + #[test] + fn cli_parsing_and_helper_coverage_includes_validation_paths() { + assert_eq!( + parse_cli_from(["sysshape", "--help"]).expect("help").0, + ParseOutcome::Help + ); + assert_eq!( + parse_cli_from(["sysshape", "--version"]) + .expect("version") + .0, + ParseOutcome::Version + ); + + let (_, cli) = parse_cli_from([ + "sysshape", + "--json", + "--color", + "never", + "--env", + "all", + "--probe-timeout-ms", + "25", + "--group", + "shell", + ]) + .expect("parsed cli"); + assert!(cli.common.json); + assert_eq!(cli.common.color, common::ColorChoice::Never); + assert_eq!(cli.env_mode, EnvMode::All); + assert_eq!(cli.probe_timeout, Duration::from_millis(25)); + assert_eq!(cli.group, ToolGroup::Shell); + + assert!(parse_env_mode("mystery").is_err()); + assert!(parse_tool_group("unknown").is_err()); + assert!(parse_u64_flag("--probe-timeout-ms", "nan").is_err()); + assert_eq!(tool_group_label(ToolGroup::Container), "container"); + assert!(group_order(ToolGroup::Core) < group_order(ToolGroup::Reverse)); + assert!(!default_shell_hint().unwrap_or_default().is_empty()); + } + + #[test] + fn build_report_probe_tools_and_render_text_cover_live_paths() { + let cli = Cli { + common: CommonArgs { + json: false, + format: None, + input_format: common::InputFormat::Auto, + color: common::ColorChoice::Never, + quiet: false, + }, + env_mode: EnvMode::None, + probe_timeout: Duration::from_millis(750), + group: ToolGroup::Shell, + }; + + let report = build_report(&cli).expect("report"); + assert!(!report.system.architecture.is_empty()); + assert!(report.environment.is_empty()); + assert!( + report + .tools + .iter() + .all(|tool| tool.group == ToolGroup::Shell) + ); + assert!(!report.tools.is_empty()); + + let text = render_text(&report); + assert!(text.contains("system:")); + assert!(text.contains("cpu:")); + assert!(text.contains("tools[shell]:")); + + let shell_tools = probe_tools(ToolGroup::Shell, Duration::from_millis(750)).expect("tools"); + assert!(!shell_tools.is_empty()); + assert!( + shell_tools + .iter() + .all(|tool| tool.group == ToolGroup::Shell) + ); + + let resolved_cmd = resolve_command_path("cmd").expect("cmd path"); + assert!(resolved_cmd.exists()); + assert_eq!(executable_extensions("demo.exe"), vec![String::new()]); + assert!(!executable_extensions("demo").is_empty()); + assert!(capture_environment(EnvMode::None).is_empty()); + assert!(!capture_environment(EnvMode::Safe).contains_key("PATH")); + assert!(!capture_path_like().is_empty()); + } + + #[test] + fn render_and_probe_helpers_cover_absolute_paths_and_timeouts() { + let temp = tempdir().expect("tempdir"); + let script = temp.path().join("slow.cmd"); + fs::write( + &script, + "@echo off\r\nping -n 2 127.0.0.1 >nul\r\necho slow\r\n", + ) + .expect("slow script"); + + let resolved = resolve_command_path(script.to_string_lossy().as_ref()).expect("absolute"); + assert_eq!(resolved, script); + let probe = probe_tool_version(&script, "slow", &["--version"], Duration::from_millis(10)) + .expect("probe"); + assert_eq!(probe.version, None); + assert_eq!(probe.status, ToolProbeStatus::TimedOut); + + let text = render_text(&SysshapeReport { + system: SystemSummary { + os_name: Some("Windows".to_string()), + os_version: Some("11".to_string()), + kernel_version: Some("kernel".to_string()), + architecture: "x86_64".to_string(), + hostname: Some("mercury".to_string()), + cpu_model: Some("cpu".to_string()), + logical_cores: 8, + physical_cores: Some(4), + total_memory_bytes: 16, + available_memory_bytes: 8, + default_shell: Some("pwsh".to_string()), + session_shell: Some("powershell".to_string()), + }, + environment: BTreeMap::from([("PATH".to_string(), "A;B".to_string())]), + path_like: vec![PathLikeSummary { + name: "PATH".to_string(), + segment_count: 2, + duplicate_count: 1, + omitted_segments: 0, + omitted_duplicate_segments: 0, + segments_are_previews: true, + duplicate_segments_are_previews: true, + segments: vec!["A".to_string(), "B".to_string()], + duplicate_segments: vec!["A".to_string()], + }], + tools: vec![ + ToolInfo { + name: "pwsh".to_string(), + group: ToolGroup::Shell, + path: "C:/pwsh.exe".to_string(), + version: Some("7.5.0".to_string()), + probe_status: ToolProbeStatus::Ok, + error: None, + }, + ToolInfo { + name: "dotnet".to_string(), + group: ToolGroup::Dev, + path: "C:/dotnet.exe".to_string(), + version: None, + probe_status: ToolProbeStatus::Failed, + error: Some("No .NET SDKs were found.".to_string()), + }, + ], + }); + assert!(text.contains("default_shell=pwsh")); + assert!(text.contains("session_shell=powershell")); + assert!(text.contains("path_like: PATH(2 dup=1 first_dup=A)")); + assert!(text.contains("env: PATH=A;B")); + assert!(text.contains("tools[shell]: pwsh 7.5.0")); + assert!(text.contains("tools[dev]: dotnet [probe_failed=No .NET SDKs were found.]")); + } + + #[test] + fn probe_poll_interval_is_aggressive_for_short_lived_processes() { + assert_eq!( + probe_poll_interval(Duration::ZERO, Duration::from_millis(500)), + Duration::from_millis(1) + ); + assert_eq!( + probe_poll_interval(Duration::from_millis(25), Duration::from_millis(500)), + Duration::from_millis(4) + ); + assert_eq!( + probe_poll_interval(Duration::from_millis(125), Duration::from_millis(500)), + Duration::from_millis(10) + ); + assert_eq!( + probe_poll_interval(Duration::from_millis(98), Duration::from_millis(100)), + Duration::from_millis(2) + ); + } + + #[test] + fn duplicate_path_segments_are_reported_once() { + assert_eq!( + duplicate_segments(&[ + "C:\\Tools".to_string(), + "C:\\Tools".to_string(), + "C:\\Other".to_string(), + "C:\\Tools".to_string(), + ]), + vec!["C:\\Tools".to_string()] + ); + } + + #[test] + fn compact_path_like_segment_truncates_long_paths() { + let compacted = compact_path_like_segment( + "C:\\Users\\example\\AppData\\Local\\Programs\\Mercury Toolbox\\bin\\deeply\\nested\\tooling", + ); + assert!(compacted.starts_with("C:\\Users\\example\\AppDat")); + assert!(compacted.contains("...")); + assert!(compacted.ends_with("nested\\tooling")); + } + + #[test] + fn truncate_preview_limits_counts_without_losing_totals() { + let (preview, omitted) = truncate_preview( + &[ + "C:\\A".to_string(), + "C:\\B".to_string(), + "C:\\C".to_string(), + ], + 2, + ); + assert_eq!(preview, vec!["C:\\A".to_string(), "C:\\B".to_string()]); + assert_eq!(omitted, 1); + } +} diff --git a/crates/sysshape/src/main.rs b/crates/sysshape/src/main.rs new file mode 100644 index 0000000..5eb3b73 --- /dev/null +++ b/crates/sysshape/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `sysshape`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(sysshape::main_entry()); +} diff --git a/crates/sysshape/tests/sysshape_cli.rs b/crates/sysshape/tests/sysshape_cli.rs new file mode 100644 index 0000000..54fa64e --- /dev/null +++ b/crates/sysshape/tests/sysshape_cli.rs @@ -0,0 +1,57 @@ +//! Integration tests for the `sysshape` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("sysshape").expect("binary") +} + +#[test] +fn summarizes_system_as_json() { + let mut command = cargo_command(); + command + .args(["--json", "--env", "safe", "--group", "shell"]) + .assert() + .success() + .stdout(predicate::str::contains("\"system\"")) + .stdout(predicate::str::contains("\"architecture\"")) + .stdout(predicate::str::contains("\"tools\"")); +} + +#[test] +fn detects_only_present_shimmed_tools() { + let temp = tempdir().expect("tempdir"); + let cargo_cmd = temp.path().join("cargo.cmd"); + let cmake_cmd = temp.path().join("cmake.cmd"); + fs::write(&cargo_cmd, "@echo off\r\necho cargo 9.9.9\r\n").expect("cargo shim"); + fs::write(&cmake_cmd, "@echo off\r\necho cmake version 3.99.0\r\n").expect("cmake shim"); + + let mut command = cargo_command(); + command + .args(["--json", "--env", "none", "--group", "all"]) + .env("PATH", temp.path()) + .env("PATHEXT", ".CMD;.EXE") + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"cargo\"")) + .stdout(predicate::str::contains("\"version\":\"cargo 9.9.9\"")) + .stdout(predicate::str::contains("\"name\":\"cmake\"")) + .stdout(predicate::str::contains( + "\"version\":\"cmake version 3.99.0\"", + )); +} + +#[test] +fn help_includes_group_and_env_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--env")) + .stdout(predicate::str::contains("--group")) + .stdout(predicate::str::contains("sysshape --json")); +} diff --git a/crates/tonl/Cargo.toml b/crates/tonl/Cargo.toml new file mode 100644 index 0000000..087051f --- /dev/null +++ b/crates/tonl/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tonl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Encode, decode, query, validate, index, stream, and ETL JSON-backed TONL data." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/tonl/src/lib.rs b/crates/tonl/src/lib.rs new file mode 100644 index 0000000..8493d4f --- /dev/null +++ b/crates/tonl/src/lib.rs @@ -0,0 +1,1221 @@ +//! Rust-native `tonl` command implementation. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::io::{self, IsTerminal, Read}; +use std::path::{Path, PathBuf}; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, parse_format_choice, print_quick_help_error, + print_structured, should_read_stdin, write_stdout, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +const HELP: &str = "\ +Encode, decode, query, validate, index, stream, and ETL JSON-backed TONL data. + +Usage: + tonl [OPTIONS] [PATH] + tonl index [OPTIONS] + tonl stream query [OPTIONS] [PATH] + +Commands: + encode Convert JSON or JSONL into TONL + decode Convert TONL into JSON or JSONL + query Filter JSON, JSONL, or TONL records + validate Validate TONL input + index build Build a JSON lookup index from records + index query Read a JSON lookup index + stream query Stream-query JSONL or TONL records + etl Convert between json, jsonl, and tonl + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --where Filter records by top-level JSON value + --key Index field for 'index build' + --output Index output path for 'index build' + --from ETL source format: auto, json, jsonl, tonl + --to ETL target format: json, jsonl, tonl + -h, --help Show this help text + -V, --version Show the command version + +Examples: + tonl encode .\\fixtures\\json-family\\tonl\\sample.json + tonl decode .\\fixtures\\json-family\\tonl\\sample.tonl + tonl query --where active=true .\\fixtures\\json-family\\tonl\\records.tonl + tonl index build --key id --output .\\records.tonl-index.json .\\records.tonl + tonl index query .\\records.tonl-index.json user-2 + tonl stream query --where active=false .\\fixtures\\json-family\\tonl\\records.jsonl + tonl etl --from jsonl --to tonl .\\fixtures\\json-family\\tonl\\records.jsonl +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Format { + Auto, + Json, + Jsonl, + Tonl, +} + +#[derive(Debug, Clone, PartialEq)] +enum CliCommand { + Encode(InputCommand), + Decode(InputCommand), + Query(QueryCommand), + Validate(InputCommand), + IndexBuild(IndexBuildCommand), + IndexQuery(IndexQueryCommand), + StreamQuery(QueryCommand), + Etl(EtlCommand), +} + +#[derive(Debug, Clone, PartialEq)] +struct InputCommand { + render_mode: RenderMode, + path: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct QueryCommand { + render_mode: RenderMode, + filter: Filter, + path: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct IndexBuildCommand { + render_mode: RenderMode, + key: String, + output: PathBuf, + path: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct IndexQueryCommand { + render_mode: RenderMode, + index: PathBuf, + lookup: String, +} + +#[derive(Debug, Clone, PartialEq)] +struct EtlCommand { + from: Format, + to: Format, + render_mode: RenderMode, + path: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct Filter { + key: String, + value: Value, +} + +#[derive(Debug, Clone, PartialEq)] +struct LoadedInput { + content: String, + path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct StatusPayload { + status: &'static str, + records: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct TextPayload { + format: &'static str, + records: usize, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct IndexStatusPayload { + status: &'static str, + key: String, + records: usize, + output: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct IndexFile { + format: String, + key: String, + entries: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("tonl {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, Some(command))) => match run(command) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Ok((ParseOutcome::Run, None)) => { + print_quick_help_error(&CliError::usage("missing command"), HELP); + ExitCode::UsageError.as_i32() + } + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Option), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut tokens = Vec::new(); + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') if tokens.is_empty() => { + return Ok((ParseOutcome::Help, None)); + } + Long("version") | Short('V') if tokens.is_empty() => { + return Ok((ParseOutcome::Version, None)); + } + Long(name) => tokens.push(format!("--{name}")), + Short(name) => tokens.push(format!("-{name}")), + ArgValue(value) => tokens.push(os_to_string(value)?), + } + } + + if tokens.is_empty() { + return Ok((ParseOutcome::Run, None)); + } + + let command = parse_command(&tokens)?; + Ok((ParseOutcome::Run, Some(command))) +} + +fn os_to_string(value: OsString) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "argument expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_command(tokens: &[String]) -> Result { + match tokens.first().map(String::as_str) { + Some("encode") => parse_input_command(tokens, "encode").map(CliCommand::Encode), + Some("decode") => parse_input_command(tokens, "decode").map(CliCommand::Decode), + Some("query") => parse_query_command(tokens, 1).map(CliCommand::Query), + Some("validate") => parse_input_command(tokens, "validate").map(CliCommand::Validate), + Some("index") => parse_index_command(tokens), + Some("stream") => parse_stream_command(tokens), + Some("etl") => parse_etl_command(tokens).map(CliCommand::Etl), + Some("-h" | "--help") => Err(CliError::usage("use 'tonl --help' for command help")), + Some(other) => Err(CliError::usage(format!("unknown command '{other}'"))), + None => Err(CliError::usage("missing command")), + } +} + +fn parse_input_command(tokens: &[String], command_name: &str) -> Result { + let mut render_mode = default_render_mode(); + let mut path = None; + let mut index = 1_usize; + while index < tokens.len() { + if parse_render_mode_token(tokens, &mut index, &mut render_mode)? { + index += 1; + continue; + } + match tokens[index].as_str() { + "-h" | "--help" => return Err(CliError::usage("use 'tonl --help' for command help")), + value if value.starts_with('-') => { + return Err(CliError::usage(format!( + "unsupported {command_name} option '{value}'" + ))); + } + value => set_path_once(&mut path, value, command_name)?, + } + index += 1; + } + Ok(InputCommand { render_mode, path }) +} + +fn parse_query_command(tokens: &[String], start: usize) -> Result { + let mut render_mode = default_render_mode(); + let mut filter = None; + let mut path = None; + let mut index = start; + while index < tokens.len() { + if parse_render_mode_token(tokens, &mut index, &mut render_mode)? { + index += 1; + continue; + } + match tokens[index].as_str() { + "--where" => { + index += 1; + let value = tokens + .get(index) + .ok_or_else(|| CliError::usage("--where expects FIELD=VALUE"))?; + filter = Some(parse_filter(value)?); + } + value if value.starts_with('-') => { + return Err(CliError::usage(format!( + "unsupported query option '{value}'" + ))); + } + value => set_path_once(&mut path, value, "query")?, + } + index += 1; + } + let filter = filter.ok_or_else(|| CliError::usage("query requires --where FIELD=VALUE"))?; + Ok(QueryCommand { + render_mode, + filter, + path, + }) +} + +fn parse_index_command(tokens: &[String]) -> Result { + match tokens.get(1).map(String::as_str) { + Some("build") => parse_index_build(tokens).map(CliCommand::IndexBuild), + Some("query") => parse_index_query(tokens).map(CliCommand::IndexQuery), + Some(other) => Err(CliError::usage(format!("unknown index command '{other}'"))), + None => Err(CliError::usage("index requires build or query")), + } +} + +fn parse_index_build(tokens: &[String]) -> Result { + let mut render_mode = default_render_mode(); + let mut key = None; + let mut output = None; + let mut path = None; + let mut index = 2_usize; + while index < tokens.len() { + if parse_render_mode_token(tokens, &mut index, &mut render_mode)? { + index += 1; + continue; + } + match tokens[index].as_str() { + "--key" => { + index += 1; + key = Some( + tokens + .get(index) + .ok_or_else(|| CliError::usage("--key expects a field name"))? + .clone(), + ); + } + "--output" => { + index += 1; + output = + Some(PathBuf::from(tokens.get(index).ok_or_else(|| { + CliError::usage("--output expects an index path") + })?)); + } + value if value.starts_with('-') => { + return Err(CliError::usage(format!( + "unsupported index build option '{value}'" + ))); + } + value => set_path_once(&mut path, value, "index build")?, + } + index += 1; + } + Ok(IndexBuildCommand { + render_mode, + key: key.ok_or_else(|| CliError::usage("index build requires --key FIELD"))?, + output: output.ok_or_else(|| CliError::usage("index build requires --output PATH"))?, + path, + }) +} + +fn parse_index_query(tokens: &[String]) -> Result { + let mut render_mode = default_render_mode(); + let mut values = Vec::new(); + let mut index = 2_usize; + while index < tokens.len() { + if parse_render_mode_token(tokens, &mut index, &mut render_mode)? { + index += 1; + continue; + } + match tokens[index].as_str() { + value if value.starts_with('-') => { + return Err(CliError::usage(format!( + "unsupported index query option '{value}'" + ))); + } + value => values.push(value.to_owned()), + } + index += 1; + } + if values.len() != 2 { + return Err(CliError::usage( + "index query requires INDEX_PATH LOOKUP_VALUE", + )); + } + Ok(IndexQueryCommand { + render_mode, + index: PathBuf::from(&values[0]), + lookup: values[1].clone(), + }) +} + +fn parse_stream_command(tokens: &[String]) -> Result { + match tokens.get(1).map(String::as_str) { + Some("query") => parse_query_command(tokens, 2).map(CliCommand::StreamQuery), + Some(other) => Err(CliError::usage(format!("unknown stream command '{other}'"))), + None => Err(CliError::usage("stream requires query")), + } +} + +fn parse_etl_command(tokens: &[String]) -> Result { + let mut from = Format::Auto; + let mut to = None; + let mut render_mode = default_render_mode(); + let mut path = None; + let mut index = 1_usize; + while index < tokens.len() { + if parse_render_mode_token(tokens, &mut index, &mut render_mode)? { + index += 1; + continue; + } + match tokens[index].as_str() { + "--from" => { + index += 1; + from = parse_format( + tokens + .get(index) + .ok_or_else(|| CliError::usage("--from expects a format"))?, + true, + )?; + } + "--to" => { + index += 1; + to = Some(parse_format( + tokens + .get(index) + .ok_or_else(|| CliError::usage("--to expects a format"))?, + false, + )?); + } + value if value.starts_with('-') => { + return Err(CliError::usage(format!("unsupported etl option '{value}'"))); + } + value => set_path_once(&mut path, value, "etl")?, + } + index += 1; + } + Ok(EtlCommand { + from, + to: to.ok_or_else(|| CliError::usage("etl requires --to FORMAT"))?, + render_mode, + path, + }) +} + +fn default_render_mode() -> RenderMode { + CommonArgs::default().render_mode() +} + +fn parse_render_mode_token( + tokens: &[String], + index: &mut usize, + render_mode: &mut RenderMode, +) -> Result { + match tokens[*index].as_str() { + "--json" => { + *render_mode = RenderMode::Json; + Ok(true) + } + "--toon" => { + *render_mode = RenderMode::Toon; + Ok(true) + } + "--format" => { + *index += 1; + let value = tokens + .get(*index) + .ok_or_else(|| CliError::usage("--format expects text, json, or toon"))?; + *render_mode = parse_format_choice(value).map_err(|error| { + CliError::usage(format!( + "{error}; tonl uses --to for data conversion and --format for wrapper output" + )) + })?; + Ok(true) + } + _ => Ok(false), + } +} + +fn parse_format(value: &str, allow_auto: bool) -> Result { + match value { + "auto" if allow_auto => Ok(Format::Auto), + "json" => Ok(Format::Json), + "jsonl" => Ok(Format::Jsonl), + "tonl" => Ok(Format::Tonl), + "auto" => Err(CliError::usage("--to does not accept auto")), + other => Err(CliError::usage(format!( + "invalid format '{other}'; expected json, jsonl, or tonl" + ))), + } +} + +fn set_path_once( + path: &mut Option, + value: &str, + command_name: &str, +) -> Result<(), CliError> { + if path.replace(PathBuf::from(value)).is_some() { + return Err(CliError::usage(format!( + "{command_name} accepts at most one input path" + ))); + } + Ok(()) +} + +fn parse_filter(value: &str) -> Result { + let (key, raw_value) = value + .split_once('=') + .ok_or_else(|| CliError::usage("--where expects FIELD=VALUE"))?; + let key = key.trim(); + if key.is_empty() { + return Err(CliError::usage("--where field cannot be empty")); + } + let value = serde_json::from_str(raw_value.trim()) + .unwrap_or_else(|_| Value::String(raw_value.trim().to_owned())); + Ok(Filter { + key: key.to_owned(), + value, + }) +} + +fn run(command: CliCommand) -> Result { + match command { + CliCommand::Encode(command) => run_encode(&command), + CliCommand::Decode(command) => run_decode(&command), + CliCommand::Query(command) | CliCommand::StreamQuery(command) => run_query(&command), + CliCommand::Validate(command) => run_validate(&command), + CliCommand::IndexBuild(command) => run_index_build(&command), + CliCommand::IndexQuery(command) => run_index_query(&command), + CliCommand::Etl(command) => run_etl(&command), + } +} + +fn run_encode(command: &InputCommand) -> Result { + let input = load_input(command.path.as_deref())?; + let records = parse_json_records(&input.content, input.path.as_deref())?; + let output = encode_tonl_documents(&records)?; + emit_text_payload("tonl", records.len(), output, command.render_mode)?; + Ok(ExitCode::Success) +} + +fn run_decode(command: &InputCommand) -> Result { + let input = load_input(command.path.as_deref())?; + let records = parse_tonl_documents(&input.content)?; + emit_records(&records, command.render_mode)?; + Ok(ExitCode::Success) +} + +fn run_query(command: &QueryCommand) -> Result { + let input = load_input(command.path.as_deref())?; + let records = parse_records_auto(&input.content, input.path.as_deref())?; + let matches = records + .into_iter() + .filter(|record| matches_filter(record, &command.filter)) + .collect::>(); + if matches.is_empty() { + return Ok(ExitCode::NoResults); + } + emit_records(&matches, command.render_mode)?; + Ok(ExitCode::Success) +} + +fn run_validate(command: &InputCommand) -> Result { + let input = load_input(command.path.as_deref())?; + match parse_tonl_documents(&input.content) { + Ok(records) => { + match command.render_mode { + RenderMode::Text => { + write_stdout(&format!("valid TONL: {} record(s)\n", records.len()))?; + } + RenderMode::Json | RenderMode::Toon => print_structured( + &StatusPayload { + status: "valid", + records: records.len(), + }, + command.render_mode, + )?, + } + Ok(ExitCode::Success) + } + Err(error) => Err(CliError::usage(format!("invalid TONL: {error}"))), + } +} + +fn run_index_build(command: &IndexBuildCommand) -> Result { + let input = load_input(command.path.as_deref())?; + let records = parse_records_auto(&input.content, input.path.as_deref())?; + let mut entries = BTreeMap::new(); + for record in &records { + let key_value = record.get(&command.key).ok_or_else(|| { + CliError::usage(format!("record missing index key '{}'", command.key)) + })?; + entries.insert(index_key_text(key_value)?, record.clone()); + } + let index = IndexFile { + format: "tonl-index-v1".to_owned(), + key: command.key.clone(), + entries, + }; + let rendered = serde_json::to_string(&index) + .map_err(|error| CliError::runtime(format!("failed to render index json: {error}")))?; + fs::write(&command.output, rendered) + .map_err(|error| CliError::runtime(format!("failed to write index: {error}")))?; + match command.render_mode { + RenderMode::Text => { + write_stdout(&format!( + "indexed {} records by '{}' into {}\n", + records.len(), + command.key, + command.output.display() + ))?; + } + RenderMode::Json | RenderMode::Toon => { + print_structured( + &IndexStatusPayload { + status: "indexed", + key: command.key.clone(), + records: records.len(), + output: command.output.display().to_string(), + }, + command.render_mode, + )?; + } + } + Ok(ExitCode::Success) +} + +fn run_index_query(command: &IndexQueryCommand) -> Result { + let content = fs::read_to_string(&command.index).map_err(|error| { + CliError::runtime(format!( + "failed to read index {}: {error}", + command.index.display() + )) + })?; + let index = serde_json::from_str::(&content) + .map_err(|error| CliError::usage(format!("invalid TONL index: {error}")))?; + let Some(record) = index.entries.get(&command.lookup) else { + return Ok(ExitCode::NoResults); + }; + match command.render_mode { + RenderMode::Text => write_stdout(&format!("{}\n", compact_json(record)?))?, + RenderMode::Json | RenderMode::Toon => print_structured(record, command.render_mode)?, + } + Ok(ExitCode::Success) +} + +fn run_etl(command: &EtlCommand) -> Result { + let input = load_input(command.path.as_deref())?; + let records = match command.from { + Format::Auto => parse_records_auto(&input.content, input.path.as_deref())?, + Format::Json => vec![parse_json_document(&input.content, input.path.as_deref())?], + Format::Jsonl => parse_jsonl_records(&input.content)?, + Format::Tonl => parse_tonl_documents(&input.content)?, + }; + let record_count = records.len(); + let (format, text) = match command.to { + Format::Json => { + if record_count == 1 { + ("json", format!("{}\n", compact_json(&records[0])?)) + } else { + ( + "json", + format!("{}\n", compact_json(&Value::Array(records))?), + ) + } + } + Format::Jsonl => ("jsonl", render_jsonl_records(&records)?), + Format::Tonl => ("tonl", encode_tonl_documents(&records)?), + Format::Auto => return Err(CliError::usage("--to does not accept auto")), + }; + emit_text_payload(format, record_count, text, command.render_mode)?; + Ok(ExitCode::Success) +} + +fn load_input(path: Option<&Path>) -> Result { + if should_read_stdin(path.is_some(), io::stdin().is_terminal()) { + let mut content = String::new(); + io::stdin() + .read_to_string(&mut content) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !content.is_empty() { + return Ok(LoadedInput { + content, + path: None, + }); + } + } + let Some(path) = path else { + return Err(CliError::usage( + "provide an input path or pipe data into stdin", + )); + }; + let content = fs::read_to_string(path).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", path.display())) + })?; + Ok(LoadedInput { + content, + path: Some(path.to_path_buf()), + }) +} + +fn parse_records_auto(content: &str, path: Option<&Path>) -> Result, CliError> { + if likely_tonl_path(path) || looks_like_tonl(content) { + return parse_tonl_documents(content); + } + parse_json_records(content, path) +} + +fn parse_json_records(content: &str, path: Option<&Path>) -> Result, CliError> { + if likely_jsonl_path(path) { + return parse_jsonl_records(content); + } + match serde_json::from_str::(content) { + Ok(value) => Ok(vec![value]), + Err(document_error) => parse_jsonl_records(content).map_err(|jsonl_error| { + CliError::usage(format!( + "input is neither JSON nor JSONL: {document_error}; {jsonl_error}" + )) + }), + } +} + +fn parse_json_document(content: &str, path: Option<&Path>) -> Result { + serde_json::from_str::(content).map_err(|error| { + let source = path.map_or_else(|| "stdin".to_owned(), |value| value.display().to_string()); + CliError::usage(format!("{source} is not valid JSON: {error}")) + }) +} + +fn parse_jsonl_records(content: &str) -> Result, CliError> { + let mut records = Vec::new(); + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::usage(format!( + "JSONL line {} is not valid JSON: {error}", + index + 1 + )) + })?; + records.push(value); + } + if records.is_empty() { + return Err(CliError::usage("no JSONL records found")); + } + Ok(records) +} + +fn likely_jsonl_path(path: Option<&Path>) -> bool { + path.and_then(|value| value.extension()) + .and_then(|value| value.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("jsonl")) +} + +fn likely_tonl_path(path: Option<&Path>) -> bool { + path.and_then(|value| value.extension()) + .and_then(|value| value.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("tonl")) +} + +fn looks_like_tonl(content: &str) -> bool { + content.lines().any(|line| { + let trimmed = line.trim(); + trimmed == "---" || (!trimmed.starts_with('{') && trimmed.contains(" = ")) + }) +} + +fn encode_tonl_documents(records: &[Value]) -> Result { + let mut output = String::with_capacity(records.len().saturating_mul(96)); + for record in records { + if records.len() > 1 { + output.push_str("---\n"); + } + push_tonl_document(&mut output, record)?; + } + Ok(output) +} + +fn push_tonl_document(output: &mut String, record: &Value) -> Result<(), CliError> { + match record { + Value::Object(object) => { + for (key, value) in object { + output.push_str(key); + output.push_str(" = "); + output.push_str(&compact_json(value)?); + output.push('\n'); + } + } + value => { + output.push_str("$ = "); + output.push_str(&compact_json(value)?); + output.push('\n'); + } + } + Ok(()) +} + +fn parse_tonl_documents(content: &str) -> Result, CliError> { + let mut records = Vec::new(); + let mut current = Map::new(); + let mut root_value = None; + for (index, line) in content.lines().enumerate() { + let line_number = index + 1; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if trimmed == "---" { + finish_tonl_record(&mut records, &mut current, &mut root_value); + continue; + } + let (key, raw_value) = trimmed.split_once('=').ok_or_else(|| { + CliError::usage(format!( + "line {line_number}: expected 'key = '" + )) + })?; + let key = key.trim(); + if key.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: key cannot be empty" + ))); + } + let value = serde_json::from_str::(raw_value.trim()).map_err(|error| { + CliError::usage(format!( + "line {line_number}: value must be a valid JSON value: {error}" + )) + })?; + if key == "$" { + if !current.is_empty() { + return Err(CliError::usage(format!( + "line {line_number}: '$' root value cannot be mixed with object fields" + ))); + } + root_value = Some(value); + } else { + if root_value.is_some() { + return Err(CliError::usage(format!( + "line {line_number}: object fields cannot be mixed with '$' root value" + ))); + } + current.insert(key.to_owned(), value); + } + } + finish_tonl_record(&mut records, &mut current, &mut root_value); + if records.is_empty() { + return Err(CliError::usage("no TONL records found")); + } + Ok(records) +} + +fn finish_tonl_record( + records: &mut Vec, + current: &mut Map, + root_value: &mut Option, +) { + if let Some(value) = root_value.take() { + records.push(value); + } else if !current.is_empty() { + records.push(Value::Object(std::mem::take(current))); + } +} + +fn emit_text_payload( + format: &'static str, + records: usize, + text: String, + render_mode: RenderMode, +) -> Result<(), CliError> { + match render_mode { + RenderMode::Text => write_stdout(&text)?, + RenderMode::Json | RenderMode::Toon => { + print_structured( + &TextPayload { + format, + records, + text, + }, + render_mode, + )?; + } + } + Ok(()) +} + +fn emit_records(records: &[Value], render_mode: RenderMode) -> Result<(), CliError> { + match render_mode { + RenderMode::Json | RenderMode::Toon => print_structured(&records, render_mode)?, + RenderMode::Text if records.len() == 1 => { + write_stdout(&format!("{}\n", compact_json(&records[0])?))?; + } + RenderMode::Text => write_stdout(&render_jsonl_records(records)?)?, + } + Ok(()) +} + +fn render_jsonl_records(records: &[Value]) -> Result { + let mut output = String::with_capacity(records.len().saturating_mul(96)); + for record in records { + output.push_str(&compact_json(record)?); + output.push('\n'); + } + Ok(output) +} + +fn compact_json(value: &Value) -> Result { + serde_json::to_string(value) + .map_err(|error| CliError::runtime(format!("failed to render json: {error}"))) +} + +fn matches_filter(record: &Value, filter: &Filter) -> bool { + record + .as_object() + .and_then(|object| object.get(&filter.key)) + == Some(&filter.value) +} + +fn index_key_text(value: &Value) -> Result { + match value { + Value::String(text) => Ok(text.clone()), + other => compact_json(other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn parse_cli_covers_all_command_families() { + let (_, command) = + parse_cli_from(["tonl", "encode", "--json", "records.json"]).expect("encode command"); + assert!(matches!( + command, + Some(CliCommand::Encode(InputCommand { + render_mode: RenderMode::Json, + path: Some(_) + })) + )); + + let query = parse_command(&strings(&[ + "query", + "--where", + "active=true", + "--format", + "toon", + "records.tonl", + ])) + .expect("query command"); + assert!(matches!( + query, + CliCommand::Query(QueryCommand { + render_mode: RenderMode::Toon, + .. + }) + )); + + let build = parse_command(&strings(&[ + "index", + "build", + "--key", + "id", + "--output", + "index.json", + "records.jsonl", + ])) + .expect("index build"); + assert!(matches!( + build, + CliCommand::IndexBuild(IndexBuildCommand { key, .. }) if key == "id" + )); + + let lookup = parse_command(&strings(&[ + "index", + "query", + "--json", + "index.json", + "user-1", + ])) + .expect("index query"); + assert!(matches!( + lookup, + CliCommand::IndexQuery(IndexQueryCommand { + render_mode: RenderMode::Json, + .. + }) + )); + + let stream = + parse_command(&strings(&["stream", "query", "--where", "id=1"])).expect("stream query"); + assert!(matches!(stream, CliCommand::StreamQuery(_))); + + let etl = parse_command(&strings(&[ + "etl", + "--from", + "jsonl", + "--to", + "tonl", + "records.jsonl", + ])) + .expect("etl command"); + assert!(matches!( + etl, + CliCommand::Etl(EtlCommand { + from: Format::Jsonl, + to: Format::Tonl, + .. + }) + )); + } + + #[test] + fn parse_cli_reports_usage_errors() { + assert!( + parse_cli_from(["tonl"]) + .expect("missing command") + .1 + .is_none() + ); + assert!(parse_command(&strings(&["wat"])).is_err()); + assert!(parse_command(&strings(&["query"])).is_err()); + assert!(parse_command(&strings(&["index"])).is_err()); + assert!(parse_command(&strings(&["stream"])).is_err()); + assert!(parse_command(&strings(&["etl", "--to", "auto"])).is_err()); + assert!(parse_filter("=true").is_err()); + assert!(parse_format("auto", false).is_err()); + } + + #[test] + fn json_jsonl_and_tonl_records_roundtrip() { + let records = vec![ + json!({"id": "user-1", "active": true, "score": 1}), + json!({"id": "user-2", "active": false, "score": 2}), + ]; + + let tonl = encode_tonl_documents(&records).expect("encode tonl"); + assert!(tonl.contains("---")); + assert!(looks_like_tonl(&tonl)); + assert_eq!(parse_tonl_documents(&tonl).expect("parse tonl"), records); + + let jsonl = render_jsonl_records(&records).expect("jsonl render"); + assert_eq!(parse_jsonl_records(&jsonl).expect("jsonl parse"), records); + assert_eq!( + parse_json_records(&jsonl, Some(Path::new("records.jsonl"))).expect("jsonl path parse"), + records + ); + assert_eq!( + parse_records_auto(&tonl, Some(Path::new("records.tonl"))).expect("auto tonl"), + records + ); + } + + #[test] + fn tonl_parser_rejects_ambiguous_or_malformed_records() { + assert!(parse_jsonl_records("\n").is_err()); + assert!(parse_json_records("not-json", None).is_err()); + assert!(parse_tonl_documents("id 1\n").is_err()); + assert!(parse_tonl_documents(" = 1\n").is_err()); + assert!(parse_tonl_documents("$ = 1\nname = \"Ada\"\n").is_err()); + assert!(parse_tonl_documents("name = \"Ada\"\n$ = 1\n").is_err()); + } + + #[test] + fn filters_and_index_keys_match_serialized_values() { + let record = json!({"id": "user-1", "active": true, "count": 3}); + assert!(matches_filter( + &record, + &Filter { + key: "active".to_owned(), + value: Value::Bool(true), + } + )); + assert_eq!( + index_key_text(record.get("id").expect("id")).expect("string key"), + "user-1" + ); + assert_eq!( + index_key_text(record.get("count").expect("count")).expect("number key"), + "3" + ); + } + + #[test] + fn run_covers_file_backed_commands_and_no_result_paths() { + let directory = tempdir().expect("tempdir"); + let jsonl_path = directory.path().join("records.jsonl"); + let tonl_path = directory.path().join("records.tonl"); + let index_path = directory.path().join("records.index.json"); + fs::write( + &jsonl_path, + "{\"id\":\"user-1\",\"active\":true}\n{\"id\":\"user-2\",\"active\":false}\n", + ) + .expect("jsonl fixture"); + fs::write( + &tonl_path, + "---\nid = \"user-1\"\nactive = true\n---\nid = \"user-2\"\nactive = false\n", + ) + .expect("tonl fixture"); + + assert_eq!( + run(CliCommand::Encode(InputCommand { + render_mode: RenderMode::Json, + path: Some(jsonl_path.clone()), + })) + .expect("encode"), + ExitCode::Success + ); + assert_eq!( + run(CliCommand::Decode(InputCommand { + render_mode: RenderMode::Toon, + path: Some(tonl_path.clone()), + })) + .expect("decode"), + ExitCode::Success + ); + assert_eq!( + run(CliCommand::Query(QueryCommand { + render_mode: RenderMode::Json, + filter: Filter { + key: "active".to_owned(), + value: Value::Bool(true), + }, + path: Some(jsonl_path.clone()), + })) + .expect("query"), + ExitCode::Success + ); + assert_eq!( + run(CliCommand::Query(QueryCommand { + render_mode: RenderMode::Text, + filter: Filter { + key: "active".to_owned(), + value: Value::Null, + }, + path: Some(jsonl_path.clone()), + })) + .expect("empty query"), + ExitCode::NoResults + ); + assert_eq!( + run(CliCommand::Validate(InputCommand { + render_mode: RenderMode::Text, + path: Some(tonl_path.clone()), + })) + .expect("validate"), + ExitCode::Success + ); + assert_eq!( + run(CliCommand::IndexBuild(IndexBuildCommand { + render_mode: RenderMode::Json, + key: "id".to_owned(), + output: index_path.clone(), + path: Some(jsonl_path), + })) + .expect("index build"), + ExitCode::Success + ); + assert_eq!( + run(CliCommand::IndexQuery(IndexQueryCommand { + render_mode: RenderMode::Text, + index: index_path.clone(), + lookup: "user-1".to_owned(), + })) + .expect("index query"), + ExitCode::Success + ); + assert_eq!( + run(CliCommand::IndexQuery(IndexQueryCommand { + render_mode: RenderMode::Json, + index: index_path, + lookup: "missing".to_owned(), + })) + .expect("missing index query"), + ExitCode::NoResults + ); + assert_eq!( + run(CliCommand::Etl(EtlCommand { + from: Format::Tonl, + to: Format::Jsonl, + render_mode: RenderMode::Json, + path: Some(tonl_path), + })) + .expect("etl"), + ExitCode::Success + ); + } + + #[test] + fn run_reports_validation_and_index_errors() { + let directory = tempdir().expect("tempdir"); + let invalid_tonl = directory.path().join("bad.tonl"); + let missing_key_jsonl = directory.path().join("missing-key.jsonl"); + fs::write(&invalid_tonl, "not tonl\n").expect("invalid fixture"); + fs::write( + &missing_key_jsonl, + "{\"id\":\"user-1\"}\n{\"name\":\"Ada\"}\n", + ) + .expect("missing key fixture"); + + assert!( + run(CliCommand::Validate(InputCommand { + render_mode: RenderMode::Json, + path: Some(invalid_tonl), + })) + .is_err() + ); + assert!( + run(CliCommand::IndexBuild(IndexBuildCommand { + render_mode: RenderMode::Text, + key: "id".to_owned(), + output: directory.path().join("out.json"), + path: Some(missing_key_jsonl), + })) + .is_err() + ); + assert!(load_input(Some(&directory.path().join("missing.tonl"))).is_err()); + } +} diff --git a/crates/tonl/src/main.rs b/crates/tonl/src/main.rs new file mode 100644 index 0000000..408e24f --- /dev/null +++ b/crates/tonl/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for the `tonl` command. + +fn main() { + std::process::exit(tonl::main_entry()); +} diff --git a/crates/tonl/tests/tonl_cli.rs b/crates/tonl/tests/tonl_cli.rs new file mode 100644 index 0000000..d3b99d1 --- /dev/null +++ b/crates/tonl/tests/tonl_cli.rs @@ -0,0 +1,224 @@ +//! Integration tests for the `tonl` command. + +use std::fs; +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn fixture(path: &str) -> PathBuf { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("json-family") + .join("tonl") + .join(path); + assert!( + fixture.exists(), + "missing fixture `{path}` at {}", + fixture.display() + ); + fixture +} + +fn cargo_tonl() -> Command { + Command::cargo_bin("tonl").expect("binary") +} + +fn fixture_text(path: &str) -> String { + fs::read_to_string(fixture(path)).expect("fixture text") +} + +#[test] +fn roundtrip_json_object_through_tonl() { + let encoded = cargo_tonl() + .args(["encode"]) + .arg(fixture("sample.json")) + .assert() + .success() + .stdout(predicate::str::contains("name = \"Ada\"")) + .get_output() + .stdout + .clone(); + + let decoded = cargo_tonl() + .args(["decode"]) + .write_stdin(encoded) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let expected: Value = + serde_json::from_str(&fixture_text("sample.json")).expect("expected json"); + let actual: Value = serde_json::from_slice(&decoded).expect("decoded json"); + assert_eq!(actual, expected); +} + +#[test] +fn invalid_input_reports_line_diagnostics() { + cargo_tonl() + .args(["decode"]) + .arg(fixture("invalid.tonl")) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("line 3")) + .stderr(predicate::str::contains("valid JSON value")); +} + +#[test] +fn supports_path_and_stdin_input() { + cargo_tonl() + .args(["decode"]) + .arg(fixture("sample.tonl")) + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"Ada\"")); + + cargo_tonl() + .args(["encode"]) + .write_stdin(fixture_text("sample.json")) + .assert() + .success() + .stdout(predicate::str::contains("score = 42")); +} + +#[test] +fn encode_json_wrapper_reports_tonl_text() { + cargo_tonl() + .args(["encode", "--json"]) + .arg(fixture("sample.json")) + .assert() + .success() + .stdout(predicate::str::contains("\"format\":\"tonl\"")) + .stdout(predicate::str::contains("\"records\":1")) + .stdout(predicate::str::contains("name = \\\"Ada\\\"")); +} + +#[test] +fn encode_toon_wrapper_reports_tonl_text() { + cargo_tonl() + .args(["encode", "--format", "toon"]) + .arg(fixture("sample.json")) + .assert() + .success() + .stdout(predicate::str::contains("format: tonl")) + .stdout(predicate::str::contains("records: 1")) + .stdout(predicate::str::contains("name = \\\"Ada\\\"")); +} + +#[test] +fn query_filters_records_by_field_value() { + let output = cargo_tonl() + .args(["query", "--where", "active=true"]) + .arg(fixture("records.tonl")) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let record: Value = serde_json::from_slice(&output).unwrap_or_else(|error| { + panic!( + "query output should be a JSON record: {error}\n{}", + String::from_utf8_lossy(&output) + ) + }); + assert_eq!( + record["id"], + "user-1", + "stdout={}", + String::from_utf8_lossy(&output) + ); + assert_eq!( + record["active"], + true, + "stdout={}", + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn validate_reports_failures() { + cargo_tonl() + .args(["validate"]) + .arg(fixture("invalid.tonl")) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("invalid TONL")); +} + +#[test] +fn index_build_and_query_lookup_record() { + let temp = tempdir().expect("tempdir"); + let index = temp.path().join("records.tonl-index.json"); + + cargo_tonl() + .args(["index", "build", "--key", "id", "--output"]) + .arg(&index) + .arg(fixture("records.tonl")) + .assert() + .success() + .stdout(predicate::str::contains("indexed 2 records")); + + cargo_tonl() + .args(["index", "query"]) + .arg(&index) + .arg("user-2") + .assert() + .success() + .stdout(predicate::str::contains("\"name\":\"Bob\"")) + .stdout(predicate::str::contains("\"active\":false")); +} + +#[test] +fn stream_query_smoke_filters_jsonl_records() { + cargo_tonl() + .args(["stream", "query", "--where", "active=false"]) + .arg(fixture("records.jsonl")) + .assert() + .success() + .stdout(predicate::str::contains("\"id\":\"user-2\"")) + .stdout(predicate::str::contains("\"id\":\"user-1\"").not()); +} + +#[test] +fn etl_converts_jsonl_to_tonl_and_tonl_to_jsonl() { + cargo_tonl() + .args(["etl", "--from", "jsonl", "--to", "tonl"]) + .arg(fixture("records.jsonl")) + .assert() + .success() + .stdout(predicate::str::contains("---")) + .stdout(predicate::str::contains("id = \"user-1\"")); + + cargo_tonl() + .args(["etl", "--from", "tonl", "--to", "jsonl"]) + .arg(fixture("records.tonl")) + .assert() + .success() + .stdout(predicate::str::contains("{\"id\":\"user-1\"")) + .stdout(predicate::str::contains("{\"id\":\"user-2\"")); +} + +#[test] +fn help_lists_full_tool_surface() { + cargo_tonl() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("encode")) + .stdout(predicate::str::contains("decode")) + .stdout(predicate::str::contains("query")) + .stdout(predicate::str::contains("validate")) + .stdout(predicate::str::contains("index build")) + .stdout(predicate::str::contains("index query")) + .stdout(predicate::str::contains("stream query")) + .stdout(predicate::str::contains("etl")); +} diff --git a/crates/toon/Cargo.toml b/crates/toon/Cargo.toml new file mode 100644 index 0000000..55cc47f --- /dev/null +++ b/crates/toon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "toon" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Convert between JSON and TOON text with compact CLI output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/toon/src/lib.rs b/crates/toon/src/lib.rs new file mode 100644 index 0000000..af346af --- /dev/null +++ b/crates/toon/src/lib.rs @@ -0,0 +1,3315 @@ +//! The `toon` command converts JSON and TOON. + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + read_existing_stdin_paths, should_read_stdin, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::{Map, Number, Value}; + +const HELP: &str = "\ +Convert between JSON and TOON with compact CLI output and stable JSON wrappers. + +Usage: + toon [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --from Force source syntax: auto, json, toon + --to Force target syntax: auto, json, toon + --delimiter TOON array delimiter: comma, tab, pipe + --indent Spaces per indentation level + --key-folding JSON -> TOON key folding mode: off, safe + --flatten-depth Maximum number of folded path segments + --expand-paths TOON -> JSON dotted-path expansion mode: off, safe + --strict Enforce strict TOON validation: true, false + -h, --help Show this help text + -V, --version Show the command version + +Examples: + toon .\\fixtures\\toon\\config.json + toon .\\fixtures\\toon\\config.toon --json | ConvertFrom-Json + '{\"ok\":true}' | toon + bat --style=plain --paging=never .\\fixtures\\toon\\records.jsonl | toon + toon .\\records.jsonl + '.\\fixtures\\toon\\config.json' | toon --input-format lines +"; + +const DEFAULT_INDENT: usize = 2; +const MAX_TOON_DEPTH: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceFormat { + Auto, + Json, + Toon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetFormat { + Auto, + Json, + Toon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DelimiterChoice { + Comma, + Tab, + Pipe, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SafeMode { + Off, + Safe, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Format { + Json, + Toon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Delimiter { + Comma, + Tab, + Pipe, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EncodeOptions { + indent: usize, + delimiter: Delimiter, + key_folding: SafeMode, + flatten_depth: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DecodeOptions { + indent: usize, + strict: bool, + expand_paths: SafeMode, +} + +#[derive(Debug, Clone, PartialEq)] +enum Node { + Null, + Bool(bool), + Number(Number), + String(String), + Array(Vec), + Object(NodeObject), +} + +#[derive(Debug, Clone, PartialEq, Default)] +struct NodeObject { + fields: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +struct NodeField { + key: String, + was_quoted: bool, + value: Node, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedLine { + content: String, + depth: usize, + line_number: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct ScanResult { + lines: Vec, + blank_lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ArrayHeaderInfo { + key: Option, + key_was_quoted: bool, + length: usize, + delimiter: Delimiter, + fields: Option>, +} + +#[derive(Debug, Clone)] +struct Cursor { + lines: Vec, + blank_lines: Vec, + index: usize, + last_line_number: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct FoldResult<'value> { + folded_key: String, + remainder: Option<&'value Map>, + leaf_value: &'value Value, + segment_count: usize, +} + +#[derive(Debug, Clone, Copy)] +struct EncodeContext<'value> { + options: EncodeOptions, + root_literal_keys: &'value [String], + path_prefix: Option<&'value str>, + flatten_depth: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ToonTextPayload { + format: &'static str, + text: String, + line_count: usize, +} + +/// CLI arguments for the `toon` binary. +#[derive(Debug, Clone)] +struct Cli { + /// Shared output and stdin policy flags. + common: CommonArgs, + /// Explicitly select the source syntax. + from: SourceFormat, + /// Explicitly select the target syntax. + to: TargetFormat, + /// Delimiter for TOON array and tabular output. + delimiter: DelimiterChoice, + /// Spaces per indentation level. + indent: usize, + /// Enable safe key folding during JSON to TOON conversion. + key_folding: SafeMode, + /// Maximum number of path segments to fold. + flatten_depth: Option, + /// Enable safe dotted-path expansion during TOON to JSON conversion. + expand_paths: SafeMode, + /// Enforce strict TOON validation while decoding. + strict: bool, + /// Optional file to read when stdin is empty. + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("toon {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + from: SourceFormat::Auto, + to: TargetFormat::Auto, + delimiter: DelimiterChoice::Comma, + indent: DEFAULT_INDENT, + key_folding: SafeMode::Off, + flatten_depth: None, + expand_paths: SafeMode::Off, + strict: true, + path: None, + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + let value = parser_value_string(&mut parser, "--input-format")?; + cli.common.input_format = parse_input_format(&value)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + cli.common.color = parse_color_choice(&value)?; + } + Long("quiet") => cli.common.quiet = true, + Long("from") => { + cli.from = parse_source_format(&parser_value_string(&mut parser, "--from")?)?; + } + Long("to") => { + cli.to = parse_target_format(&parser_value_string(&mut parser, "--to")?)?; + } + Long("delimiter") => { + cli.delimiter = + parse_delimiter_choice(&parser_value_string(&mut parser, "--delimiter")?)?; + } + Long("indent") => { + cli.indent = + parse_usize_flag("--indent", &parser_value_string(&mut parser, "--indent")?)?; + } + Long("key-folding") => { + cli.key_folding = + parse_safe_mode(&parser_value_string(&mut parser, "--key-folding")?)?; + } + Long("flatten-depth") => { + cli.flatten_depth = Some(parse_usize_flag( + "--flatten-depth", + &parser_value_string(&mut parser, "--flatten-depth")?, + )?); + } + Long("expand-paths") => { + cli.expand_paths = + parse_safe_mode(&parser_value_string(&mut parser, "--expand-paths")?)?; + } + Long("strict") => { + cli.strict = + parse_bool_flag("--strict", &parser_value_string(&mut parser, "--strict")?)?; + } + ArgValue(path) => { + if cli.path.replace(PathBuf::from(path)).is_some() { + return Err(CliError::usage("toon accepts at most one explicit path")); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_bool_flag(flag: &str, value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + other => Err(CliError::usage(format!( + "invalid {flag} value '{other}'; expected true or false" + ))), + } +} + +fn parse_source_format(value: &str) -> Result { + match value { + "auto" => Ok(SourceFormat::Auto), + "json" => Ok(SourceFormat::Json), + "toon" => Ok(SourceFormat::Toon), + other => Err(CliError::usage(format!( + "invalid --from value '{other}'; expected auto, json, or toon" + ))), + } +} + +fn parse_target_format(value: &str) -> Result { + match value { + "auto" => Ok(TargetFormat::Auto), + "json" => Ok(TargetFormat::Json), + "toon" => Ok(TargetFormat::Toon), + other => Err(CliError::usage(format!( + "invalid --to value '{other}'; expected auto, json, or toon" + ))), + } +} + +fn parse_delimiter_choice(value: &str) -> Result { + match value { + "comma" => Ok(DelimiterChoice::Comma), + "tab" => Ok(DelimiterChoice::Tab), + "pipe" => Ok(DelimiterChoice::Pipe), + other => Err(CliError::usage(format!( + "invalid --delimiter value '{other}'; expected comma, tab, or pipe" + ))), + } +} + +fn parse_safe_mode(value: &str) -> Result { + match value { + "off" => Ok(SafeMode::Off), + "safe" => Ok(SafeMode::Safe), + other => Err(CliError::usage(format!( + "invalid mode value '{other}'; expected off or safe" + ))), + } +} + +fn run(cli: &Cli) -> Result { + if cli.indent == 0 { + return Err(CliError::usage("--indent must be greater than 0")); + } + + let input = load_input(cli)?; + let source_format = detect_source_format(cli, &input); + let target_format = resolve_target_format(cli.to, source_format); + + let value = match source_format { + Format::Json => parse_json_source(&input, cli.common.input_format)?, + Format::Toon => decode_toon( + &input, + DecodeOptions { + indent: cli.indent, + strict: cli.strict, + expand_paths: cli.expand_paths, + }, + )?, + }; + + match target_format { + Format::Json => render_json_output(&value, cli.common.render_mode())?, + Format::Toon => { + let text = encode_toon( + &value, + EncodeOptions { + indent: cli.indent, + delimiter: Delimiter::from_choice(cli.delimiter), + key_folding: cli.key_folding, + flatten_depth: cli.flatten_depth.unwrap_or(usize::MAX), + }, + )?; + render_toon_output(&text, cli.common.render_mode())?; + } + } + + Ok(ExitCode::Success) +} + +fn load_input(cli: &Cli) -> Result { + if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.is_empty() { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = + read_existing_stdin_paths(&buffer, cli.common.input_format, "toon")? + { + return read_single_stdin_path(&paths, "toon"); + } + return Ok(buffer); + } + } + + let Some(path) = &cli.path else { + return Err(CliError::usage( + "provide one JSON/TOON path or pipe input into stdin", + )); + }; + + let path = common::require_exactly_one_input_path( + &common::expand_input_patterns(std::slice::from_ref(path), "toon")?, + "toon", + )?; + fs::read_to_string(&path) + .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) +} + +fn read_single_stdin_path(paths: &[PathBuf], command_name: &str) -> Result { + if paths.len() != 1 { + return Err(CliError::usage(format!( + "{command_name} accepts exactly one stdin path, got {}", + paths.len() + ))); + } + fs::read_to_string(&paths[0]).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", paths[0].display())) + }) +} + +fn detect_source_format(cli: &Cli, input: &str) -> Format { + match cli.from { + SourceFormat::Json => Format::Json, + SourceFormat::Toon => Format::Toon, + SourceFormat::Auto => { + if cli.common.input_format == InputFormat::Jsonl { + return Format::Json; + } + + let trimmed = input.trim(); + if trimmed.is_empty() { + return Format::Toon; + } + + if serde_json::from_str::(trimmed).is_ok() + || cli.common.input_format == InputFormat::Auto && looks_like_jsonl(trimmed) + { + Format::Json + } else { + Format::Toon + } + } + } +} + +const fn resolve_target_format(target: TargetFormat, source: Format) -> Format { + match target { + TargetFormat::Json => Format::Json, + TargetFormat::Toon => Format::Toon, + TargetFormat::Auto => match source { + Format::Json => Format::Toon, + Format::Toon => Format::Json, + }, + } +} + +fn parse_json_source(input: &str, input_format: InputFormat) -> Result { + match input_format { + InputFormat::Jsonl => { + let values = parse_jsonl_values(input)?; + Ok(Value::Array(values)) + } + InputFormat::Auto | InputFormat::Lines => { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(CliError::usage("JSON input is empty")); + } + + match serde_json::from_str::(trimmed) { + Ok(value) => Ok(value), + Err(_) if input_format == InputFormat::Auto && looks_like_jsonl(trimmed) => { + parse_jsonl_values(trimmed).map(Value::Array) + } + Err(json_error) => Err(CliError::runtime(format!( + "invalid JSON input: {json_error}" + ))), + } + } + } +} + +fn looks_like_jsonl(input: &str) -> bool { + let mut count = 0_usize; + let mut first_line = None; + + for line in input.lines().map(str::trim).filter(|line| !line.is_empty()) { + first_line.get_or_insert(line); + if serde_json::from_str::(line).is_err() { + return false; + } + count += 1; + } + + if count == 0 { + return false; + } + if count == 1 + && !matches!( + first_line.and_then(|line| line.chars().next()), + Some('{' | '[' | '"') + ) + { + return false; + } + true +} + +fn parse_jsonl_values(input: &str) -> Result, CliError> { + let mut values = Vec::new(); + + for (index, line) in input.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::runtime(format!("invalid JSONL at line {}: {error}", index + 1)) + })?; + values.push(value); + } + + Ok(values) +} + +fn render_json_output(value: &Value, render_mode: RenderMode) -> Result<(), CliError> { + match render_mode { + RenderMode::Json => print_json(value), + RenderMode::Toon => print_structured(value, RenderMode::Toon), + RenderMode::Text => { + let mut rendered = serde_json::to_string_pretty(value) + .map_err(|error| CliError::runtime(format!("failed to render JSON: {error}")))?; + rendered.push('\n'); + write_stdout(rendered.as_bytes()) + } + } +} + +fn render_toon_output(text: &str, render_mode: RenderMode) -> Result<(), CliError> { + match render_mode { + RenderMode::Json => print_json(&ToonTextPayload { + format: "toon", + text: text.to_string(), + line_count: count_lines(text), + }), + RenderMode::Text | RenderMode::Toon => { + if text.is_empty() { + return Ok(()); + } + + let mut rendered = text.to_string(); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + write_stdout(rendered.as_bytes()) + } + } +} + +fn count_lines(text: &str) -> usize { + if text.is_empty() { + 0 + } else { + text.lines().count() + } +} + +fn write_stdout(bytes: &[u8]) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + stdout + .write_all(bytes) + .map_err(|error| CliError::runtime(format!("failed to write stdout: {error}"))) +} + +fn decode_toon(input: &str, options: DecodeOptions) -> Result { + let scan = scan_lines(input, options.indent, options.strict)?; + let mut cursor = Cursor::new(scan); + + let node = if let Some(first) = cursor.peek().cloned() { + if first.content.trim_start().starts_with('[') { + if let Some((header, inline_values)) = + parse_array_header_line(&first.content, Delimiter::Comma)? + { + cursor.next(); + parse_array_from_header(&header, inline_values.as_deref(), &mut cursor, 0, options)? + } else if let Some(array) = parse_bracket_inline_array(&first.content)? { + cursor.next(); + array + } else if cursor.lines.len() == 1 && !is_key_value_line(&first.content) { + cursor.next(); + parse_primitive_token(first.content.trim())? + } else { + Node::Object(parse_object_fields(&mut cursor, 0, options)?) + } + } else if cursor.lines.len() == 1 && !is_key_value_line(&first.content) { + cursor.next(); + parse_primitive_token(first.content.trim())? + } else { + Node::Object(parse_object_fields(&mut cursor, 0, options)?) + } + } else { + Node::Object(NodeObject::default()) + }; + + if let Some(line) = cursor.peek() { + return Err(CliError::runtime(format!( + "unexpected trailing content at line {}", + line.line_number + ))); + } + + let expanded = if options.expand_paths == SafeMode::Safe { + expand_paths_safe(node, options.strict)? + } else { + node + }; + + Ok(expanded.into_value()) +} + +fn scan_lines(input: &str, indent_size: usize, strict: bool) -> Result { + let mut scan = ScanResult::default(); + + for (index, raw_line) in input.lines().enumerate() { + let line_number = index + 1; + let indent = raw_line.bytes().take_while(|byte| *byte == b' ').count(); + let content = raw_line[indent..].to_string(); + + if content.trim().is_empty() { + scan.blank_lines.push(line_number); + continue; + } + + if strict { + if raw_line + .bytes() + .take_while(|byte| *byte == b' ' || *byte == b'\t') + .any(|byte| byte == b'\t') + { + return Err(CliError::runtime(format!( + "line {line_number}: tabs are not allowed in indentation in strict mode" + ))); + } + if indent > 0 && indent % indent_size != 0 { + return Err(CliError::runtime(format!( + "line {line_number}: indentation must be a multiple of {indent_size}" + ))); + } + } + + let depth = indent / indent_size; + if depth > MAX_TOON_DEPTH { + return Err(CliError::runtime(format!( + "maximum TOON nesting depth of {MAX_TOON_DEPTH} exceeded at line {line_number}" + ))); + } + + scan.lines.push(ParsedLine { + content, + depth, + line_number, + }); + } + + Ok(scan) +} + +fn parse_object_fields( + cursor: &mut Cursor, + base_depth: usize, + options: DecodeOptions, +) -> Result { + let mut object = NodeObject::default(); + let mut effective_depth = None; + + while let Some(line) = cursor.peek().cloned() { + if line.depth < base_depth { + break; + } + + if effective_depth.is_none() { + effective_depth = Some(line.depth); + } + + if line.depth != effective_depth.unwrap_or(base_depth) { + break; + } + + let Some(consumed) = cursor.next() else { + break; + }; + object.fields.push(parse_key_value( + &consumed.content, + cursor, + line.depth, + options, + )?); + } + + Ok(object) +} + +fn parse_key_value( + content: &str, + cursor: &mut Cursor, + base_depth: usize, + options: DecodeOptions, +) -> Result { + if let Some((header, inline_values)) = parse_array_header_line(content, Delimiter::Comma)? { + if let Some(key) = &header.key { + let value = parse_array_from_header( + &header, + inline_values.as_deref(), + cursor, + base_depth, + options, + )?; + return Ok(NodeField { + key: key.clone(), + was_quoted: header.key_was_quoted, + value, + }); + } + } + + let (key, was_quoted, end_index) = parse_key_token(content)?; + let rest = content[end_index..].trim(); + + let value = if rest.is_empty() { + if cursor.peek().is_some_and(|line| line.depth > base_depth) { + Node::Object(parse_object_fields(cursor, base_depth + 1, options)?) + } else { + Node::Object(NodeObject::default()) + } + } else if let Some(array) = parse_bracket_inline_array(rest)? { + array + } else { + parse_primitive_token(rest)? + }; + + Ok(NodeField { + key, + was_quoted, + value, + }) +} + +fn parse_array_from_header( + header: &ArrayHeaderInfo, + inline_values: Option<&str>, + cursor: &mut Cursor, + base_depth: usize, + options: DecodeOptions, +) -> Result { + if let Some(values) = inline_values { + return parse_inline_primitive_array(header, values, options); + } + + if header + .fields + .as_ref() + .is_some_and(|fields| !fields.is_empty()) + { + return parse_tabular_array(header, cursor, base_depth, options); + } + + parse_list_array(header, cursor, base_depth, options) +} + +fn parse_inline_primitive_array( + header: &ArrayHeaderInfo, + inline_values: &str, + options: DecodeOptions, +) -> Result { + if inline_values.trim().is_empty() { + assert_expected_count(0, header.length, "inline array items", options.strict)?; + return Ok(Node::Array(Vec::new())); + } + + let tokens = parse_delimited_values(inline_values, header.delimiter); + assert_expected_count( + tokens.len(), + header.length, + "inline array items", + options.strict, + )?; + let items = tokens + .iter() + .map(|token| parse_primitive_token(token)) + .collect::, _>>()?; + Ok(Node::Array(items)) +} + +fn parse_tabular_array( + header: &ArrayHeaderInfo, + cursor: &mut Cursor, + base_depth: usize, + options: DecodeOptions, +) -> Result { + let row_depth = base_depth + 1; + let mut rows = Vec::new(); + let mut start_line = None; + let mut end_line = None; + let fields = header + .fields + .as_ref() + .ok_or_else(|| CliError::runtime("tabular array is missing field names"))?; + + while rows.len() < header.length { + let Some(line) = cursor.peek().cloned() else { + break; + }; + if line.depth < row_depth { + break; + } + if line.depth != row_depth { + break; + } + + if start_line.is_none() { + start_line = Some(line.line_number); + } + end_line = Some(line.line_number); + + cursor.next(); + let tokens = parse_delimited_values(&line.content, header.delimiter); + assert_expected_count( + tokens.len(), + fields.len(), + "tabular row values", + options.strict, + )?; + + let mut row = NodeObject::default(); + for (field, token) in fields.iter().zip(tokens.iter()) { + row.fields.push(NodeField { + key: field.clone(), + was_quoted: false, + value: parse_primitive_token(token)?, + }); + } + rows.push(Node::Object(row)); + } + + assert_expected_count(rows.len(), header.length, "tabular rows", options.strict)?; + + if let (Some(first_line), Some(last_line)) = (start_line, end_line) { + validate_no_blank_lines( + first_line, + last_line, + cursor.blank_lines(), + options.strict, + "tabular array", + )?; + } + + if options.strict { + validate_no_extra_tabular_rows(cursor.peek(), row_depth, header.delimiter, header.length)?; + } + + Ok(Node::Array(rows)) +} + +fn parse_list_array( + header: &ArrayHeaderInfo, + cursor: &mut Cursor, + base_depth: usize, + options: DecodeOptions, +) -> Result { + let item_depth = base_depth + 1; + let mut items = Vec::new(); + let mut start_line = None; + let mut end_line = None; + + while items.len() < header.length { + let Some(line) = cursor.peek().cloned() else { + break; + }; + if line.depth < item_depth { + break; + } + + let is_list_item = + line.depth == item_depth && (line.content == "-" || line.content.starts_with("- ")); + if !is_list_item { + break; + } + + if start_line.is_none() { + start_line = Some(line.line_number); + } + + items.push(parse_list_item(cursor, item_depth, options)?); + end_line = cursor.current_line_number().or(Some(line.line_number)); + } + + assert_expected_count( + items.len(), + header.length, + "list array items", + options.strict, + )?; + + if let (Some(first_line), Some(last_line)) = (start_line, end_line) { + validate_no_blank_lines( + first_line, + last_line, + cursor.blank_lines(), + options.strict, + "list array", + )?; + } + + if options.strict { + validate_no_extra_list_items(cursor.peek(), item_depth, header.length)?; + } + + Ok(Node::Array(items)) +} + +fn parse_list_item( + cursor: &mut Cursor, + base_depth: usize, + options: DecodeOptions, +) -> Result { + let line = cursor + .next() + .ok_or_else(|| CliError::runtime("expected list item"))?; + + let after_hyphen = if line.content == "-" { + return Ok(Node::Object(NodeObject::default())); + } else if let Some(rest) = line.content.strip_prefix("- ") { + rest + } else { + return Err(CliError::runtime(format!( + "line {}: expected list item to start with '-'", + line.line_number + ))); + }; + + if after_hyphen.trim().is_empty() { + return Ok(Node::Object(NodeObject::default())); + } + + if after_hyphen.trim_start().starts_with('[') { + if let Some((header, inline_values)) = + parse_array_header_line(after_hyphen, Delimiter::Comma)? + { + return parse_array_from_header( + &header, + inline_values.as_deref(), + cursor, + base_depth, + options, + ); + } + if let Some(array) = parse_bracket_inline_array(after_hyphen)? { + return Ok(array); + } + } + + if let Some((header, inline_values)) = parse_array_header_line(after_hyphen, Delimiter::Comma)? + { + if header.key.is_some() && header.fields.is_some() { + let key = header.key.clone().unwrap_or_default(); + let value = parse_array_from_header( + &header, + inline_values.as_deref(), + cursor, + base_depth + 1, + options, + )?; + let mut object = NodeObject { + fields: vec![NodeField { + key, + was_quoted: header.key_was_quoted, + value, + }], + }; + parse_follow_object_fields(cursor, base_depth + 1, options, &mut object)?; + return Ok(Node::Object(object)); + } + } + + if is_key_value_content(after_hyphen) { + let mut object = NodeObject::default(); + object.fields.push(parse_key_value( + after_hyphen, + cursor, + base_depth + 1, + options, + )?); + parse_follow_object_fields(cursor, base_depth + 1, options, &mut object)?; + return Ok(Node::Object(object)); + } + + parse_primitive_token(after_hyphen) +} + +fn parse_follow_object_fields( + cursor: &mut Cursor, + follow_depth: usize, + options: DecodeOptions, + object: &mut NodeObject, +) -> Result<(), CliError> { + while let Some(next_line) = cursor.peek().cloned() { + if next_line.depth < follow_depth { + break; + } + if next_line.depth != follow_depth + || next_line.content == "-" + || next_line.content.starts_with("- ") + { + break; + } + + let consumed = cursor + .next() + .ok_or_else(|| CliError::runtime("expected object field"))?; + object.fields.push(parse_key_value( + &consumed.content, + cursor, + follow_depth, + options, + )?); + } + + Ok(()) +} + +fn parse_array_header_line( + content: &str, + default_delimiter: Delimiter, +) -> Result)>, CliError> { + let trimmed = content.trim_start(); + let bracket_start = if trimmed.starts_with('"') { + let closing_quote = find_closing_quote(trimmed, 0) + .ok_or_else(|| CliError::runtime("unterminated quoted key in array header"))?; + if !trimmed[closing_quote + 1..].starts_with('[') { + return Ok(None); + } + content.len() - trimmed.len() + closing_quote + 1 + } else if let Some(index) = content.find('[') { + index + } else { + return Ok(None); + }; + + let Some(relative_bracket_end) = content[bracket_start + 1..].find(']') else { + return Err(CliError::runtime("array header is missing ']'")); + }; + let bracket_end = bracket_start + 1 + relative_bracket_end; + + let brace_start = content[bracket_end + 1..] + .find('{') + .map(|index| bracket_end + 1 + index); + let colon_after_bracket = content[bracket_end + 1..] + .find(':') + .map(|index| bracket_end + 1 + index); + + let (brace_end, colon_index) = + if let (Some(start), Some(colon_index)) = (brace_start, colon_after_bracket) { + if start < colon_index { + if !content[bracket_end + 1..start].trim().is_empty() { + return Ok(None); + } + + let Some(relative_brace_end) = content[start + 1..].find('}') else { + return Err(CliError::runtime("array header is missing '}'")); + }; + let end = start + 1 + relative_brace_end; + let Some(relative_colon) = content[end + 1..].find(':') else { + return Err(CliError::runtime("array header is missing ':'")); + }; + (Some(end), end + 1 + relative_colon) + } else { + (None, colon_index) + } + } else if let Some(colon_index) = colon_after_bracket { + (None, colon_index) + } else { + return Ok(None); + }; + + let gap_start = brace_end.map_or(bracket_end + 1, |end| end + 1); + if !content[gap_start..colon_index].trim().is_empty() { + return Ok(None); + } + + let (key, key_was_quoted) = if bracket_start == 0 { + (None, false) + } else { + let raw_key = content[..bracket_start].trim(); + if raw_key.is_empty() { + (None, false) + } else if raw_key.starts_with('"') { + (Some(parse_string_literal(raw_key)?), true) + } else { + (Some(raw_key.to_string()), false) + } + }; + + let (length, delimiter) = + parse_bracket_segment(&content[bracket_start + 1..bracket_end], default_delimiter)?; + let fields = if let Some(end) = brace_end { + let raw_fields = &content[brace_start.unwrap_or(bracket_end + 1) + 1..end]; + Some( + parse_delimited_values(raw_fields, delimiter) + .iter() + .map(|field| parse_string_literal(field.trim())) + .collect::, _>>()?, + ) + } else { + None + }; + + let trailing = content[colon_index + 1..].trim(); + Ok(Some(( + ArrayHeaderInfo { + key, + key_was_quoted, + length, + delimiter, + fields, + }, + (!trailing.is_empty()).then(|| trailing.to_string()), + ))) +} + +fn parse_bracket_segment( + segment: &str, + default_delimiter: Delimiter, +) -> Result<(usize, Delimiter), CliError> { + let (length_text, delimiter) = segment.strip_suffix('\t').map_or_else( + || { + segment + .strip_suffix('|') + .map_or((segment, default_delimiter), |stripped| { + (stripped, Delimiter::Pipe) + }) + }, + |stripped| (stripped, Delimiter::Tab), + ); + + let length = length_text + .parse::() + .map_err(|error| CliError::runtime(format!("invalid array length '{segment}': {error}")))?; + Ok((length, delimiter)) +} + +fn parse_key_token(content: &str) -> Result<(String, bool, usize), CliError> { + let trimmed = content.trim_start(); + let start = content.len() - trimmed.len(); + if trimmed.starts_with('"') { + let closing_quote = find_closing_quote(content, start) + .ok_or_else(|| CliError::runtime("unterminated quoted key"))?; + if content.as_bytes().get(closing_quote + 1).copied() != Some(b':') { + return Err(CliError::runtime("missing ':' after quoted key")); + } + let key = unescape_string(&content[start + 1..closing_quote])?; + Ok((key, true, closing_quote + 2)) + } else { + let Some(colon_index) = content[start..].find(':').map(|index| start + index) else { + return Err(CliError::runtime("missing ':' after key")); + }; + let key = content[start..colon_index].trim().to_string(); + Ok((key, false, colon_index + 1)) + } +} + +fn parse_primitive_token(token: &str) -> Result { + let trimmed = token.trim(); + if trimmed.is_empty() { + return Ok(Node::String(String::new())); + } + + if trimmed.starts_with('"') { + return Ok(Node::String(parse_string_literal(trimmed)?)); + } + + match trimmed { + "true" => return Ok(Node::Bool(true)), + "false" => return Ok(Node::Bool(false)), + "null" => return Ok(Node::Null), + _ => {} + } + + if is_numeric_literal(trimmed) { + return Ok(Node::Number(parse_number_literal(trimmed)?)); + } + + Ok(Node::String(trimmed.to_string())) +} + +fn parse_bracket_inline_array(token: &str) -> Result, CliError> { + let trimmed = token.trim(); + let Some(inner) = trimmed + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + else { + return Ok(None); + }; + + if inner.trim().is_empty() { + return Ok(Some(Node::Array(Vec::new()))); + } + + let values = parse_delimited_values(inner, Delimiter::Comma) + .into_iter() + .map(|value| parse_primitive_token(&value)) + .collect::, _>>()?; + Ok(Some(Node::Array(values))) +} + +fn parse_string_literal(token: &str) -> Result { + let trimmed = token.trim(); + if !trimmed.starts_with('"') { + return Ok(trimmed.to_string()); + } + + let closing_quote = find_closing_quote(trimmed, 0) + .ok_or_else(|| CliError::runtime("unterminated string literal"))?; + if closing_quote != trimmed.len() - 1 { + return Err(CliError::runtime( + "unexpected characters after closing quote", + )); + } + + unescape_string(&trimmed[1..closing_quote]) +} + +fn parse_delimited_values(input: &str, delimiter: Delimiter) -> Vec { + let mut values = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let bytes = input.as_bytes(); + let delimiter_byte = delimiter.as_char() as u8; + let mut index = 0; + + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'\\' && in_quotes && index + 1 < bytes.len() { + current.push(char::from(byte)); + current.push(char::from(bytes[index + 1])); + index += 2; + continue; + } + if byte == b'"' { + in_quotes = !in_quotes; + current.push('"'); + index += 1; + continue; + } + if byte == delimiter_byte && !in_quotes { + values.push(current.trim().to_string()); + current.clear(); + index += 1; + continue; + } + + current.push(char::from(byte)); + index += 1; + } + + if !current.is_empty() || !values.is_empty() { + values.push(current.trim().to_string()); + } + + values +} + +fn is_key_value_line(content: &str) -> bool { + find_unquoted_char(content, ':', 0).is_some() +} + +fn is_key_value_content(content: &str) -> bool { + find_unquoted_char(content, ':', 0).is_some() +} + +fn assert_expected_count( + actual: usize, + expected: usize, + item_type: &str, + strict: bool, +) -> Result<(), CliError> { + if strict && actual != expected { + return Err(CliError::runtime(format!( + "expected {expected} {item_type}, but got {actual}" + ))); + } + Ok(()) +} + +fn validate_no_blank_lines( + start_line: usize, + end_line: usize, + blank_lines: &[usize], + strict: bool, + context: &str, +) -> Result<(), CliError> { + if !strict { + return Ok(()); + } + + if let Some(line_number) = blank_lines + .iter() + .copied() + .find(|line_number| *line_number > start_line && *line_number < end_line) + { + return Err(CliError::runtime(format!( + "line {line_number}: blank lines inside {context} are not allowed in strict mode" + ))); + } + + Ok(()) +} + +fn validate_no_extra_list_items( + next_line: Option<&ParsedLine>, + item_depth: usize, + expected_count: usize, +) -> Result<(), CliError> { + if let Some(line) = next_line { + if line.depth == item_depth && (line.content == "-" || line.content.starts_with("- ")) { + return Err(CliError::runtime(format!( + "expected {expected_count} list array items, but found more" + ))); + } + } + + Ok(()) +} + +fn validate_no_extra_tabular_rows( + next_line: Option<&ParsedLine>, + row_depth: usize, + delimiter: Delimiter, + expected_count: usize, +) -> Result<(), CliError> { + if let Some(line) = next_line { + if line.depth == row_depth + && line.content != "-" + && !line.content.starts_with("- ") + && is_data_row(&line.content, delimiter) + { + return Err(CliError::runtime(format!( + "expected {expected_count} tabular rows, but found more" + ))); + } + } + + Ok(()) +} + +fn is_data_row(content: &str, delimiter: Delimiter) -> bool { + let colon_position = find_unquoted_char(content, ':', 0); + let delimiter_position = find_unquoted_char(content, delimiter.as_char(), 0); + + match (colon_position, delimiter_position) { + (None, _) => true, + (Some(_), Some(delimiter_position)) => { + delimiter_position < colon_position.unwrap_or(usize::MAX) + } + (Some(_), None) => false, + } +} + +fn expand_paths_safe(node: Node, strict: bool) -> Result { + match node { + Node::Array(items) => Ok(Node::Array( + items + .into_iter() + .map(|item| expand_paths_safe(item, strict)) + .collect::, _>>()?, + )), + Node::Object(object) => { + let mut expanded = NodeObject::default(); + + for field in object.fields { + let expanded_value = expand_paths_safe(field.value, strict)?; + if field.key.contains('.') && !field.was_quoted { + let segments = field.key.split('.').collect::>(); + if segments.len() > MAX_TOON_DEPTH { + return Err(CliError::runtime(format!( + "maximum TOON path depth of {MAX_TOON_DEPTH} exceeded for '{}'", + field.key + ))); + } + if segments + .iter() + .all(|segment| is_identifier_segment(segment)) + { + insert_path_safe(&mut expanded, &segments, expanded_value, strict)?; + continue; + } + } + + merge_or_insert_field( + &mut expanded, + NodeField { + key: field.key, + was_quoted: field.was_quoted, + value: expanded_value, + }, + strict, + )?; + } + + Ok(Node::Object(expanded)) + } + primitive => Ok(primitive), + } +} + +fn insert_path_safe( + target: &mut NodeObject, + segments: &[&str], + value: Node, + strict: bool, +) -> Result<(), CliError> { + let Some((first_segment, rest_segments)) = segments.split_first() else { + return Ok(()); + }; + + if rest_segments.is_empty() { + merge_or_insert_field( + target, + NodeField { + key: (*first_segment).to_string(), + was_quoted: false, + value, + }, + strict, + )?; + return Ok(()); + } + + let child = ensure_object_field(target, first_segment, strict)?; + insert_path_safe(child, rest_segments, value, strict) +} + +fn ensure_object_field<'target>( + target: &'target mut NodeObject, + key: &str, + strict: bool, +) -> Result<&'target mut NodeObject, CliError> { + let index = if let Some(index) = target.position(key) { + index + } else { + target.fields.push(NodeField { + key: key.to_string(), + was_quoted: false, + value: Node::Object(NodeObject::default()), + }); + target.fields.len() - 1 + }; + + let is_object = target + .fields + .get(index) + .is_some_and(|field| matches!(field.value, Node::Object(_))); + if !is_object { + if strict { + return Err(CliError::runtime(format!( + "path expansion conflict at segment '{key}'" + ))); + } + if let Some(field) = target.fields.get_mut(index) { + field.value = Node::Object(NodeObject::default()); + } + } + + let field = target + .fields + .get_mut(index) + .ok_or_else(|| CliError::runtime("missing object field"))?; + match &mut field.value { + Node::Object(object) => Ok(object), + _ => Err(CliError::runtime("failed to replace conflicting field")), + } +} + +fn merge_or_insert_field( + target: &mut NodeObject, + field: NodeField, + strict: bool, +) -> Result<(), CliError> { + if let Some(index) = target.position(&field.key) { + let existing = target + .fields + .get_mut(index) + .ok_or_else(|| CliError::runtime("missing object field"))?; + if matches!( + (&mut existing.value, &field.value), + (Node::Object(_), Node::Object(_)) + ) { + let Node::Object(existing_object) = &mut existing.value else { + return Err(CliError::runtime("expected object during merge")); + }; + let Node::Object(source_object) = field.value else { + return Err(CliError::runtime("expected source object during merge")); + }; + merge_objects(existing_object, source_object, strict)?; + return Ok(()); + } + + if strict { + return Err(CliError::runtime(format!( + "path expansion conflict at key '{}'", + existing.key + ))); + } + + existing.value = field.value; + existing.was_quoted = field.was_quoted; + return Ok(()); + } + + target.fields.push(field); + Ok(()) +} + +fn merge_objects( + target: &mut NodeObject, + source: NodeObject, + strict: bool, +) -> Result<(), CliError> { + for field in source.fields { + merge_or_insert_field(target, field, strict)?; + } + Ok(()) +} + +fn encode_toon(value: &Value, options: EncodeOptions) -> Result { + ensure_encode_depth(value)?; + let mut lines = Vec::with_capacity(estimate_line_count(value)); + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + let primitive = encode_primitive_value(value, options.delimiter); + if !primitive.is_empty() { + lines.push(primitive); + } + } + Value::Array(array) => encode_array_lines(None, array, 0, options, &mut lines), + Value::Object(object) => { + let root_literal_keys = object + .keys() + .filter(|key| key.contains('.')) + .cloned() + .collect::>(); + encode_object_lines( + object, + 0, + options, + &root_literal_keys, + None, + options.flatten_depth, + &mut lines, + ); + } + } + + Ok(lines.join("\n")) +} + +fn ensure_encode_depth(value: &Value) -> Result<(), CliError> { + let mut stack = vec![(value, 0_usize)]; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_TOON_DEPTH { + return Err(CliError::runtime(format!( + "maximum TOON encoding depth of {MAX_TOON_DEPTH} exceeded" + ))); + } + + match node { + Value::Array(items) => { + for item in items { + stack.push((item, depth + 1)); + } + } + Value::Object(object) => { + for child in object.values() { + stack.push((child, depth + 1)); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + } + + Ok(()) +} + +fn encode_object_lines( + object: &Map, + depth: usize, + options: EncodeOptions, + root_literal_keys: &[String], + path_prefix: Option<&str>, + flatten_depth: usize, + lines: &mut Vec, +) { + let sibling_keys = object.keys().map(String::as_str).collect::>(); + let context = EncodeContext { + options, + root_literal_keys, + path_prefix, + flatten_depth, + }; + + for (key, value) in object { + encode_object_entry(context, &sibling_keys, key, value, depth, lines); + } +} + +fn encode_object_entry( + context: EncodeContext<'_>, + sibling_keys: &[&str], + key: &str, + value: &Value, + depth: usize, + lines: &mut Vec, +) { + if context.options.key_folding == SafeMode::Safe + && encode_folded_object_entry(context, sibling_keys, key, value, depth, lines) + { + return; + } + + emit_standard_object_entry(context, key, value, depth, lines); +} + +fn encode_folded_object_entry( + context: EncodeContext<'_>, + sibling_keys: &[&str], + key: &str, + value: &Value, + depth: usize, + lines: &mut Vec, +) -> bool { + let Some(fold_result) = try_fold_key_chain( + key, + value, + sibling_keys, + context.root_literal_keys, + context.path_prefix, + context.flatten_depth, + ) else { + return false; + }; + + let encoded_key = encode_key(&fold_result.folded_key); + if let Some(remainder) = fold_result.remainder { + lines.push(indented_line( + depth, + &format!("{encoded_key}:"), + context.options.indent, + )); + let folded_path = joined_path(context.path_prefix, &fold_result.folded_key); + encode_object_lines( + remainder, + depth + 1, + context.options, + context.root_literal_keys, + Some(&folded_path), + context + .flatten_depth + .saturating_sub(fold_result.segment_count), + lines, + ); + return true; + } + + match fold_result.leaf_value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + lines.push(indented_line( + depth, + &format!( + "{encoded_key}: {}", + encode_primitive_value(fold_result.leaf_value, context.options.delimiter) + ), + context.options.indent, + )); + } + Value::Array(array) => encode_array_lines( + Some(&fold_result.folded_key), + array, + depth, + context.options, + lines, + ), + Value::Object(object) => { + lines.push(indented_line( + depth, + &format!("{encoded_key}:"), + context.options.indent, + )); + if !object.is_empty() { + let folded_path = joined_path(context.path_prefix, &fold_result.folded_key); + encode_object_lines( + object, + depth + 1, + context.options, + context.root_literal_keys, + Some(&folded_path), + context + .flatten_depth + .saturating_sub(fold_result.segment_count), + lines, + ); + } + } + } + + true +} + +fn emit_standard_object_entry( + context: EncodeContext<'_>, + key: &str, + value: &Value, + depth: usize, + lines: &mut Vec, +) { + let encoded_key = encode_key(key); + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + lines.push(indented_line( + depth, + &format!( + "{encoded_key}: {}", + encode_primitive_value(value, context.options.delimiter) + ), + context.options.indent, + )); + } + Value::Array(array) => encode_array_lines(Some(key), array, depth, context.options, lines), + Value::Object(object) => { + lines.push(indented_line( + depth, + &format!("{encoded_key}:"), + context.options.indent, + )); + if !object.is_empty() { + encode_object_lines( + object, + depth + 1, + context.options, + context.root_literal_keys, + Some(&joined_path(context.path_prefix, key)), + context.flatten_depth, + lines, + ); + } + } + } +} + +fn encode_array_lines( + key: Option<&str>, + array: &[Value], + depth: usize, + options: EncodeOptions, + lines: &mut Vec, +) { + if array.is_empty() { + lines.push(indented_line( + depth, + &format_header(0, key, None, options.delimiter), + options.indent, + )); + return; + } + + if array.iter().all(is_json_primitive) { + lines.push(indented_line( + depth, + &encode_inline_array_line(array, options.delimiter, key), + options.indent, + )); + return; + } + + if array.iter().all(Value::is_array) + && array + .iter() + .filter_map(Value::as_array) + .all(|nested| nested.iter().all(is_json_primitive)) + { + lines.push(indented_line( + depth, + &format_header(array.len(), key, None, options.delimiter), + options.indent, + )); + for nested in array.iter().filter_map(Value::as_array) { + let inline = encode_inline_array_line(nested, options.delimiter, None); + lines.push(indented_list_item(depth + 1, &inline, options.indent)); + } + return; + } + + if array.iter().all(Value::is_object) { + if let Some(header) = extract_tabular_header(array) { + lines.push(indented_line( + depth, + &format_header(array.len(), key, Some(&header), options.delimiter), + options.indent, + )); + for row in array.iter().filter_map(Value::as_object) { + let joined = joined_tabular_values(row, &header, options.delimiter); + lines.push(indented_line(depth + 1, &joined, options.indent)); + } + return; + } + } + + lines.push(indented_line( + depth, + &format_header(array.len(), key, None, options.delimiter), + options.indent, + )); + for item in array { + encode_list_item_value(item, depth + 1, options, lines); + } +} + +fn encode_list_item_value( + value: &Value, + depth: usize, + options: EncodeOptions, + lines: &mut Vec, +) { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + lines.push(indented_list_item( + depth, + &encode_primitive_value(value, options.delimiter), + options.indent, + )); + } + Value::Array(array) => { + if array.iter().all(is_json_primitive) { + let inline = encode_inline_array_line(array, options.delimiter, None); + lines.push(indented_list_item(depth, &inline, options.indent)); + } else { + lines.push(indented_list_item( + depth, + &format_header(array.len(), None, None, options.delimiter), + options.indent, + )); + for item in array { + encode_list_item_value(item, depth + 1, options, lines); + } + } + } + Value::Object(object) => encode_object_as_list_item(object, depth, options, lines), + } +} + +fn encode_object_as_list_item( + object: &Map, + depth: usize, + options: EncodeOptions, + lines: &mut Vec, +) { + if object.is_empty() { + lines.push(indented_line(depth, "-", options.indent)); + return; + } + + let mut entries = object.iter(); + let Some((first_key, first_value)) = entries.next() else { + lines.push(indented_line(depth, "-", options.indent)); + return; + }; + let rest = entries + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + + if let Value::Array(array) = first_value { + if encode_tabular_first_field(first_key, array, &rest, depth, options, lines) { + return; + } + } + + let encoded_key = encode_key(first_key); + match first_value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + lines.push(indented_list_item( + depth, + &format!( + "{encoded_key}: {}", + encode_primitive_value(first_value, options.delimiter) + ), + options.indent, + )); + } + Value::Array(array) => { + if array.is_empty() { + lines.push(indented_list_item( + depth, + &format!( + "{encoded_key}{}", + format_header(0, None, None, options.delimiter) + ), + options.indent, + )); + } else if array.iter().all(is_json_primitive) { + lines.push(indented_list_item( + depth, + &format!( + "{encoded_key}{}", + encode_inline_array_line(array, options.delimiter, None) + ), + options.indent, + )); + } else { + lines.push(indented_list_item( + depth, + &format!( + "{encoded_key}{}", + format_header(array.len(), None, None, options.delimiter) + ), + options.indent, + )); + for item in array { + encode_list_item_value(item, depth + 2, options, lines); + } + } + } + Value::Object(inner) => { + lines.push(indented_list_item( + depth, + &format!("{encoded_key}:"), + options.indent, + )); + if !inner.is_empty() { + encode_object_lines( + inner, + depth + 2, + options, + &[], + None, + options.flatten_depth, + lines, + ); + } + } + } + + emit_rest_object_fields(&rest, depth + 1, options, lines); +} + +fn encode_tabular_first_field( + first_key: &str, + array: &[Value], + rest: &[(String, Value)], + depth: usize, + options: EncodeOptions, + lines: &mut Vec, +) -> bool { + let Some(header) = extract_tabular_header(array) else { + return false; + }; + + let header_text = format_header( + array.len(), + Some(first_key), + Some(&header), + options.delimiter, + ); + lines.push(indented_list_item(depth, &header_text, options.indent)); + for row in array.iter().filter_map(Value::as_object) { + let joined = joined_tabular_values(row, &header, options.delimiter); + lines.push(indented_line(depth + 2, &joined, options.indent)); + } + emit_rest_object_fields(rest, depth + 1, options, lines); + true +} + +fn emit_rest_object_fields( + rest: &[(String, Value)], + depth: usize, + options: EncodeOptions, + lines: &mut Vec, +) { + if rest.is_empty() { + return; + } + + let rest_map = rest.iter().cloned().collect::>(); + encode_object_lines( + &rest_map, + depth, + options, + &[], + None, + options.flatten_depth, + lines, + ); +} + +fn extract_tabular_header(array: &[Value]) -> Option> { + let first = array.first()?.as_object()?; + if first.is_empty() { + return None; + } + + let header = first.keys().cloned().collect::>(); + let is_valid = array.iter().filter_map(Value::as_object).all(|row| { + row.len() == header.len() + && header + .iter() + .all(|field| row.get(field).is_some_and(is_json_primitive)) + }); + is_valid.then_some(header) +} + +fn encode_inline_array_line(array: &[Value], delimiter: Delimiter, key: Option<&str>) -> String { + let header = format_header(array.len(), key, None, delimiter); + let joined = joined_primitive_values(array, delimiter); + + if array.is_empty() { + header + } else { + format!("{header} {joined}") + } +} + +fn encode_primitive_value(value: &Value, delimiter: Delimiter) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(boolean) => boolean.to_string(), + Value::Number(number) => canonical_number(number), + Value::String(text) => encode_string_literal(text, delimiter), + Value::Array(_) | Value::Object(_) => String::new(), + } +} + +fn encode_string_literal(value: &str, delimiter: Delimiter) -> String { + if is_safe_unquoted(value, delimiter) { + value.to_string() + } else { + format!("\"{}\"", escape_string(value)) + } +} + +fn encode_key(key: &str) -> String { + if is_valid_unquoted_key(key) { + key.to_string() + } else { + format!("\"{}\"", escape_string(key)) + } +} + +fn format_header( + length: usize, + key: Option<&str>, + fields: Option<&[String]>, + delimiter: Delimiter, +) -> String { + let mut rendered = String::new(); + if let Some(key) = key { + rendered.push_str(&encode_key(key)); + } + rendered.push('['); + rendered.push_str(&length.to_string()); + if delimiter != Delimiter::Comma { + rendered.push(delimiter.as_char()); + } + rendered.push(']'); + if let Some(fields) = fields { + rendered.push('{'); + push_encoded_fields(&mut rendered, fields, delimiter); + rendered.push('}'); + } + rendered.push(':'); + rendered +} + +fn joined_tabular_values( + row: &Map, + header: &[String], + delimiter: Delimiter, +) -> String { + let mut output = String::with_capacity(header.len().saturating_mul(12)); + for (index, field) in header.iter().enumerate() { + if index > 0 { + output.push_str(delimiter_string(delimiter)); + } + output.push_str(&encode_primitive_value( + row.get(field).unwrap_or(&Value::Null), + delimiter, + )); + } + output +} + +fn joined_primitive_values(array: &[Value], delimiter: Delimiter) -> String { + let mut output = String::with_capacity(array.len().saturating_mul(12)); + for (index, value) in array.iter().enumerate() { + if index > 0 { + output.push_str(delimiter_string(delimiter)); + } + output.push_str(&encode_primitive_value(value, delimiter)); + } + output +} + +fn push_encoded_fields(output: &mut String, fields: &[String], delimiter: Delimiter) { + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push_str(delimiter_string(delimiter)); + } + output.push_str(&encode_key(field)); + } +} + +fn estimate_line_count(value: &Value) -> usize { + match value { + Value::Object(object) => object + .values() + .map(estimate_line_count) + .sum::() + .max(object.len()), + Value::Array(items) => items.len().saturating_add(1), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => 1, + } +} + +fn canonical_number(number: &Number) -> String { + if let Some(integer) = number.as_i64() { + return integer.to_string(); + } + if let Some(unsigned) = number.as_u64() { + return unsigned.to_string(); + } + + let rendered = number.to_string(); + let expanded = expand_exponent(&rendered); + if expanded == "-0" || expanded == "-0.0" || expanded == "-0." { + "0".to_string() + } else { + expanded + } +} + +fn expand_exponent(rendered: &str) -> String { + let Some(exponent_index) = rendered.find(['e', 'E']) else { + return trim_fraction(rendered); + }; + + let sign_length = usize::from(rendered.starts_with('-')); + let (sign, unsigned) = rendered.split_at(sign_length); + let exponent_index = unsigned.find(['e', 'E']).unwrap_or(exponent_index); + let significand = &unsigned[..exponent_index]; + let exponent = unsigned[exponent_index + 1..].parse::().unwrap_or(0); + + let (integer_part, fraction_part) = significand + .find('.') + .map_or((significand, ""), |dot_index| { + (&significand[..dot_index], &significand[dot_index + 1..]) + }); + + let digits = format!("{integer_part}{fraction_part}"); + let integer_length = i64::try_from(integer_part.len()).unwrap_or(i64::MAX); + let decimal_position = integer_length + i64::from(exponent); + let digits_length = i64::try_from(digits.len()).unwrap_or(i64::MAX); + + let unsigned_result = if decimal_position <= 0 { + format!( + "0.{}{}", + "0".repeat(usize::try_from(decimal_position.unsigned_abs()).unwrap_or(usize::MAX)), + digits + ) + } else if decimal_position >= digits_length { + let zero_count = usize::try_from(decimal_position - digits_length).unwrap_or(usize::MAX); + format!("{}{}", digits, "0".repeat(zero_count)) + } else { + let split = usize::try_from(decimal_position).unwrap_or(digits.len()); + format!("{}.{}", &digits[..split], &digits[split..]) + }; + + let trimmed = trim_fraction(&unsigned_result); + if trimmed.chars().all(|character| character == '0') { + return "0".to_string(); + } + if sign == "-" && trimmed != "0" { + format!("-{trimmed}") + } else { + trimmed + } +} + +fn trim_fraction(rendered: &str) -> String { + rendered.find('.').map_or_else( + || rendered.to_string(), + |dot_index| { + let mut trimmed = rendered.to_string(); + while trimmed.ends_with('0') { + trimmed.pop(); + } + if trimmed.ends_with('.') { + trimmed.pop(); + } + if trimmed.is_empty() { + "0".to_string() + } else if dot_index == 0 { + format!("0{trimmed}") + } else { + trimmed + } + }, + ) +} + +const fn is_json_primitive(value: &Value) -> bool { + matches!( + value, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) +} + +fn try_fold_key_chain<'value>( + key: &str, + value: &'value Value, + siblings: &[&str], + root_literal_keys: &[String], + path_prefix: Option<&str>, + flatten_depth: usize, +) -> Option> { + let Value::Object(_) = value else { + return None; + }; + if flatten_depth < 2 { + return None; + } + + let (segments, remainder, leaf_value) = collect_single_key_chain(key, value, flatten_depth); + if segments.len() < 2 + || !segments + .iter() + .all(|segment| is_identifier_segment(segment)) + { + return None; + } + + let folded_key = segments.join("."); + if siblings.contains(&folded_key.as_str()) { + return None; + } + + let absolute_path = joined_path(path_prefix, &folded_key); + if root_literal_keys + .iter() + .any(|literal| literal == &absolute_path) + { + return None; + } + + Some(FoldResult { + folded_key, + remainder, + leaf_value, + segment_count: segments.len(), + }) +} + +fn collect_single_key_chain<'value>( + start_key: &str, + start_value: &'value Value, + max_depth: usize, +) -> ( + Vec, + Option<&'value Map>, + &'value Value, +) { + let mut segments = vec![start_key.to_string()]; + let mut current = start_value; + + while segments.len() < max_depth { + let Some(object) = current.as_object() else { + break; + }; + if object.len() != 1 { + break; + } + + let Some((next_key, next_value)) = object.iter().next() else { + break; + }; + segments.push(next_key.clone()); + current = next_value; + } + + if let Some(object) = current.as_object() { + if !object.is_empty() { + return (segments, Some(object), current); + } + } + + (segments, None, current) +} + +fn joined_path(prefix: Option<&str>, key: &str) -> String { + prefix.map_or_else(|| key.to_string(), |prefix| format!("{prefix}.{key}")) +} + +fn indented_line(depth: usize, content: &str, indent: usize) -> String { + format!("{}{}", " ".repeat(depth * indent), content) +} + +fn indented_list_item(depth: usize, content: &str, indent: usize) -> String { + indented_line(depth, &format!("- {content}"), indent) +} + +const fn delimiter_string(delimiter: Delimiter) -> &'static str { + match delimiter { + Delimiter::Comma => ",", + Delimiter::Tab => "\t", + Delimiter::Pipe => "|", + } +} + +fn is_valid_unquoted_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|character| character == '_' || character == '.' || character.is_ascii_alphanumeric()) +} + +fn is_identifier_segment(segment: &str) -> bool { + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) +} + +fn is_safe_unquoted(value: &str, delimiter: Delimiter) -> bool { + if value.is_empty() || value != value.trim() { + return false; + } + if matches!(value, "true" | "false" | "null") || is_numeric_like(value) { + return false; + } + if value.contains(':') + || value.contains('"') + || value.contains('\\') + || value.contains(['[', ']', '{', '}']) + || value.contains(['\n', '\r', '\t']) + || value.contains(delimiter.as_char()) + || value.starts_with('-') + { + return false; + } + + true +} + +fn is_numeric_like(value: &str) -> bool { + if is_numeric_literal(value) { + return true; + } + + let unsigned = value.strip_prefix('-').unwrap_or(value); + unsigned.len() > 1 + && unsigned.starts_with('0') + && unsigned.as_bytes().get(1).is_some_and(u8::is_ascii_digit) +} + +fn is_numeric_literal(token: &str) -> bool { + if token.is_empty() { + return false; + } + + let bytes = token.as_bytes(); + let mut index = 0; + if bytes.get(index) == Some(&b'-') { + index += 1; + } + if index >= bytes.len() { + return false; + } + + if bytes[index] == b'0' { + index += 1; + if bytes.get(index).is_some_and(u8::is_ascii_digit) { + return false; + } + } else if bytes[index].is_ascii_digit() { + if !bytes[index].is_ascii_digit() || bytes[index] == b'0' { + return false; + } + index += 1; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + } else { + return false; + } + + if bytes.get(index) == Some(&b'.') { + index += 1; + let start = index; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + if index == start { + return false; + } + } + + if bytes + .get(index) + .is_some_and(|byte| matches!(byte, b'e' | b'E')) + { + index += 1; + if bytes + .get(index) + .is_some_and(|byte| matches!(byte, b'+' | b'-')) + { + index += 1; + } + let start = index; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + if index == start { + return false; + } + } + + index == bytes.len() && token.parse::().is_ok_and(f64::is_finite) +} + +fn parse_number_literal(token: &str) -> Result { + if let Ok(value) = token.parse::() { + return Ok(Number::from(value)); + } + if let Ok(value) = token.parse::() { + return Ok(Number::from(value)); + } + + let value = token.parse::().map_err(|error| { + CliError::runtime(format!("invalid numeric literal '{token}': {error}")) + })?; + if value == 0.0 { + return Ok(Number::from(0)); + } + + let canonical = expand_exponent(token); + if !canonical.contains('.') { + if let Ok(integer) = canonical.parse::() { + return Ok(Number::from(integer)); + } + if let Ok(unsigned) = canonical.parse::() { + return Ok(Number::from(unsigned)); + } + } + + Number::from_f64(value) + .ok_or_else(|| CliError::runtime(format!("numeric literal '{token}' is not finite"))) +} + +fn escape_string(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +fn unescape_string(value: &str) -> Result { + let mut result = String::new(); + let bytes = value.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'\\' { + let Some(next) = bytes.get(index + 1).copied() else { + return Err(CliError::runtime( + "invalid escape sequence: trailing backslash", + )); + }; + match next { + b'n' => result.push('\n'), + b'r' => result.push('\r'), + b't' => result.push('\t'), + b'\\' => result.push('\\'), + b'"' => result.push('"'), + _ => { + return Err(CliError::runtime(format!( + "invalid escape sequence: \\{}", + char::from(next) + ))); + } + } + index += 2; + continue; + } + + result.push(char::from(bytes[index])); + index += 1; + } + + Ok(result) +} + +fn find_closing_quote(content: &str, start: usize) -> Option { + let bytes = content.as_bytes(); + let mut index = start + 1; + + while index < bytes.len() { + if bytes[index] == b'\\' && index + 1 < bytes.len() { + index += 2; + continue; + } + if bytes[index] == b'"' { + return Some(index); + } + index += 1; + } + + None +} + +fn find_unquoted_char(content: &str, needle: char, start: usize) -> Option { + let bytes = content.as_bytes(); + let needle = needle as u8; + let mut index = start; + let mut in_quotes = false; + + while index < bytes.len() { + if bytes[index] == b'\\' && in_quotes && index + 1 < bytes.len() { + index += 2; + continue; + } + if bytes[index] == b'"' { + in_quotes = !in_quotes; + index += 1; + continue; + } + if bytes[index] == needle && !in_quotes { + return Some(index); + } + index += 1; + } + + None +} + +impl Cursor { + fn new(scan: ScanResult) -> Self { + Self { + lines: scan.lines, + blank_lines: scan.blank_lines, + index: 0, + last_line_number: None, + } + } + + fn peek(&self) -> Option<&ParsedLine> { + self.lines.get(self.index) + } + + fn next(&mut self) -> Option { + let line = self.lines.get(self.index).cloned()?; + self.index += 1; + self.last_line_number = Some(line.line_number); + Some(line) + } + + fn blank_lines(&self) -> &[usize] { + &self.blank_lines + } + + const fn current_line_number(&self) -> Option { + self.last_line_number + } +} + +impl NodeObject { + fn position(&self, key: &str) -> Option { + self.fields.iter().position(|field| field.key == key) + } +} + +impl Node { + fn into_value(self) -> Value { + match self { + Self::Null => Value::Null, + Self::Bool(boolean) => Value::Bool(boolean), + Self::Number(number) => Value::Number(number), + Self::String(text) => Value::String(text), + Self::Array(items) => Value::Array(items.into_iter().map(Self::into_value).collect()), + Self::Object(object) => { + let mut map = Map::new(); + for field in object.fields { + map.insert(field.key, field.value.into_value()); + } + Value::Object(map) + } + } + } +} + +impl Delimiter { + const fn from_choice(choice: DelimiterChoice) -> Self { + match choice { + DelimiterChoice::Comma => Self::Comma, + DelimiterChoice::Tab => Self::Tab, + DelimiterChoice::Pipe => Self::Pipe, + } + } + + const fn as_char(self) -> char { + match self { + Self::Comma => ',', + Self::Tab => '\t', + Self::Pipe => '|', + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use common::{ColorChoice, InputFormat}; + use serde_json::{Map, Value, json}; + use tempfile::tempdir; + + use super::*; + + fn common_args(json: bool, input_format: InputFormat) -> CommonArgs { + CommonArgs { + json, + format: None, + input_format, + color: ColorChoice::Never, + quiet: false, + } + } + + fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("toon-spec") + } + + fn deeply_nested_value(depth: usize) -> Value { + let mut value = json!(1); + for index in 0..depth { + let mut object = Map::new(); + object.insert(format!("k{index}"), value); + value = Value::Object(object); + } + value + } + + fn sorted_fixture_paths(directory: &Path) -> Vec { + let mut paths = fs::read_dir(directory) + .expect("fixture directory") + .map(|entry| entry.expect("fixture entry").path()) + .collect::>(); + paths.sort(); + paths + } + + fn encode_options_from_fixture(options: Option<&Value>) -> EncodeOptions { + EncodeOptions { + indent: option_usize(options, "indent").unwrap_or(2), + delimiter: option_delimiter(options), + key_folding: option_mode(options, "keyFolding"), + flatten_depth: option_usize(options, "flattenDepth").unwrap_or(usize::MAX), + } + } + + fn decode_options_from_fixture(options: Option<&Value>) -> DecodeOptions { + DecodeOptions { + indent: option_usize(options, "indent").unwrap_or(2), + strict: option_bool(options, "strict").unwrap_or(true), + expand_paths: option_mode(options, "expandPaths"), + } + } + + fn option_usize(options: Option<&Value>, key: &str) -> Option { + options + .and_then(Value::as_object) + .and_then(|object| object.get(key)) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + } + + fn option_bool(options: Option<&Value>, key: &str) -> Option { + options + .and_then(Value::as_object) + .and_then(|object| object.get(key)) + .and_then(Value::as_bool) + } + + fn option_mode(options: Option<&Value>, key: &str) -> SafeMode { + match options + .and_then(Value::as_object) + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + { + Some("safe") => SafeMode::Safe, + _ => SafeMode::Off, + } + } + + fn option_delimiter(options: Option<&Value>) -> Delimiter { + match options + .and_then(Value::as_object) + .and_then(|object| object.get("delimiter")) + .and_then(Value::as_str) + { + Some("\t") => Delimiter::Tab, + Some("|") => Delimiter::Pipe, + _ => Delimiter::Comma, + } + } + + #[test] + fn encode_json_to_toon_covers_nested_and_tabular_shapes() { + let rendered = encode_toon( + &json!({ + "context": { + "task": "Our favorite hikes together", + "location": "Boulder", + }, + "friends": ["ana", "luis", "sam"], + "hikes": [ + { + "id": 1, + "name": "Blue Lake Trail", + "distanceKm": 7.5, + "wasSunny": true, + }, + { + "id": 2, + "name": "Ridge Overlook", + "distanceKm": 9.2, + "wasSunny": false, + }, + ], + }), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + }, + ) + .expect("encode"); + + assert_eq!( + rendered, + "context:\n task: Our favorite hikes together\n location: Boulder\nfriends[3]: ana,luis,sam\nhikes[2]{id,name,distanceKm,wasSunny}:\n 1,Blue Lake Trail,7.5,true\n 2,Ridge Overlook,9.2,false" + ); + } + + #[test] + fn decode_toon_to_json_handles_tabular_and_list_forms() { + let decoded = decode_toon( + "items[2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5\nflags[3]: true,false,\"true\"\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + }, + ) + .expect("decode should succeed"); + + assert_eq!( + decoded, + json!({ + "items": [ + {"sku": "A1", "qty": 2, "price": 9.99}, + {"sku": "B2", "qty": 1, "price": 14.5}, + ], + "flags": [true, false, "true"], + }) + ); + } + + #[test] + fn decode_toon_expands_paths_without_touching_quoted_keys() { + let decoded = decode_toon( + "data.metadata.items[2]: a,b\n\"literal.path\": keep\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ) + .expect("decode should succeed"); + + assert_eq!( + decoded, + json!({ + "data": { + "metadata": { + "items": ["a", "b"], + }, + }, + "literal.path": "keep", + }) + ); + } + + #[test] + fn encode_supports_safe_key_folding_and_collisions() { + let folded = encode_toon( + &json!({ + "data": { + "metadata": { + "items": ["a", "b"], + }, + }, + "data.metadata.literal": "kept", + }), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Safe, + flatten_depth: usize::MAX, + }, + ) + .expect("encode"); + + assert_eq!( + folded, + "data.metadata.items[2]: a,b\ndata.metadata.literal: kept" + ); + } + + #[test] + fn delimiter_and_escape_rules_follow_spec_examples() { + let rendered = encode_toon( + &json!({ + "items": [ + {"order:id": 1, "full name": "Ada"}, + {"order:id": 2, "full name": "Bob"}, + ], + "note": "hello|world", + }), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Pipe, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + }, + ) + .expect("encode"); + assert_eq!( + rendered, + "items[2|]{\"order:id\"|\"full name\"}:\n 1|Ada\n 2|Bob\nnote: \"hello|world\"" + ); + + let decoded = decode_toon( + &rendered, + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + }, + ) + .expect("pipe-delimited toon should decode"); + assert_eq!( + decoded, + json!({ + "items": [ + {"order:id": 1, "full name": "Ada"}, + {"order:id": 2, "full name": "Bob"}, + ], + "note": "hello|world", + }) + ); + } + + #[test] + fn parser_rejects_strict_count_mismatches_and_bad_indent() { + let mismatch = decode_toon( + "tags[3]: a,b\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + }, + ) + .expect_err("length mismatch should fail"); + assert!(matches!( + mismatch, + CliError::Runtime(message) + if message.contains("expected 3 inline array items") + )); + + let indent = decode_toon( + "root:\n child: value\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + }, + ) + .expect_err("bad indentation should fail"); + assert!(matches!( + indent, + CliError::Runtime(message) + if message.contains("indentation must be a multiple") + )); + } + + #[test] + fn decoder_rejects_nesting_past_explicit_depth_limit() { + let mut input = String::new(); + for depth in 0..260 { + input.push_str(&" ".repeat(depth)); + input.push_str("key:\n"); + } + + let error = decode_toon( + &input, + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + }, + ) + .expect_err("depth limit"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("maximum TOON nesting depth") + )); + } + + #[test] + fn decoder_rejects_path_expansion_past_explicit_depth_limit() { + let key = (0..260) + .map(|index| format!("k{index}")) + .collect::>() + .join("."); + let input = format!("{key}: 1"); + + let error = decode_toon( + &input, + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ) + .expect_err("path depth limit"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("maximum TOON path depth") + )); + } + + #[test] + fn encoder_rejects_nesting_past_explicit_depth_limit() { + let error = encode_toon( + &deeply_nested_value(260), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + }, + ) + .expect_err("deep TOON encode should fail"); + + assert!(matches!( + error, + CliError::Runtime(message) if message.contains("maximum TOON encoding depth") + )); + } + + #[test] + fn decoder_accepts_bracket_inline_arrays_in_object_fields() { + let value = decode_toon( + "tags: [ai, powershell, compact]\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Off, + }, + ) + .expect("decode"); + + assert_eq!( + value, + json!({ + "tags": ["ai", "powershell", "compact"], + }) + ); + } + + #[test] + fn jsonl_input_parses_as_root_array() { + let value = parse_json_source( + "{\"event\":\"login\"}\n{\"event\":\"logout\"}\n", + InputFormat::Jsonl, + ) + .expect("jsonl should parse"); + assert_eq!( + value, + json!([ + {"event": "login"}, + {"event": "logout"}, + ]) + ); + } + + #[test] + fn auto_mode_detects_jsonl_without_explicit_flags() { + let input = "{\"event\":\"login\"}\n{\"event\":\"logout\"}\n"; + assert_eq!( + detect_source_format( + &Cli { + common: common_args(false, InputFormat::Auto), + from: SourceFormat::Auto, + to: TargetFormat::Auto, + delimiter: DelimiterChoice::Comma, + indent: DEFAULT_INDENT, + key_folding: SafeMode::Off, + flatten_depth: None, + expand_paths: SafeMode::Off, + strict: true, + path: None, + }, + input + ), + Format::Json + ); + assert_eq!( + parse_json_source(input, InputFormat::Auto).expect("auto jsonl"), + json!([ + {"event": "login"}, + {"event": "logout"}, + ]) + ); + } + + #[test] + fn run_reads_files_and_supports_json_wrapper_for_toon_output() { + let temp = tempdir().expect("tempdir"); + let json_path = temp.path().join("config.json"); + let toon_path = temp.path().join("config.toon"); + fs::write( + &json_path, + "{\n \"items\": [\n {\"id\": 1, \"name\": \"Ada\"},\n {\"id\": 2, \"name\": \"Bob\"}\n ]\n}\n", + ) + .expect("json fixture"); + fs::write(&toon_path, "items[2]{id,name}:\n 1,Ada\n 2,Bob\n").expect("toon fixture"); + + let json_to_toon = run(&Cli { + common: common_args(false, InputFormat::Auto), + from: SourceFormat::Json, + to: TargetFormat::Toon, + delimiter: DelimiterChoice::Comma, + indent: 2, + key_folding: SafeMode::Off, + flatten_depth: None, + expand_paths: SafeMode::Off, + strict: true, + path: Some(json_path), + }) + .expect("json to toon"); + assert_eq!(json_to_toon, ExitCode::Success); + + let toon_to_json = run(&Cli { + common: common_args(true, InputFormat::Auto), + from: SourceFormat::Toon, + to: TargetFormat::Json, + delimiter: DelimiterChoice::Comma, + indent: 2, + key_folding: SafeMode::Off, + flatten_depth: None, + expand_paths: SafeMode::Off, + strict: true, + path: Some(toon_path), + }) + .expect("toon to json"); + assert_eq!(toon_to_json, ExitCode::Success); + } + + #[test] + fn load_input_accepts_one_stdin_path_and_rejects_many() { + let temp = tempdir().expect("tempdir"); + let json_path = temp.path().join("config.json"); + let second = temp.path().join("second.json"); + fs::write(&json_path, "{\"mode\":\"dev\"}\n").expect("json"); + fs::write(&second, "{\"mode\":\"prod\"}\n").expect("second"); + + let loaded = load_input_from_buffer( + &Cli { + common: common_args(false, InputFormat::Lines), + from: SourceFormat::Auto, + to: TargetFormat::Auto, + delimiter: DelimiterChoice::Comma, + indent: 2, + key_folding: SafeMode::Off, + flatten_depth: None, + expand_paths: SafeMode::Off, + strict: true, + path: None, + }, + &format!("{}\n", json_path.display()), + ) + .expect("stdin path"); + assert!(loaded.contains("\"mode\":\"dev\"")); + + assert!(matches!( + load_input_from_buffer( + &Cli { + common: common_args(false, InputFormat::Lines), + from: SourceFormat::Auto, + to: TargetFormat::Auto, + delimiter: DelimiterChoice::Comma, + indent: 2, + key_folding: SafeMode::Off, + flatten_depth: None, + expand_paths: SafeMode::Off, + strict: true, + path: None, + }, + &format!("{}\n{}\n", json_path.display(), second.display()), + ), + Err(CliError::Usage(message)) + if message.contains("accepts exactly one stdin path") + )); + } + + fn load_input_from_buffer(cli: &Cli, buffer: &str) -> Result { + if cli.common.input_format != InputFormat::Jsonl + && let Some(paths) = read_existing_stdin_paths(buffer, cli.common.input_format, "toon")? + { + if paths.len() != 1 { + return Err(CliError::usage(format!( + "toon accepts exactly one stdin path, got {}", + paths.len() + ))); + } + return fs::read_to_string(&paths[0]).map_err(|error| { + CliError::runtime(format!("failed to read {}: {error}", paths[0].display())) + }); + } + Ok(buffer.to_string()) + } + + #[test] + fn helper_functions_cover_numbers_and_empty_output() { + assert_eq!( + canonical_number(&Number::from_f64(1e-6).expect("finite")), + "0.000001" + ); + assert_eq!( + canonical_number(&Number::from_f64(-0.0).expect("finite")), + "0" + ); + assert_eq!( + encode_toon( + &json!({}), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + } + ) + .expect("encode"), + "" + ); + } + + #[test] + fn reference_encode_fixtures_match_expected_output() { + let fixture_directory = fixture_root().join("encode"); + for fixture_path in sorted_fixture_paths(&fixture_directory) { + let fixture = serde_json::from_str::( + &fs::read_to_string(&fixture_path).expect("fixture file"), + ) + .expect("fixture json"); + let cases = fixture["tests"].as_array().expect("fixture tests"); + + for case in cases { + let input = case["input"].clone(); + let expected = case["expected"].as_str().expect("expected TOON"); + let actual = encode_toon(&input, encode_options_from_fixture(case.get("options"))) + .expect("encode fixture"); + assert_eq!( + actual, + expected, + "encode fixture failed: {} / {}", + fixture_path.display(), + case["name"].as_str().unwrap_or("") + ); + } + } + } + + #[test] + fn reference_decode_fixtures_match_expected_output() { + let fixture_directory = fixture_root().join("decode"); + for fixture_path in sorted_fixture_paths(&fixture_directory) { + let fixture = serde_json::from_str::( + &fs::read_to_string(&fixture_path).expect("fixture file"), + ) + .expect("fixture json"); + let cases = fixture["tests"].as_array().expect("fixture tests"); + + for case in cases { + let input = case["input"].as_str().expect("fixture input"); + let should_error = case["shouldError"].as_bool().unwrap_or(false); + let result = decode_toon(input, decode_options_from_fixture(case.get("options"))); + + if should_error { + assert!( + result.is_err(), + "decode fixture should fail: {} / {}", + fixture_path.display(), + case["name"].as_str().unwrap_or("") + ); + } else { + assert_eq!( + result.expect("decode fixture should succeed"), + case["expected"], + "decode fixture failed: {} / {}", + fixture_path.display(), + case["name"].as_str().unwrap_or("") + ); + } + } + } + } +} diff --git a/crates/toon/src/main.rs b/crates/toon/src/main.rs new file mode 100644 index 0000000..d308ea0 --- /dev/null +++ b/crates/toon/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `toon`. + +fn main() { + std::process::exit(toon::main_entry()); +} diff --git a/crates/toon/tests/shared_toon_core.rs b/crates/toon/tests/shared_toon_core.rs new file mode 100644 index 0000000..4389182 --- /dev/null +++ b/crates/toon/tests/shared_toon_core.rs @@ -0,0 +1,60 @@ +//! Integration coverage for the shared TOON core exposed by `common`. + +use common::formats::toon::{DecodeOptions, Delimiter, EncodeOptions, SafeMode}; +use serde_json::json; + +#[test] +fn common_toon_encoder_handles_nested_and_tabular_values() { + let rendered = common::formats::toon::encode_value( + &json!({ + "context": { + "task": "Shared TOON core", + "location": "common", + }, + "items": [ + {"id": 1, "name": "Ada"}, + {"id": 2, "name": "Bob"}, + ], + }), + EncodeOptions { + indent: 2, + delimiter: Delimiter::Comma, + key_folding: SafeMode::Off, + flatten_depth: usize::MAX, + }, + ) + .expect("encode"); + + assert_eq!( + rendered, + "context:\n task: \"Shared TOON core\"\n location: common\nitems[2]{id,name}:\n 1,Ada\n 2,Bob" + ); +} + +#[test] +fn common_toon_decoder_handles_tabular_values_and_path_expansion() { + let decoded = common::formats::toon::decode_str( + "data.metadata.items[2]: a,b\nrows[2]{id,name}:\n 1,Ada\n 2,Bob\n", + DecodeOptions { + indent: 2, + strict: true, + expand_paths: SafeMode::Safe, + }, + ) + .expect("decode should succeed"); + + assert_eq!( + decoded, + json!({ + "data": { + "metadata": { + "items": ["a", "b"], + }, + }, + "rows": [ + {"id": 1, "name": "Ada"}, + {"id": 2, "name": "Bob"}, + ], + }) + ); +} diff --git a/crates/toon/tests/toon_cli.rs b/crates/toon/tests/toon_cli.rs new file mode 100644 index 0000000..7974160 --- /dev/null +++ b/crates/toon/tests/toon_cli.rs @@ -0,0 +1,79 @@ +//! Integration tests for the `toon` command. + +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn cargo_command() -> Command { + Command::cargo_bin("toon").expect("binary") +} + +#[test] +fn help_includes_pipeline_examples() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Convert between JSON and TOON")) + .stdout(predicate::str::contains("'{\"ok\":true}' | toon")) + .stdout(predicate::str::contains( + "bat --style=plain --paging=never .\\fixtures\\toon\\records.jsonl | toon", + )) + .stdout(predicate::str::contains("toon --from json --input-format jsonl").not()) + .stdout(predicate::str::contains("--expand-paths ")); +} + +#[test] +fn version_prints_package_version() { + cargo_command() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION"))); +} + +#[test] +fn jsonl_stdin_converts_to_toon_table() { + cargo_command() + .write_stdin("{\"name\":\"Ada\",\"score\":10}\n{\"name\":\"Bob\",\"score\":11}\n") + .assert() + .success() + .stdout(predicate::str::contains("name,score")) + .stdout(predicate::str::contains("Ada,10")) + .stdout(predicate::str::contains("Bob,11")); +} + +#[test] +fn auto_detects_jsonl_files_without_extra_flags() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("records.jsonl"); + fs::write( + &path, + "{\"name\":\"Ada\",\"score\":10}\n{\"name\":\"Bob\",\"score\":11}\n", + ) + .expect("fixture"); + + cargo_command() + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("name,score")) + .stdout(predicate::str::contains("Ada,10")) + .stdout(predicate::str::contains("Bob,11")); +} + +#[test] +fn strict_flag_accepts_false_for_loose_indent_input() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("loose.toon"); + fs::write(&path, "root:\n child: value\n").expect("fixture"); + + cargo_command() + .args(["--from", "toon", "--to", "json", "--strict", "false"]) + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("\"child\": \"value\"")); +} diff --git a/crates/unityasset/Cargo.toml b/crates/unityasset/Cargo.toml new file mode 100644 index 0000000..9ee9ac5 --- /dev/null +++ b/crates/unityasset/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "unityasset" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect Unity assets, references, and extractable objects with AI-friendly output." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true +unitysupport = { path = "../unitysupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/unityasset/src/cli.rs b/crates/unityasset/src/cli.rs new file mode 100644 index 0000000..9654348 --- /dev/null +++ b/crates/unityasset/src/cli.rs @@ -0,0 +1,1787 @@ +//! The `unityasset` command inspects Unity assets and bundles. + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + print_text, read_existing_stdin_path_records, should_read_stdin, write_stdout, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use regex_lite::{Regex, RegexBuilder}; +use serde::Serialize; +use serde_json::Value; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use unitysupport::{ + AssetDump, AssetDumpFieldMode, AssetExtractOutput, AssetExtractReport, AssetIndexRow, + AssetReferenceEntry, AssetReferences, dump_unity_asset, extract_unity_asset, + index_unity_assets, refs_unity_asset, +}; + +const HELP: &str = "\ +Inspect Unity assets, AssetBundles, and YAML scene or prefab files. + +Usage: + unityasset [OPTIONS] index [PATH...] + unityasset [OPTIONS] refs [PATH...] + unityasset [OPTIONS] dump [PATH...] + unityasset [OPTIONS] extract [PATH...] [--output-dir ] + +Subcommands: + index Index supported Unity asset files and MonoScript metadata when available + refs Show incoming and outgoing references for objects + dump Dump serialized fields or metadata for objects + extract Export TextAsset, MonoScript, VideoClip, or raw bytes + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --class Filter index rows by Unity class name + --name Filter index rows by object name + --script Filter index rows by script class, namespace, or assembly + --limit Maximum number of index rows to emit after filtering (text defaults to 80 when omitted) + --summary Emit a compact class/source histogram instead of per-object rows + --allow-empty Accepted for compatibility; index already returns exit code 0 on empty matches + --direction Limit refs output: incoming, outgoing, or both + --max-refs Maximum refs to emit per direction after sorting + --output-dir Directory for extract output files + -h, --help Show this help text + -V, --version Show the command version + +Examples: + unityasset index 'C:\\game\\Game_Data' + unityasset index 'C:\\game\\Game_Data' --class MonoBehaviour --script 'MarketOffer|LaunchVehicle' + unityasset --json index 'C:\\game\\Game_Data' | ConvertFrom-Json | Where-Object { $_.class_name -eq 'MonoScript' } | Select-Object id,script_class,script_namespace,script_assembly + unityasset index 'C:\\game\\Game_Data' --class '^VideoClip$' --summary + unityasset refs 'sharedassets0.assets#1234' 'C:\\game\\Game_Data' + unityasset --json refs --direction outgoing --max-refs 25 'sharedassets0.assets#1234' 'C:\\game\\Game_Data' + unityasset dump 'sharedassets0.assets#1234' 'C:\\game\\Game_Data' --json | ConvertFrom-Json + unityasset extract 'resources.assets#5678' 'C:\\game\\Game_Data' --output-dir .\\out +"; + +const DEFAULT_TEXT_INDEX_LIMIT: usize = 80; +const RAW_DATA_PREVIEW_BYTES: usize = 16; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: Command, +} + +#[derive(Debug, Clone)] +enum Command { + Index(IndexArgs), + Refs(RefsArgs), + Dump { + selector: String, + roots: Vec, + }, + Extract { + selector: String, + roots: Vec, + output_dir: Option, + }, +} + +#[derive(Debug, Clone)] +struct IndexArgs { + roots: Vec, + class_pattern: Option, + name_pattern: Option, + script_pattern: Option, + limit: Option, + summary: bool, + allow_empty: bool, +} + +#[derive(Debug, Clone)] +struct RefsArgs { + selector: String, + roots: Vec, + direction: RefsDirection, + max_refs: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum RefsDirection { + Incoming, + Outgoing, + #[default] + Both, +} + +impl RefsDirection { + const fn includes_incoming(self) -> bool { + matches!(self, Self::Incoming | Self::Both) + } + + const fn includes_outgoing(self) -> bool { + matches!(self, Self::Outgoing | Self::Both) + } + + const fn as_str(self) -> &'static str { + match self { + Self::Incoming => "incoming", + Self::Outgoing => "outgoing", + Self::Both => "both", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RefsOptions { + direction: RefsDirection, + max_refs: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RefsReport { + target: AssetIndexRow, + direction: RefsDirection, + max_refs: Option, + outgoing_total: usize, + outgoing_shown: usize, + outgoing_truncated: bool, + outgoing_filter_reason: Option, + outgoing: Vec, + incoming_total: usize, + incoming_shown: usize, + incoming_truncated: bool, + incoming_filter_reason: Option, + incoming: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Default)] +struct ParseState { + common: CommonArgs, + command_name: Option, + selector: Option, + roots: Vec, + output_dir: Option, + class_pattern: Option, + name_pattern: Option, + script_pattern: Option, + limit: Option, + summary: bool, + allow_empty: bool, + refs_direction: RefsDirection, + max_refs: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct NameCount { + name: String, + count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct IndexSummary { + total_objects: usize, + matching_objects: usize, + shown_objects: usize, + named_objects: usize, + class_counts: Vec, + source_kind_counts: Vec, + script_class_counts: Vec, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => match write_stdout(HELP) { + Ok(()) => ExitCode::Success.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Ok((ParseOutcome::Version, _)) => { + println!("unityasset {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut state = ParseState::default(); + + loop { + let argument = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))?; + let Some(argument) = argument else { + break; + }; + match argument { + Long("help") | Short('h') => { + return Ok((ParseOutcome::Help, meta_cli(state.common))); + } + Long("version") | Short('V') => { + return Ok((ParseOutcome::Version, meta_cli(state.common))); + } + Long("json") => state.common.set_render_mode(RenderMode::Json), + Long("toon") => state.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + state.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + state.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + state.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => state.common.quiet = true, + Long("output-dir") => { + state.output_dir = Some(PathBuf::from(parser_value_string( + &mut parser, + "--output-dir", + )?)); + } + Long("class") => { + state.class_pattern = Some(parse_regex( + "--class", + &parser_value_string(&mut parser, "--class")?, + )?); + } + Long("name") => { + state.name_pattern = Some(parse_regex( + "--name", + &parser_value_string(&mut parser, "--name")?, + )?); + } + Long("script") => { + state.script_pattern = Some(parse_regex( + "--script", + &parser_value_string(&mut parser, "--script")?, + )?); + } + Long("limit") => { + state.limit = Some(parse_usize_flag( + "--limit", + &parser_value_string(&mut parser, "--limit")?, + )?); + } + Long("summary") => state.summary = true, + Long("allow-empty") => state.allow_empty = true, + Long("direction") => { + state.refs_direction = + parse_direction(&parser_value_string(&mut parser, "--direction")?)?; + } + Long("max-refs") => { + state.max_refs = Some(parse_usize_flag( + "--max-refs", + &parser_value_string(&mut parser, "--max-refs")?, + )?); + } + ArgValue(value) => push_positional(&mut state, os_value_string(value, "argument")?), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok(( + ParseOutcome::Run, + Cli { + common: state.common, + command: build_command(state)?, + }, + )) +} + +const fn meta_cli(common: CommonArgs) -> Cli { + Cli { + common, + command: Command::Index(IndexArgs { + roots: Vec::new(), + class_pattern: None, + name_pattern: None, + script_pattern: None, + limit: None, + summary: false, + allow_empty: false, + }), + } +} + +fn push_positional(state: &mut ParseState, text: String) { + if state.command_name.is_none() { + state.command_name = Some(text); + } else if matches!( + state.command_name.as_deref(), + Some("refs" | "dump" | "extract") + ) && state.selector.is_none() + { + state.selector = Some(text); + } else { + state.roots.push(PathBuf::from(text)); + } +} + +fn build_command(state: ParseState) -> Result { + let command_name = state + .command_name + .ok_or_else(|| CliError::usage("provide a subcommand: index, refs, dump, or extract"))?; + let index_flags_used = state.class_pattern.is_some() + || state.name_pattern.is_some() + || state.script_pattern.is_some() + || state.limit.is_some() + || state.summary + || state.allow_empty; + let refs_flags_used = state.max_refs.is_some() || state.refs_direction != RefsDirection::Both; + let command = match command_name.as_str() { + "index" => Command::Index(IndexArgs { + roots: state.roots, + class_pattern: state.class_pattern, + name_pattern: state.name_pattern, + script_pattern: state.script_pattern, + limit: state.limit, + summary: state.summary, + allow_empty: state.allow_empty, + }), + "refs" => Command::Refs(RefsArgs { + selector: state + .selector + .ok_or_else(|| CliError::usage("refs requires an OBJECT_ID"))?, + roots: state.roots, + direction: state.refs_direction, + max_refs: state.max_refs, + }), + "dump" => Command::Dump { + selector: state + .selector + .ok_or_else(|| CliError::usage("dump requires an OBJECT_ID"))?, + roots: state.roots, + }, + "extract" => Command::Extract { + selector: state + .selector + .ok_or_else(|| CliError::usage("extract requires an OBJECT_ID"))?, + roots: state.roots, + output_dir: state.output_dir, + }, + other => { + return Err(CliError::usage(format!( + "unsupported subcommand '{other}'; expected index, refs, dump, or extract" + ))); + } + }; + if let Command::Refs(args) = &command + && args.max_refs == Some(0) + { + return Err(CliError::usage("--max-refs must be greater than 0")); + } + if command_name != "index" && index_flags_used { + return Err(CliError::usage( + "--class, --name, --script, --limit, --summary, and --allow-empty are only supported by unityasset index", + )); + } + if command_name != "refs" && refs_flags_used { + return Err(CliError::usage( + "--direction and --max-refs are only supported by unityasset refs", + )); + } + Ok(command) +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + Command::Index(args) => run_index(cli, args), + Command::Refs(args) => run_refs(cli, args), + Command::Dump { selector, roots } => run_dump(cli, selector, roots), + Command::Extract { + selector, + roots, + output_dir, + } => run_extract(cli, selector, roots, output_dir.as_deref()), + } +} + +fn run_index(cli: &Cli, args: &IndexArgs) -> Result { + let roots = collect_roots(&args.roots, cli.common)?; + let all_rows = index_unity_assets(&roots)?; + let total_objects = all_rows.len(); + let matching_rows = filter_index_rows(&all_rows, args, false); + let rows = filter_index_rows(&all_rows, args, true); + let _ = args.allow_empty; + let exit_code = ExitCode::Success; + + if args.summary { + let summary = build_index_summary(total_objects, &matching_rows, &rows); + match cli.common.render_mode() { + RenderMode::Json => print_json(&summary)?, + RenderMode::Toon => print_structured(&summary, RenderMode::Toon)?, + RenderMode::Text => write_stdout(&render_index_summary(&summary))?, + } + return Ok(exit_code); + } + + match cli.common.render_mode() { + RenderMode::Json => print_json(&rows)?, + RenderMode::Toon => print_structured(&rows, RenderMode::Toon)?, + RenderMode::Text => { + if rows.is_empty() { + if !cli.common.quiet { + print_text("0 objects")?; + } + } else { + let (text_rows, overview, note) = + build_text_index_rows(total_objects, &matching_rows, &rows, args.limit); + write_stdout(&render_index_rows_report( + &text_rows, + overview.as_ref(), + note.as_deref(), + ))?; + } + } + } + Ok(exit_code) +} + +fn run_refs(cli: &Cli, args: &RefsArgs) -> Result { + let roots = collect_roots(&args.roots, cli.common)?; + let refs = refs_unity_asset(&roots, &args.selector)?; + let report = build_refs_report( + refs, + RefsOptions { + direction: args.direction, + max_refs: args.max_refs, + }, + ); + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => write_stdout(&render_refs_report(&report))?, + } + Ok(ExitCode::Success) +} + +fn run_dump(cli: &Cli, selector: &str, explicit_roots: &[PathBuf]) -> Result { + let roots = collect_roots(explicit_roots, cli.common)?; + let dump = dump_unity_asset(&roots, selector)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&dump)?, + RenderMode::Toon => print_structured(&dump, RenderMode::Toon)?, + RenderMode::Text => write_stdout(&render_dump(&dump)?)?, + } + Ok(ExitCode::Success) +} + +fn run_extract( + cli: &Cli, + selector: &str, + explicit_roots: &[PathBuf], + output_dir: Option<&Path>, +) -> Result { + let roots = collect_roots(explicit_roots, cli.common)?; + let report = extract_unity_asset(&roots, selector)?; + + if let Some(output_dir) = output_dir { + fs::create_dir_all(output_dir).map_err(|error| { + CliError::runtime(format!( + "failed to create output directory {}: {error}", + output_dir.display() + )) + })?; + let writes = write_extract_outputs(output_dir, &report.outputs)?; + match cli.common.render_mode() { + RenderMode::Json => print_json(&writes)?, + RenderMode::Toon => print_structured(&writes, RenderMode::Toon)?, + RenderMode::Text => write_stdout(&render_extract_writes(&report, &writes))?, + } + return Ok(ExitCode::Success); + } + + if report.outputs.len() == 1 && report.outputs[0].is_text { + let text = std::str::from_utf8(&report.outputs[0].bytes).map_err(|error| { + CliError::runtime(format!("extracted text is not valid UTF-8: {error}")) + })?; + write_stdout(text)?; + return Ok(ExitCode::Success); + } + + Err(CliError::usage( + "extract requires --output-dir when output is binary or includes multiple files", + )) +} + +fn collect_roots(explicit_roots: &[PathBuf], common: CommonArgs) -> Result, CliError> { + if should_read_stdin(!explicit_roots.is_empty(), common.stdin_is_terminal()) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let stdin_roots = parse_paths_from_string(&buffer, common.input_format)?; + if !stdin_roots.is_empty() { + return Ok(stdin_roots); + } + } + + if explicit_roots.is_empty() { + Ok(vec![PathBuf::from(".")]) + } else { + Ok(common::expand_input_patterns(explicit_roots, "unityasset")?) + } +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + read_existing_stdin_path_records(buffer, input_format, "unityasset")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn filter_index_rows( + rows: &[AssetIndexRow], + args: &IndexArgs, + apply_limit: bool, +) -> Vec { + let mut rows = rows + .iter() + .filter(|row| { + args.class_pattern + .as_ref() + .is_none_or(|pattern| pattern.is_match(&row.class_name)) + }) + .filter(|row| { + args.name_pattern.as_ref().is_none_or(|pattern| { + row.best_name + .as_deref() + .or(row.name.as_deref()) + .is_some_and(|name| pattern.is_match(name)) + }) + }) + .filter(|row| { + args.script_pattern.as_ref().is_none_or(|pattern| { + row.script_class + .as_deref() + .is_some_and(|value| pattern.is_match(value)) + || row + .script_namespace + .as_deref() + .is_some_and(|value| pattern.is_match(value)) + || row + .script_assembly + .as_deref() + .is_some_and(|value| pattern.is_match(value)) + }) + }) + .cloned() + .collect::>(); + if apply_limit && let Some(limit) = args.limit { + rows.truncate(limit); + } + rows +} + +fn build_index_summary( + total_objects: usize, + matching_rows: &[AssetIndexRow], + shown_rows: &[AssetIndexRow], +) -> IndexSummary { + IndexSummary { + total_objects, + matching_objects: matching_rows.len(), + shown_objects: shown_rows.len(), + named_objects: matching_rows + .iter() + .filter(|row| row.best_name.as_ref().or(row.name.as_ref()).is_some()) + .count(), + class_counts: count_rows_by(matching_rows, |row| row.class_name.clone()), + source_kind_counts: count_rows_by(matching_rows, |row| row.source_kind.clone()), + script_class_counts: count_rows_by_optional(matching_rows, |row| { + row.script_class + .as_deref() + .map(str::to_string) + .or_else(|| row.best_name.as_deref().map(str::to_string)) + }), + } +} + +fn build_text_index_rows( + total_objects: usize, + matching_rows: &[AssetIndexRow], + rows: &[AssetIndexRow], + explicit_limit: Option, +) -> (Vec, Option, Option) { + if explicit_limit.is_none() && matching_rows.len() > DEFAULT_TEXT_INDEX_LIMIT { + let text_rows = matching_rows + .iter() + .take(DEFAULT_TEXT_INDEX_LIMIT) + .cloned() + .collect::>(); + let overview = build_index_summary(total_objects, matching_rows, &text_rows); + let note = format!( + "note: text mode capped to the first {DEFAULT_TEXT_INDEX_LIMIT} matching objects; rerun with --limit, --summary, or --json for more" + ); + return (text_rows, Some(overview), Some(note)); + } + + (rows.to_vec(), None, None) +} + +fn count_rows_by(rows: &[AssetIndexRow], key_fn: F) -> Vec +where + F: Fn(&AssetIndexRow) -> String, +{ + let mut counts = std::collections::BTreeMap::::new(); + for row in rows { + *counts.entry(key_fn(row)).or_insert(0) += 1; + } + let mut values = counts + .into_iter() + .map(|(name, count)| NameCount { name, count }) + .collect::>(); + values.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.name.cmp(&right.name)) + }); + values +} + +fn count_rows_by_optional(rows: &[AssetIndexRow], key_fn: F) -> Vec +where + F: Fn(&AssetIndexRow) -> Option, +{ + let mut counts = std::collections::BTreeMap::::new(); + for row in rows { + if let Some(key) = key_fn(row) { + *counts.entry(key).or_insert(0) += 1; + } + } + let mut values = counts + .into_iter() + .map(|(name, count)| NameCount { name, count }) + .collect::>(); + values.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.name.cmp(&right.name)) + }); + values +} + +fn render_index_rows(rows: &[AssetIndexRow]) -> String { + let mut output = String::new(); + for row in rows { + let name = row + .best_name + .as_deref() + .or(row.name.as_deref()) + .unwrap_or("-"); + let byte_size = row + .byte_size + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let mut extras = String::new(); + if row.name.is_none() && row.best_name.is_some() { + write!(extras, " name_source=best_effort").expect("string"); + } + if let Some(script_class) = row.script_class.as_deref() { + write!(extras, " script_class={script_class}").expect("string"); + } + if let Some(script_namespace) = row.script_namespace.as_deref() { + write!(extras, " script_namespace={script_namespace}").expect("string"); + } + if let Some(script_assembly) = row.script_assembly.as_deref() { + write!(extras, " script_assembly={script_assembly}").expect("string"); + } + writeln!( + output, + "{} class={} class_id={} name={} format={} source_kind={} size={}{}", + row.id, + row.class_name, + row.class_id, + name, + row.format, + row.source_kind, + byte_size, + extras + ) + .expect("writing to a String cannot fail"); + } + output +} + +fn render_index_rows_report( + rows: &[AssetIndexRow], + overview: Option<&IndexSummary>, + note: Option<&str>, +) -> String { + let mut output = String::new(); + if let Some(overview) = overview { + output.push_str(&render_index_overview(overview)); + } + output.push_str(&render_index_rows(rows)); + if let Some(note) = note { + writeln!(output, "{note}").expect("writing to a String cannot fail"); + } + output +} + +fn render_index_overview(summary: &IndexSummary) -> String { + format!( + "summary total={} matching={} shown={} named={}\n", + summary.total_objects, + summary.matching_objects, + summary.shown_objects, + summary.named_objects + ) +} + +fn render_index_summary(summary: &IndexSummary) -> String { + let mut output = String::new(); + writeln!( + output, + "summary total={} matching={} shown={} named={}", + summary.total_objects, + summary.matching_objects, + summary.shown_objects, + summary.named_objects + ) + .expect("writing to a String cannot fail"); + for count in summary.class_counts.iter().take(12) { + writeln!(output, "class name={} count={}", count.name, count.count) + .expect("writing to a String cannot fail"); + } + for count in summary.source_kind_counts.iter().take(8) { + writeln!( + output, + "source_kind name={} count={}", + count.name, count.count + ) + .expect("writing to a String cannot fail"); + } + for count in summary.script_class_counts.iter().take(16) { + writeln!( + output, + "script_class name={} count={}", + count.name, count.count + ) + .expect("writing to a String cannot fail"); + } + output +} + +fn build_refs_report(refs: AssetReferences, options: RefsOptions) -> RefsReport { + let (outgoing, outgoing_total, outgoing_filter_reason, outgoing_truncated) = filter_ref_entries( + refs.outgoing, + options.direction.includes_outgoing(), + options.max_refs, + ); + let (incoming, incoming_total, incoming_filter_reason, incoming_truncated) = filter_ref_entries( + refs.incoming, + options.direction.includes_incoming(), + options.max_refs, + ); + + RefsReport { + target: refs.target, + direction: options.direction, + max_refs: options.max_refs, + outgoing_total, + outgoing_shown: outgoing.len(), + outgoing_truncated, + outgoing_filter_reason, + outgoing, + incoming_total, + incoming_shown: incoming.len(), + incoming_truncated, + incoming_filter_reason, + incoming, + } +} + +fn filter_ref_entries( + mut entries: Vec, + include: bool, + max_refs: Option, +) -> (Vec, usize, Option, bool) { + sort_ref_entries(&mut entries); + let total = entries.len(); + if !include { + return ( + Vec::new(), + total, + Some("excluded_by_direction".to_string()), + false, + ); + } + + let truncated = max_refs.is_some_and(|limit| total > limit); + if let Some(limit) = max_refs { + entries.truncate(limit); + } + (entries, total, None, truncated) +} + +fn sort_ref_entries(entries: &mut [AssetReferenceEntry]) { + entries.sort_unstable_by(|left, right| { + ( + u8::from(!left.resolved), + ref_class_rank(left.class_name.as_deref()), + u8::from(left.name.is_none()), + u8::from(left.path.is_none()), + left.class_name.as_deref().unwrap_or(""), + left.name.as_deref().unwrap_or(""), + left.path.as_deref().unwrap_or(""), + left.file_id.unwrap_or(i32::MAX), + left.guid.as_deref().unwrap_or(""), + left.id.as_str(), + ) + .cmp(&( + u8::from(!right.resolved), + ref_class_rank(right.class_name.as_deref()), + u8::from(right.name.is_none()), + u8::from(right.path.is_none()), + right.class_name.as_deref().unwrap_or(""), + right.name.as_deref().unwrap_or(""), + right.path.as_deref().unwrap_or(""), + right.file_id.unwrap_or(i32::MAX), + right.guid.as_deref().unwrap_or(""), + right.id.as_str(), + )) + }); +} + +fn ref_class_rank(class_name: Option<&str>) -> u8 { + match class_name { + Some("MonoScript" | "MonoBehaviour" | "GameObject" | "Transform" | "ScriptableObject") => 0, + Some( + "TextAsset" | "VideoClip" | "AudioClip" | "Prefab" | "PrefabInstance" | "SceneAsset", + ) => 1, + Some("Material" | "Mesh" | "MeshRenderer" | "RectTransform" | "Texture2D" | "Shader") => 3, + Some(name) if name.starts_with("Class_") => 4, + Some(_) => 2, + None => 5, + } +} + +fn render_refs_report(report: &RefsReport) -> String { + let mut output = String::new(); + let target_name = report + .target + .best_name + .as_deref() + .or(report.target.name.as_deref()) + .unwrap_or("-"); + writeln!( + output, + "direction={} max_refs={}", + report.direction.as_str(), + report + .max_refs + .map_or_else(|| "all".to_string(), |value| value.to_string()) + ) + .expect("writing to a String cannot fail"); + writeln!( + output, + "target={} class={} name={}", + report.target.id, report.target.class_name, target_name + ) + .expect("writing to a String cannot fail"); + write_refs_section( + &mut output, + "outgoing", + report.outgoing_total, + report.outgoing_shown, + report.outgoing_truncated, + report.outgoing_filter_reason.as_deref(), + ); + for edge in &report.outgoing { + writeln!( + output, + "-> {} class={} name={} resolved={} path={}", + edge.id, + edge.class_name.as_deref().unwrap_or("-"), + edge.name.as_deref().unwrap_or("-"), + edge.resolved, + edge.path.as_deref().unwrap_or("-") + ) + .expect("writing to a String cannot fail"); + } + write_refs_section( + &mut output, + "incoming", + report.incoming_total, + report.incoming_shown, + report.incoming_truncated, + report.incoming_filter_reason.as_deref(), + ); + for edge in &report.incoming { + writeln!( + output, + "<- {} class={} name={} path={}", + edge.id, + edge.class_name.as_deref().unwrap_or("-"), + edge.name.as_deref().unwrap_or("-"), + edge.path.as_deref().unwrap_or("-") + ) + .expect("writing to a String cannot fail"); + } + output +} + +fn write_refs_section( + output: &mut String, + label: &str, + total: usize, + shown: usize, + truncated: bool, + filter_reason: Option<&str>, +) { + write!(output, "{label}={total} shown={shown}").expect("writing to a String cannot fail"); + if truncated { + write!(output, " truncated=true").expect("writing to a String cannot fail"); + } + if let Some(filter_reason) = filter_reason { + write!(output, " filter_reason={filter_reason}").expect("writing to a String cannot fail"); + } + writeln!(output).expect("writing to a String cannot fail"); +} + +fn render_dump(dump: &AssetDump) -> Result { + let mut output = String::new(); + let object_name = dump + .object + .best_name + .as_deref() + .or(dump.object.name.as_deref()) + .unwrap_or("-"); + writeln!( + output, + "{} class={} name={} field_mode={}", + dump.object.id, + dump.object.class_name, + object_name, + match dump.field_mode { + AssetDumpFieldMode::Typetree => "typetree", + AssetDumpFieldMode::MetadataOnly => "metadata_only", + } + ) + .expect("writing to a String cannot fail"); + if !dump.warnings.is_empty() { + writeln!(output, "warnings={}", dump.warnings.len()) + .expect("writing to a String cannot fail"); + } + if let Some(next_hint) = &dump.next_hint { + writeln!(output, "next_hint={next_hint}").expect("writing to a String cannot fail"); + } + let properties = if dump.field_mode == AssetDumpFieldMode::MetadataOnly { + compact_dump_properties_for_text(&dump.properties) + } else { + dump.properties.clone() + }; + let json = serde_json::to_string(&properties) + .map_err(|error| CliError::runtime(format!("failed to render dump json: {error}")))?; + writeln!(output, "{json}").expect("writing to a String cannot fail"); + Ok(output) +} + +fn compact_dump_properties_for_text(properties: &Value) -> Value { + let mut compact = properties.clone(); + compact_raw_arrays_for_text(&mut compact); + compact +} + +fn compact_raw_arrays_for_text(value: &mut Value) { + match value { + Value::Object(object) => { + if let Some(raw_items) = object.get("_raw_data").and_then(Value::as_array) + && raw_items.len() > RAW_DATA_PREVIEW_BYTES + { + let preview = raw_items + .iter() + .take(RAW_DATA_PREVIEW_BYTES) + .cloned() + .collect::>(); + let raw_len = object + .get("_raw_data_len") + .and_then(Value::as_u64) + .unwrap_or(raw_items.len() as u64); + object.remove("_raw_data"); + object.insert("_raw_data_preview".to_string(), Value::Array(preview)); + object.insert( + "_raw_data_omitted".to_string(), + Value::from(raw_len.saturating_sub(RAW_DATA_PREVIEW_BYTES as u64)), + ); + } + for nested in object.values_mut() { + compact_raw_arrays_for_text(nested); + } + } + Value::Array(items) => { + for item in items { + compact_raw_arrays_for_text(item); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ExtractWrite { + file_name: String, + path: String, + bytes: usize, +} + +fn write_extract_outputs( + output_dir: &Path, + outputs: &[AssetExtractOutput], +) -> Result, CliError> { + let mut writes = Vec::new(); + for output in outputs { + let path = output_dir.join(&output.file_name); + fs::write(&path, &output.bytes).map_err(|error| { + CliError::runtime(format!("failed to write {}: {error}", path.display())) + })?; + writes.push(ExtractWrite { + file_name: output.file_name.clone(), + path: path.display().to_string(), + bytes: output.bytes.len(), + }); + } + Ok(writes) +} + +fn render_extract_writes(report: &AssetExtractReport, writes: &[ExtractWrite]) -> String { + let mut output = String::new(); + writeln!( + output, + "object={} outputs={}", + report.object.id, + writes.len() + ) + .expect("writing to a String cannot fail"); + for write in writes { + writeln!( + output, + "wrote {} bytes={} file={}", + write.path, write.bytes, write.file_name + ) + .expect("writing to a String cannot fail"); + } + output +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_value_string(value, flag) +} + +fn os_value_string(value: OsString, flag: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_regex(flag: &str, value: &str) -> Result { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(|error| CliError::usage(format!("invalid {flag} regex '{value}': {error}"))) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn parse_direction(value: &str) -> Result { + match value { + "incoming" => Ok(RefsDirection::Incoming), + "outgoing" => Ok(RefsDirection::Outgoing), + "both" => Ok(RefsDirection::Both), + other => Err(CliError::usage(format!( + "invalid --direction value '{other}'; expected incoming, outgoing, or both" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{suffix}")); + fs::create_dir_all(&path).expect("temp dir"); + path + } + + #[test] + fn parse_cli_accepts_extract_output_dir() { + let (_, cli) = parse_cli_from([ + "unityasset", + "extract", + "demo.assets#1", + "--output-dir", + "out", + ]) + .expect("cli"); + match cli.command { + Command::Extract { + selector, + roots, + output_dir, + } => { + assert_eq!(selector, "demo.assets#1"); + assert!(roots.is_empty()); + assert_eq!(output_dir, Some(PathBuf::from("out"))); + } + Command::Index(_) | Command::Refs { .. } | Command::Dump { .. } => { + panic!("unexpected command") + } + } + } + + #[test] + fn parse_cli_accepts_index_filters() { + let (_, cli) = parse_cli_from([ + "unityasset", + "index", + "--class", + "MonoBehaviour", + "--script", + "SampleBehaviour", + "--limit", + "3", + "fixtures", + ]) + .expect("cli"); + let Command::Index(args) = cli.command else { + panic!("expected index command"); + }; + assert_eq!(args.roots, vec![PathBuf::from("fixtures")]); + assert_eq!(args.limit, Some(3)); + assert!(args.class_pattern.is_some()); + assert!(args.script_pattern.is_some()); + assert!(!args.summary); + assert!(!args.allow_empty); + } + + #[test] + fn parse_cli_accepts_index_summary_and_allow_empty() { + let (_, cli) = parse_cli_from([ + "unityasset", + "index", + "--summary", + "--allow-empty", + "fixtures", + ]) + .expect("cli"); + let Command::Index(args) = cli.command else { + panic!("expected index command"); + }; + assert!(args.summary); + assert!(args.allow_empty); + } + + #[test] + fn parse_paths_supports_plain_text_and_jsonl() { + let root = unique_temp_dir("unityasset-paths"); + let plain_file = root.join("fixtures"); + fs::write(&plain_file, "demo").expect("plain file"); + let jsonl_file = root.join("records.json"); + fs::write(&jsonl_file, "demo").expect("jsonl file"); + + let plain = + parse_paths_from_string(&format!("{}\n", plain_file.display()), InputFormat::Lines) + .expect("plain"); + assert_eq!(plain, vec![plain_file]); + + let jsonl = parse_paths_from_string( + &format!( + "{{\"path\":{}}}\n", + serde_json::to_string(&jsonl_file.display().to_string()).expect("json path") + ), + InputFormat::Jsonl, + ) + .expect("jsonl"); + assert_eq!(jsonl, vec![jsonl_file]); + } + + #[test] + fn parse_paths_auto_keeps_plain_paths() { + let root = unique_temp_dir("unityasset-auto-paths"); + let alpha = root.join("alpha"); + let beta = root.join("beta"); + fs::write(&alpha, "a").expect("alpha file"); + fs::write(&beta, "b").expect("beta file"); + let parsed = parse_paths_from_string( + &format!("{}\n{}\n", alpha.display(), beta.display()), + InputFormat::Auto, + ) + .expect("auto"); + assert_eq!(parsed, vec![alpha, beta]); + } + + #[test] + fn parse_paths_reports_jsonl_errors_with_line_numbers() { + let missing = parse_paths_from_string("{\"root\":\"fixtures\"}\n", InputFormat::Jsonl) + .expect_err("missing path"); + assert!(missing.to_string().contains("line 1")); + assert!(missing.to_string().contains("string 'path' field")); + + let invalid = + parse_paths_from_string("{not-json}\n", InputFormat::Jsonl).expect_err("invalid json"); + assert!( + invalid + .to_string() + .contains("stdin JSONL path line 1 is not valid JSON") + ); + } + + #[test] + fn parse_cli_requires_selector_for_refs() { + let error = parse_cli_from(["unityasset", "refs"]).expect_err("missing selector"); + assert_eq!(error.to_string(), "refs requires an OBJECT_ID"); + } + + #[test] + fn parse_cli_accepts_refs_direction_and_max_refs() { + let (_, cli) = parse_cli_from([ + "unityasset", + "refs", + "--direction", + "incoming", + "--max-refs", + "2", + "demo.assets#1", + "fixtures", + ]) + .expect("cli"); + match cli.command { + Command::Refs(args) => { + assert_eq!(args.selector, "demo.assets#1"); + assert_eq!(args.roots, vec![PathBuf::from("fixtures")]); + assert_eq!(args.direction, RefsDirection::Incoming); + assert_eq!(args.max_refs, Some(2)); + } + Command::Index(_) | Command::Dump { .. } | Command::Extract { .. } => { + panic!("unexpected command") + } + } + } + + #[test] + fn parse_cli_rejects_invalid_refs_controls() { + let invalid_direction = parse_cli_from([ + "unityasset", + "refs", + "--direction", + "sideways", + "demo.assets#1", + ]) + .expect_err("invalid direction"); + assert!( + invalid_direction + .to_string() + .contains("invalid --direction value 'sideways'") + ); + + let zero_max = parse_cli_from(["unityasset", "refs", "--max-refs", "0", "demo.assets#1"]) + .expect_err("zero max refs"); + assert!( + zero_max + .to_string() + .contains("--max-refs must be greater than 0") + ); + } + + #[test] + fn render_dump_includes_warning_count_and_json_line() { + let dump = AssetDump { + object: AssetIndexRow { + id: "demo.assets#7".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 1, + class_name: "GameObject".to_string(), + name: None, + best_name: None, + script_class: None, + script_namespace: None, + script_assembly: None, + source: "demo.assets".to_string(), + asset_index: None, + byte_size: Some(4), + }, + field_mode: AssetDumpFieldMode::MetadataOnly, + next_hint: Some("metadata-only dump; try `unityasset refs demo.assets#7`".to_string()), + properties: serde_json::json!({"name":"Demo"}), + warnings: vec!["one".to_string(), "two".to_string()], + }; + let rendered = render_dump(&dump).expect("render"); + assert!(rendered.contains("field_mode=metadata_only")); + assert!(rendered.contains("warnings=2")); + assert!(rendered.contains("next_hint=metadata-only dump")); + assert!(rendered.contains("{\"name\":\"Demo\"}")); + } + + #[test] + fn dump_text_compacts_large_raw_arrays_for_metadata_only_objects() { + let dump = AssetDump { + object: AssetIndexRow { + id: "demo.assets#8".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 114, + class_name: "MonoBehaviour".to_string(), + name: Some("Window".to_string()), + best_name: Some("Window".to_string()), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "demo.assets".to_string(), + asset_index: None, + byte_size: Some(64), + }, + field_mode: AssetDumpFieldMode::MetadataOnly, + next_hint: None, + properties: serde_json::json!({ + "_raw_data_len": 32, + "_raw_data": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18] + }), + warnings: Vec::new(), + }; + + let rendered = render_dump(&dump).expect("render"); + assert!(rendered.contains("_raw_data_preview")); + assert!(rendered.contains("_raw_data_omitted")); + assert!(!rendered.contains("\"_raw_data\":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18]")); + } + + #[test] + fn write_extract_outputs_writes_bytes_and_reports_paths() { + let output_dir = unique_temp_dir("unityasset-writes"); + let outputs = vec![AssetExtractOutput { + mode: unitysupport::ExtractionMode::TextAsset, + file_name: "greeting.txt".to_string(), + bytes: b"hello".to_vec(), + is_text: true, + }]; + + let writes = write_extract_outputs(&output_dir, &outputs).expect("writes"); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].bytes, 5); + assert!(writes[0].path.ends_with("greeting.txt")); + assert_eq!( + fs::read_to_string(output_dir.join("greeting.txt")).expect("read file"), + "hello" + ); + } + + #[test] + fn render_extract_writes_includes_object_and_files() { + let report = AssetExtractReport { + object: AssetIndexRow { + id: "demo.assets#9".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 114, + class_name: "MonoBehaviour".to_string(), + name: Some("Player".to_string()), + best_name: Some("Player".to_string()), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "demo.assets".to_string(), + asset_index: None, + byte_size: Some(12), + }, + outputs: Vec::new(), + }; + let writes = vec![ExtractWrite { + file_name: "Player.bin".to_string(), + path: "out\\Player.bin".to_string(), + bytes: 12, + }]; + let text = render_extract_writes(&report, &writes); + assert!(text.contains("object=demo.assets#9 outputs=1")); + assert!(text.contains("bytes=12 file=Player.bin")); + } + + #[test] + fn renderers_emit_compact_text() { + let row = AssetIndexRow { + id: "sample.assets#1".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 49, + class_name: "TextAsset".to_string(), + name: Some("Greeting".to_string()), + best_name: Some("Greeting".to_string()), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(12), + }; + let rendered_index = render_index_rows(std::slice::from_ref(&row)); + assert!(rendered_index.contains("class=TextAsset")); + assert!(rendered_index.contains("class_id=49")); + + let refs = AssetReferences { + target: row, + outgoing: Vec::new(), + incoming: vec![unitysupport::AssetReferenceEntry { + id: "sample.assets#3".to_string(), + resolved: true, + class_name: None, + name: None, + file_id: None, + path: Some("m_TextAsset".to_string()), + guid: None, + }], + }; + let report = build_refs_report( + refs, + RefsOptions { + direction: RefsDirection::Both, + max_refs: None, + }, + ); + assert!(render_refs_report(&report).contains("incoming=1 shown=1")); + } + + #[test] + fn render_index_summary_emits_class_histogram() { + let summary = build_index_summary( + 4, + &[ + AssetIndexRow { + id: "a#1".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 114, + class_name: "MonoBehaviour".to_string(), + name: None, + best_name: Some("Game.Mod".to_string()), + script_class: Some("Mod".to_string()), + script_namespace: Some("Game".to_string()), + script_assembly: Some("Assembly-CSharp".to_string()), + source: "a".to_string(), + asset_index: None, + byte_size: Some(8), + }, + AssetIndexRow { + id: "a#2".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 49, + class_name: "TextAsset".to_string(), + name: Some("Readme".to_string()), + best_name: Some("Readme".to_string()), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "a".to_string(), + asset_index: None, + byte_size: Some(8), + }, + ], + &[], + ); + let rendered = render_index_summary(&summary); + assert!(rendered.contains("summary total=4 matching=2 shown=0 named=2")); + assert!(rendered.contains("class name=MonoBehaviour count=1")); + assert!(rendered.contains("source_kind name=serialized_file count=2")); + } + + #[test] + fn text_index_rows_default_to_a_compact_cap_when_limit_is_omitted() { + let rows = (0..100) + .map(|index| AssetIndexRow { + id: format!("sample.assets#{index}"), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 114, + class_name: "MonoBehaviour".to_string(), + name: Some(format!("Item{index}")), + best_name: Some(format!("Item{index}")), + script_class: Some("Demo".to_string()), + script_namespace: Some("Game.UI".to_string()), + script_assembly: Some("Assembly-CSharp".to_string()), + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(8), + }) + .collect::>(); + + let (text_rows, overview, note) = build_text_index_rows(120, &rows, &rows, None); + assert_eq!(text_rows.len(), DEFAULT_TEXT_INDEX_LIMIT); + assert_eq!( + overview.expect("overview").shown_objects, + DEFAULT_TEXT_INDEX_LIMIT + ); + assert!(note.expect("note").contains("--summary")); + } + + #[test] + fn refs_report_filters_direction_and_limits_entries() { + let refs = AssetReferences { + target: AssetIndexRow { + id: "sample.assets#1".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 114, + class_name: "MonoBehaviour".to_string(), + name: Some("Target".to_string()), + best_name: Some("Target".to_string()), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(12), + }, + outgoing: vec![ + unitysupport::AssetReferenceEntry { + id: "sample.assets#9".to_string(), + resolved: true, + class_name: Some("GameObject".to_string()), + name: Some("Zeta".to_string()), + file_id: Some(0), + path: Some("z".to_string()), + guid: None, + }, + unitysupport::AssetReferenceEntry { + id: "sample.assets#2".to_string(), + resolved: true, + class_name: Some("GameObject".to_string()), + name: Some("Alpha".to_string()), + file_id: Some(0), + path: Some("a".to_string()), + guid: None, + }, + ], + incoming: vec![ + unitysupport::AssetReferenceEntry { + id: "sample.assets#8".to_string(), + resolved: true, + class_name: Some("GameObject".to_string()), + name: Some("Beta".to_string()), + file_id: Some(0), + path: Some("in.z".to_string()), + guid: None, + }, + unitysupport::AssetReferenceEntry { + id: "sample.assets#3".to_string(), + resolved: true, + class_name: Some("GameObject".to_string()), + name: Some("Gamma".to_string()), + file_id: Some(0), + path: Some("in.a".to_string()), + guid: None, + }, + ], + }; + + let report = build_refs_report( + refs, + RefsOptions { + direction: RefsDirection::Outgoing, + max_refs: Some(1), + }, + ); + + assert_eq!(report.direction, RefsDirection::Outgoing); + assert_eq!(report.max_refs, Some(1)); + assert_eq!(report.outgoing_total, 2); + assert_eq!(report.outgoing_shown, 1); + assert!(report.outgoing_truncated); + assert_eq!(report.outgoing[0].id, "sample.assets#2"); + assert_eq!(report.incoming_total, 2); + assert_eq!(report.incoming_shown, 0); + assert_eq!( + report.incoming_filter_reason.as_deref(), + Some("excluded_by_direction") + ); + assert!(report.incoming.is_empty()); + } + + #[test] + fn ref_sorting_prioritizes_resolved_scriptish_named_entries() { + let mut entries = vec![ + unitysupport::AssetReferenceEntry { + id: "sample.assets#9".to_string(), + resolved: false, + class_name: None, + name: None, + file_id: None, + path: None, + guid: Some("guid".to_string()), + }, + unitysupport::AssetReferenceEntry { + id: "sample.assets#2".to_string(), + resolved: true, + class_name: Some("MonoBehaviour".to_string()), + name: Some("MarketConfig".to_string()), + file_id: Some(0), + path: Some("m_Config".to_string()), + guid: None, + }, + unitysupport::AssetReferenceEntry { + id: "sample.assets#3".to_string(), + resolved: true, + class_name: Some("Material".to_string()), + name: Some("Window".to_string()), + file_id: Some(0), + path: Some("m_Material".to_string()), + guid: None, + }, + ]; + + sort_ref_entries(&mut entries); + assert_eq!(entries[0].id, "sample.assets#2"); + assert_eq!(entries[2].id, "sample.assets#9"); + } + + #[test] + fn render_refs_reports_totals_and_filter_state() { + let report = RefsReport { + target: AssetIndexRow { + id: "sample.assets#1".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 1, + class_name: "GameObject".to_string(), + name: Some("Player".to_string()), + best_name: Some("Player".to_string()), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(4), + }, + direction: RefsDirection::Incoming, + max_refs: Some(1), + outgoing_total: 2, + outgoing_shown: 0, + outgoing_truncated: false, + outgoing_filter_reason: Some("excluded_by_direction".to_string()), + outgoing: Vec::new(), + incoming_total: 3, + incoming_shown: 1, + incoming_truncated: true, + incoming_filter_reason: None, + incoming: vec![unitysupport::AssetReferenceEntry { + id: "sample.assets#3".to_string(), + resolved: true, + class_name: Some("GameObject".to_string()), + name: Some("Source".to_string()), + file_id: Some(0), + path: Some("m_Target".to_string()), + guid: None, + }], + }; + + let rendered = render_refs_report(&report); + assert!(rendered.contains("direction=incoming max_refs=1")); + assert!(rendered.contains("outgoing=2 shown=0 filter_reason=excluded_by_direction")); + assert!(rendered.contains("incoming=3 shown=1 truncated=true")); + } + + #[test] + fn filter_index_rows_matches_class_script_and_limit() { + let rows = vec![ + AssetIndexRow { + id: "sample.assets#1".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 114, + class_name: "MonoBehaviour".to_string(), + name: Some("PlayerPanel".to_string()), + best_name: Some("PlayerPanel".to_string()), + script_class: Some("MarketOfferWindow".to_string()), + script_namespace: Some("Game.UI.Windows.Windows".to_string()), + script_assembly: Some("Assembly-CSharp".to_string()), + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(8), + }, + AssetIndexRow { + id: "sample.assets#2".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 115, + class_name: "MonoScript".to_string(), + name: Some("Other".to_string()), + best_name: Some("Other".to_string()), + script_class: Some("OtherType".to_string()), + script_namespace: Some("Game.Other".to_string()), + script_assembly: Some("Assembly-CSharp".to_string()), + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(8), + }, + ]; + let args = IndexArgs { + roots: Vec::new(), + class_pattern: Some(parse_regex("--class", "MonoBehaviour").expect("class regex")), + name_pattern: None, + script_pattern: Some(parse_regex("--script", "MarketOffer").expect("script regex")), + limit: Some(1), + summary: false, + allow_empty: false, + }; + + let filtered = filter_index_rows(&rows, &args, true); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "sample.assets#1"); + } +} diff --git a/crates/unityasset/src/lib.rs b/crates/unityasset/src/lib.rs new file mode 100644 index 0000000..f8a12aa --- /dev/null +++ b/crates/unityasset/src/lib.rs @@ -0,0 +1,6 @@ +//! Public entry point for the `unityasset` command crate. +#![allow(clippy::multiple_crate_versions)] + +mod cli; + +pub use cli::main_entry; diff --git a/crates/unityasset/src/main.rs b/crates/unityasset/src/main.rs new file mode 100644 index 0000000..c76b17e --- /dev/null +++ b/crates/unityasset/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `unityasset`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(unityasset::main_entry()); +} diff --git a/crates/unityasset/tests/unityasset_cli.rs b/crates/unityasset/tests/unityasset_cli.rs new file mode 100644 index 0000000..2f89c34 --- /dev/null +++ b/crates/unityasset/tests/unityasset_cli.rs @@ -0,0 +1,852 @@ +//! Integration tests for the `unityasset` command. + +use assert_cmd::Command; +use predicates::prelude::*; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn cargo_command() -> Command { + Command::cargo_bin("unityasset").expect("binary") +} + +const ALIGN_BYTES_FLAG: i32 = 0x4000; + +#[derive(Clone, Copy)] +struct PPtrValue { + file_id: i32, + path_id: i64, +} + +#[derive(Clone)] +enum FixtureObject { + GameObject { + name: String, + }, + TextAsset { + name: String, + script: String, + }, + MonoScript { + name: String, + class_name: String, + namespace: String, + assembly_name: String, + }, + MonoBehaviour { + name: String, + script: PPtrValue, + text_asset: PPtrValue, + }, + StrippedMonoBehaviour { + game_object: PPtrValue, + script: PPtrValue, + extra_refs: Vec, + }, +} + +#[derive(Clone)] +struct FixtureEntry { + path_id: i64, + class_id: i32, + object: FixtureObject, +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("unityasset-{label}-{suffix}")); + fs::create_dir_all(&path).expect("temp dir"); + path +} + +fn to_i32(value: usize) -> i32 { + i32::try_from(value).expect("fixture value fits in i32") +} + +fn to_u32(value: usize) -> u32 { + u32::try_from(value).expect("fixture value fits in u32") +} + +fn to_i64(value: usize) -> i64 { + i64::try_from(value).expect("fixture value fits in i64") +} + +fn standalone_entries() -> Vec { + vec![ + FixtureEntry { + path_id: 1001, + class_id: 49, + object: FixtureObject::TextAsset { + name: "BinaryText".to_string(), + script: "Hello from binary text asset\n".to_string(), + }, + }, + FixtureEntry { + path_id: 1002, + class_id: 115, + object: FixtureObject::MonoScript { + name: "SampleBehaviour".to_string(), + class_name: "SampleBehaviour".to_string(), + namespace: "Mercury.Sample".to_string(), + assembly_name: "Assembly-CSharp".to_string(), + }, + }, + FixtureEntry { + path_id: 1003, + class_id: 114, + object: FixtureObject::MonoBehaviour { + name: "BinaryBehaviour".to_string(), + script: PPtrValue { + file_id: 0, + path_id: 1002, + }, + text_asset: PPtrValue { + file_id: 0, + path_id: 1001, + }, + }, + }, + ] +} + +fn bundle_entries() -> Vec { + vec![ + FixtureEntry { + path_id: 2001, + class_id: 49, + object: FixtureObject::TextAsset { + name: "BundledText".to_string(), + script: "Hello from bundled text asset\n".to_string(), + }, + }, + FixtureEntry { + path_id: 2002, + class_id: 115, + object: FixtureObject::MonoScript { + name: "BundledBehaviour".to_string(), + class_name: "BundledBehaviour".to_string(), + namespace: "Mercury.Bundle".to_string(), + assembly_name: "Assembly-CSharp".to_string(), + }, + }, + FixtureEntry { + path_id: 2003, + class_id: 114, + object: FixtureObject::MonoBehaviour { + name: "BundledMonoBehaviour".to_string(), + script: PPtrValue { + file_id: 0, + path_id: 2002, + }, + text_asset: PPtrValue { + file_id: 0, + path_id: 2001, + }, + }, + }, + ] +} + +fn stripped_entries() -> Vec { + vec![ + FixtureEntry { + path_id: 29, + class_id: 1, + object: FixtureObject::GameObject { + name: "StrippedWindow".to_string(), + }, + }, + FixtureEntry { + path_id: 30, + class_id: 1, + object: FixtureObject::GameObject { + name: "LinkedChild".to_string(), + }, + }, + FixtureEntry { + path_id: 1182, + class_id: 115, + object: FixtureObject::MonoScript { + name: "StrippedWindowBehaviour".to_string(), + class_name: "StrippedWindowBehaviour".to_string(), + namespace: "Mercury.Stripped".to_string(), + assembly_name: "Assembly-CSharp".to_string(), + }, + }, + FixtureEntry { + path_id: 2000, + class_id: 114, + object: FixtureObject::StrippedMonoBehaviour { + game_object: PPtrValue { + file_id: 0, + path_id: 29, + }, + script: PPtrValue { + file_id: 0, + path_id: 1182, + }, + extra_refs: vec![PPtrValue { + file_id: 0, + path_id: 30, + }], + }, + }, + ] +} + +fn write_fixture_workspace(root: &Path) { + fs::write( + root.join("sample.assets"), + build_serialized_file(&standalone_entries()), + ) + .expect("standalone asset"); + fs::write( + root.join("sample.bundle"), + build_unityfs_bundle("bundled.assets", &build_serialized_file(&bundle_entries())), + ) + .expect("bundle"); + fs::write( + root.join("stripped.assets"), + build_serialized_file(&stripped_entries()), + ) + .expect("stripped asset"); + fs::write(root.join("scene.unity"), sample_yaml_fixture()).expect("yaml fixture"); +} + +fn sample_yaml_fixture() -> String { + r"--- !u!1 &1000 +GameObject: + m_Name: SampleYamlGameObject + m_Component: + - component: {fileID: 2000} +--- !u!114 &2000 +MonoBehaviour: + m_Name: SampleYamlBehaviour + m_GameObject: {fileID: 1000} + m_Script: {fileID: 11500000, guid: 1234567890abcdef1234567890abcdef, type: 3} + m_TextAsset: {fileID: 3000} +--- !u!49 &3000 +TextAsset: + m_Name: YamlText + m_Script: Hello from YAML text asset +" + .to_string() +} + +fn build_serialized_file(entries: &[FixtureEntry]) -> Vec { + let type_descriptors = entries + .iter() + .map(|entry| (entry.class_id, type_tree_blob_for(&entry.object))) + .collect::>(); + let object_payloads = entries + .iter() + .map(|entry| object_bytes(&entry.object)) + .collect::>(); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2019.4.0f1\0"); + metadata.extend_from_slice(&0i32.to_le_bytes()); + metadata.push(1u8); + metadata.extend_from_slice(&to_i32(type_descriptors.len()).to_le_bytes()); + + for (class_id, tree_blob) in &type_descriptors { + metadata.extend_from_slice(&class_id.to_le_bytes()); + metadata.push(0u8); + metadata.extend_from_slice(&(-1i16).to_le_bytes()); + if *class_id == 114 { + metadata.extend_from_slice(&[0xAB; 16]); + } + metadata.extend_from_slice(&[0xCD; 16]); + metadata.extend_from_slice(tree_blob); + } + + metadata.extend_from_slice(&to_i32(entries.len()).to_le_bytes()); + let mut relative_offset = 0u32; + for (index, entry) in entries.iter().enumerate() { + while metadata.len() % 4 != 0 { + metadata.push(0u8); + } + metadata.extend_from_slice(&entry.path_id.to_le_bytes()); + metadata.extend_from_slice(&relative_offset.to_le_bytes()); + metadata.extend_from_slice(&to_u32(object_payloads[index].len()).to_le_bytes()); + metadata.extend_from_slice(&to_i32(index).to_le_bytes()); + relative_offset = relative_offset + .checked_add(to_u32(object_payloads[index].len())) + .expect("relative offset overflow"); + } + + metadata.extend_from_slice(&0i32.to_le_bytes()); + metadata.extend_from_slice(&0i32.to_le_bytes()); + metadata.push(0u8); + + let header_size = 20u32; + let data_offset = header_size + .checked_add(to_u32(metadata.len())) + .expect("data offset overflow"); + let file_size = data_offset + .checked_add( + object_payloads + .iter() + .map(|bytes| to_u32(bytes.len())) + .sum::(), + ) + .expect("file size overflow"); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&data_offset.to_be_bytes()); + bytes.extend_from_slice(&file_size.to_be_bytes()); + bytes.extend_from_slice(&19u32.to_be_bytes()); + bytes.extend_from_slice(&data_offset.to_be_bytes()); + bytes.push(0u8); + bytes.extend_from_slice(&[0u8; 3]); + bytes.extend_from_slice(&metadata); + for payload in object_payloads { + bytes.extend_from_slice(&payload); + } + bytes +} + +fn build_unityfs_bundle(entry_name: &str, file_bytes: &[u8]) -> Vec { + let mut blocks_info = vec![0u8; 16]; + blocks_info.extend_from_slice(&1i32.to_be_bytes()); + blocks_info.extend_from_slice(&to_u32(file_bytes.len()).to_be_bytes()); + blocks_info.extend_from_slice(&to_u32(file_bytes.len()).to_be_bytes()); + blocks_info.extend_from_slice(&0u16.to_be_bytes()); + blocks_info.extend_from_slice(&1i32.to_be_bytes()); + blocks_info.extend_from_slice(&0i64.to_be_bytes()); + blocks_info.extend_from_slice(&to_i64(file_bytes.len()).to_be_bytes()); + blocks_info.extend_from_slice(&0x4u32.to_be_bytes()); + blocks_info.extend_from_slice(entry_name.as_bytes()); + blocks_info.push(0u8); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"UnityFS\0"); + bytes.extend_from_slice(&7u32.to_be_bytes()); + bytes.extend_from_slice(b"2019.4.0f1\0"); + bytes.extend_from_slice(b"2019.4.0f1\0"); + let size_offset = bytes.len(); + bytes.extend_from_slice(&0i64.to_be_bytes()); + bytes.extend_from_slice(&to_u32(blocks_info.len()).to_be_bytes()); + bytes.extend_from_slice(&to_u32(blocks_info.len()).to_be_bytes()); + bytes.extend_from_slice(&0u32.to_be_bytes()); + while bytes.len() % 16 != 0 { + bytes.push(0u8); + } + bytes.extend_from_slice(&blocks_info); + bytes.extend_from_slice(file_bytes); + let total_size = to_i64(bytes.len()); + bytes[size_offset..size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + bytes +} + +fn type_tree_blob_for(object: &FixtureObject) -> Vec { + let nodes = match object { + FixtureObject::GameObject { .. } => vec![ + node(0, "GameObject", "Base", -1, 0), + node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), + ], + FixtureObject::TextAsset { .. } => vec![ + node(0, "TextAsset", "Base", -1, 0), + node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_Script", -1, ALIGN_BYTES_FLAG), + ], + FixtureObject::MonoScript { .. } => vec![ + node(0, "MonoScript", "Base", -1, 0), + node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_ClassName", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_Namespace", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_AssemblyName", -1, ALIGN_BYTES_FLAG), + ], + FixtureObject::MonoBehaviour { .. } => vec![ + node(0, "MonoBehaviour", "Base", -1, 0), + node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), + node(1, "PPtr", "m_Script", -1, 0), + node(2, "int", "m_FileID", 4, 0), + node(2, "long long", "m_PathID", 8, 0), + node(1, "PPtr", "m_TextAsset", -1, 0), + node(2, "int", "m_FileID", 4, 0), + node(2, "long long", "m_PathID", 8, 0), + ], + FixtureObject::StrippedMonoBehaviour { .. } => return empty_type_tree_blob(), + }; + + let mut string_buffer = Vec::new(); + let mut records = Vec::new(); + for (index, (level, type_name, field_name, byte_size, meta_flags)) in nodes.iter().enumerate() { + let type_offset = push_cstring(&mut string_buffer, type_name); + let name_offset = push_cstring(&mut string_buffer, field_name); + records.push(( + *level, + type_offset, + name_offset, + *byte_size, + to_i32(index), + *meta_flags, + )); + } + + let mut blob = Vec::new(); + blob.extend_from_slice(&to_i32(records.len()).to_le_bytes()); + blob.extend_from_slice(&to_i32(string_buffer.len()).to_le_bytes()); + for (level, type_offset, name_offset, byte_size, index, meta_flags) in records { + blob.extend_from_slice(&1u16.to_le_bytes()); + blob.push(level); + blob.push(0u8); + blob.extend_from_slice(&type_offset.to_le_bytes()); + blob.extend_from_slice(&name_offset.to_le_bytes()); + blob.extend_from_slice(&byte_size.to_le_bytes()); + blob.extend_from_slice(&index.to_le_bytes()); + blob.extend_from_slice(&meta_flags.to_le_bytes()); + blob.extend_from_slice(&0u64.to_le_bytes()); + } + blob.extend_from_slice(&string_buffer); + blob +} + +fn empty_type_tree_blob() -> Vec { + let mut blob = Vec::new(); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob +} + +const fn node( + level: u8, + type_name: &'static str, + field_name: &'static str, + byte_size: i32, + meta_flags: i32, +) -> (u8, &'static str, &'static str, i32, i32) { + (level, type_name, field_name, byte_size, meta_flags) +} + +fn push_cstring(buffer: &mut Vec, value: &str) -> u32 { + let offset = to_u32(buffer.len()); + buffer.extend_from_slice(value.as_bytes()); + buffer.push(0u8); + offset +} + +fn object_bytes(object: &FixtureObject) -> Vec { + let mut bytes = Vec::new(); + match object { + FixtureObject::GameObject { name } => { + push_aligned_string(&mut bytes, name); + } + FixtureObject::TextAsset { name, script } => { + push_aligned_string(&mut bytes, name); + push_aligned_string(&mut bytes, script); + } + FixtureObject::MonoScript { + name, + class_name, + namespace, + assembly_name, + } => { + push_aligned_string(&mut bytes, name); + push_aligned_string(&mut bytes, class_name); + push_aligned_string(&mut bytes, namespace); + push_aligned_string(&mut bytes, assembly_name); + } + FixtureObject::MonoBehaviour { + name, + script, + text_asset, + } => { + push_aligned_string(&mut bytes, name); + push_pptr(&mut bytes, *script); + push_pptr(&mut bytes, *text_asset); + } + FixtureObject::StrippedMonoBehaviour { + game_object, + script, + extra_refs, + } => { + push_pptr(&mut bytes, *game_object); + bytes.extend_from_slice(&1i32.to_le_bytes()); + push_pptr(&mut bytes, *script); + for value in extra_refs { + push_pptr(&mut bytes, *value); + } + } + } + bytes +} + +fn push_aligned_string(bytes: &mut Vec, value: &str) { + bytes.extend_from_slice(&to_i32(value.len()).to_le_bytes()); + bytes.extend_from_slice(value.as_bytes()); + while bytes.len() % 4 != 0 { + bytes.push(0u8); + } +} + +fn push_pptr(bytes: &mut Vec, value: PPtrValue) { + bytes.extend_from_slice(&value.file_id.to_le_bytes()); + bytes.extend_from_slice(&value.path_id.to_le_bytes()); +} + +fn assert_json(assert: &assert_cmd::assert::Assert, label: &str) -> T { + serde_json::from_slice(&assert.get_output().stdout).expect(label) +} + +#[test] +fn help_includes_subcommands_and_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("unityasset index")) + .stdout(predicate::str::contains("unityasset refs")) + .stdout(predicate::str::contains("--direction ")) + .stdout(predicate::str::contains("--max-refs ")) + .stdout(predicate::str::contains("unityasset dump")) + .stdout(predicate::str::contains("unityasset extract")); +} + +#[test] +fn indexes_serialized_bundle_and_yaml_objects_as_json() { + let root = unique_temp_dir("index"); + write_fixture_workspace(&root); + + let mut command = cargo_command(); + let assert = command + .arg("--json") + .arg("index") + .arg(&root) + .assert() + .success(); + let rows = assert_json::>(&assert, "json"); + + assert!(rows.iter().any(|row| { + row.get("id").and_then(Value::as_str) == Some("sample.assets#1001") + && row.get("class_name").and_then(Value::as_str) == Some("TextAsset") + })); + assert!(rows.iter().any(|row| { + row.get("id").and_then(Value::as_str) == Some("sample.assets#1002") + && row.get("script_class").and_then(Value::as_str) == Some("SampleBehaviour") + && row.get("script_namespace").and_then(Value::as_str) == Some("Mercury.Sample") + && row.get("script_assembly").and_then(Value::as_str) == Some("Assembly-CSharp") + })); + assert!(rows.iter().any(|row| { + row.get("id").and_then(Value::as_str) == Some("sample.bundle::bundled.assets#2001") + && row.get("source_kind").and_then(Value::as_str) == Some("asset_bundle") + })); + assert!(rows.iter().any(|row| { + row.get("id").and_then(Value::as_str) == Some("scene.unity#3000") + && row.get("format").and_then(Value::as_str) == Some("yaml") + })); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn index_filters_by_script_metadata_for_mono_behaviour_instances() { + let root = unique_temp_dir("index-script-filter"); + write_fixture_workspace(&root); + + let mut command = cargo_command(); + let assert = command + .args([ + "--json", + "index", + "--class", + "MonoBehaviour", + "--script", + "SampleBehaviour", + &root.display().to_string(), + ]) + .assert() + .success(); + let rows = assert_json::>(&assert, "json"); + + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].get("id").and_then(Value::as_str), + Some("sample.assets#1003") + ); + assert_eq!( + rows[0].get("script_class").and_then(Value::as_str), + Some("SampleBehaviour") + ); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn index_empty_matches_still_exit_success_with_json_output() { + let root = unique_temp_dir("index-empty-success"); + write_fixture_workspace(&root); + + let mut command = cargo_command(); + let assert = command + .args([ + "--json", + "index", + "--class", + "^VideoClip$", + &root.display().to_string(), + ]) + .assert() + .success(); + let rows = assert_json::>(&assert, "json"); + assert!(rows.is_empty()); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn reports_binary_incoming_and_outgoing_references_as_json() { + let root = unique_temp_dir("refs-bin"); + write_fixture_workspace(&root); + + let mut command = cargo_command(); + let assert = command + .args(["--json", "refs", "sample.assets#1001"]) + .arg(&root) + .assert() + .success(); + let refs = assert_json::(&assert, "json"); + + assert_eq!( + refs.get("target") + .and_then(|target| target.get("id")) + .and_then(Value::as_str), + Some("sample.assets#1001") + ); + assert_eq!( + refs.get("incoming") + .and_then(Value::as_array) + .expect("incoming") + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .collect::>(), + vec!["sample.assets#1003"] + ); + assert!( + refs.get("outgoing") + .and_then(Value::as_array) + .expect("outgoing") + .is_empty() + ); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn reports_yaml_references_as_json() { + let root = unique_temp_dir("refs-yaml"); + write_fixture_workspace(&root); + + let mut command = cargo_command(); + let assert = command + .args(["--json", "refs", "scene.unity#1000"]) + .arg(&root) + .assert() + .success(); + let refs = assert_json::(&assert, "json"); + + assert_eq!( + refs.get("outgoing") + .and_then(Value::as_array) + .expect("outgoing") + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .collect::>(), + vec!["scene.unity#2000"] + ); + assert_eq!( + refs.get("incoming") + .and_then(Value::as_array) + .expect("incoming") + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .collect::>(), + vec!["scene.unity#2000"] + ); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn reports_stripped_mono_behaviour_refs_and_metadata_only_dump() { + let root = unique_temp_dir("refs-stripped"); + write_fixture_workspace(&root); + + let mut refs_command = cargo_command(); + let refs_assert = refs_command + .args(["--json", "refs", "stripped.assets#2000"]) + .arg(&root) + .assert() + .success(); + let refs = assert_json::(&refs_assert, "json"); + + let outgoing = refs + .get("outgoing") + .and_then(Value::as_array) + .expect("outgoing"); + assert!(outgoing.iter().any(|entry| { + entry.get("path").and_then(Value::as_str) == Some("m_GameObject") + && entry.get("id").and_then(Value::as_str) == Some("stripped.assets#29") + })); + assert!(outgoing.iter().any(|entry| { + entry.get("path").and_then(Value::as_str) == Some("m_Script") + && entry.get("id").and_then(Value::as_str) == Some("stripped.assets#1182") + })); + assert!(outgoing.iter().any(|entry| { + entry + .get("path") + .and_then(Value::as_str) + .is_some_and(|path| path.starts_with("_raw_pptr@")) + && entry.get("id").and_then(Value::as_str) == Some("stripped.assets#30") + })); + + let mut dump_command = cargo_command(); + let dump_assert = dump_command + .args(["--json", "dump", "stripped.assets#2000"]) + .arg(&root) + .assert() + .success(); + let dump = assert_json::(&dump_assert, "json"); + + assert_eq!( + dump.get("field_mode").and_then(Value::as_str), + Some("metadata_only") + ); + assert_eq!( + dump.get("properties") + .and_then(|value| value.get("m_GameObject")) + .and_then(|value| value.get("resolved_id")) + .and_then(Value::as_str), + Some("stripped.assets#29") + ); + assert_eq!( + dump.get("properties") + .and_then(|value| value.get("m_Script")) + .and_then(|value| value.get("resolved_id")) + .and_then(Value::as_str), + Some("stripped.assets#1182") + ); + assert!( + dump.get("properties") + .and_then(|value| value.get("_raw_resolved_references")) + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + ); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn refs_json_supports_direction_and_max_refs() { + let root = unique_temp_dir("refs-direction"); + write_fixture_workspace(&root); + + let mut command = cargo_command(); + let assert = command + .args([ + "--json", + "refs", + "--direction", + "outgoing", + "--max-refs", + "2", + "stripped.assets#2000", + ]) + .arg(&root) + .assert() + .success(); + let refs = assert_json::(&assert, "json"); + + assert_eq!( + refs.get("direction").and_then(Value::as_str), + Some("outgoing") + ); + assert_eq!(refs.get("max_refs").and_then(Value::as_u64), Some(2)); + assert_eq!(refs.get("outgoing_total").and_then(Value::as_u64), Some(3)); + assert_eq!(refs.get("outgoing_shown").and_then(Value::as_u64), Some(2)); + assert_eq!( + refs.get("outgoing_truncated").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + refs.get("incoming_filter_reason").and_then(Value::as_str), + Some("excluded_by_direction") + ); + assert!( + refs.get("incoming") + .and_then(Value::as_array) + .expect("incoming") + .is_empty() + ); + assert_eq!( + refs.get("outgoing") + .and_then(Value::as_array) + .expect("outgoing") + .len(), + 2 + ); + + fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn dumps_bundle_objects_and_extracts_text_payloads() { + let root = unique_temp_dir("dump-extract"); + write_fixture_workspace(&root); + + let mut dump_command = cargo_command(); + let dump_assert = dump_command + .args(["--json", "dump", "sample.bundle::bundled.assets#2002"]) + .arg(&root) + .assert() + .success(); + let dump = assert_json::(&dump_assert, "dump json"); + assert_eq!( + dump.get("properties") + .and_then(|properties| properties.get("m_ClassName")) + .and_then(Value::as_str), + Some("BundledBehaviour") + ); + + let out_dir = root.join("out"); + fs::create_dir_all(&out_dir).expect("out dir"); + let mut extract_command = cargo_command(); + extract_command + .args([ + "extract", + "sample.bundle::bundled.assets#2001", + &root.display().to_string(), + "--output-dir", + &out_dir.display().to_string(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("BundledText")); + + let bundled_text = fs::read_to_string(out_dir.join("BundledText.txt")).expect("bundled text"); + assert_eq!(bundled_text, "Hello from bundled text asset\n"); + + let mut yaml_extract = cargo_command(); + yaml_extract + .args([ + "extract", + "scene.unity#3000", + &root.display().to_string(), + "--output-dir", + &out_dir.display().to_string(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("YamlText")); + let yaml_text = fs::read_to_string(out_dir.join("YamlText.txt")).expect("yaml text"); + assert_eq!(yaml_text, "Hello from YAML text asset"); + + fs::remove_dir_all(root).expect("cleanup"); +} diff --git a/crates/unitydiag/Cargo.toml b/crates/unitydiag/Cargo.toml new file mode 100644 index 0000000..918b0d5 --- /dev/null +++ b/crates/unitydiag/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "unitydiag" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Extract high-signal Unity and BepInEx incidents from runtime logs." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true +unitysupport = { path = "../unitysupport" } + +[dev-dependencies] +assert_cmd.workspace = true +filetime.workspace = true +predicates.workspace = true diff --git a/crates/unitydiag/src/cli.rs b/crates/unitydiag/src/cli.rs new file mode 100644 index 0000000..27a5920 --- /dev/null +++ b/crates/unitydiag/src/cli.rs @@ -0,0 +1,700 @@ +//! The `unitydiag` command summarizes Unity and `BepInEx` logs. + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io::{self, Read}; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + print_text, read_existing_stdin_paths, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde_json::Value; +use unitysupport::{ + IncidentGroupMode, StackMode, UnityDiagOptions, UnityDiagReport, UnityLogKind, + analyze_log_paths, analyze_log_text, discover_log_paths, parse_incident_group_mode, + parse_stack_mode, +}; + +const HELP: &str = "\ +Extract high-signal incidents from Unity Player.log and BepInEx logs. + +Usage: + unitydiag [OPTIONS] [PATH...] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --game-root Auto-include BepInEx logs plus matching Player.log files for that game + --latest Search common Unity and BepInEx log locations for the newest log + --top Maximum incidents to render + --group-by incident, message, or frame + --stack none, top, or full + --include-warnings Include warning incidents + --include-info Include info incidents + -h, --help Show this help text + -V, --version Show the command version + +Examples: + unitydiag 'C:\\Users\\example\\AppData\\LocalLow\\Studio\\Game\\Player.log' + unitydiag --game-root 'C:\\game' --latest + unitydiag 'C:\\game\\BepInEx\\LogOutput.log' --include-info --top 10 + unitydiag --game-root 'C:\\game' --json | ConvertFrom-Json +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + game_root: Option, + latest: bool, + top: usize, + group_by: IncidentGroupMode, + stack_mode: StackMode, + include_warnings: bool, + include_info: bool, + paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +enum InputSource { + Paths(Vec), + Text { label: String, content: String }, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => match print_text(HELP) { + Ok(()) => ExitCode::Success.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Ok((ParseOutcome::Version, _)) => { + println!("unitydiag {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + common: CommonArgs::default(), + game_root: None, + latest: false, + top: 20, + group_by: IncidentGroupMode::Incident, + stack_mode: StackMode::Top, + include_warnings: false, + include_info: false, + paths: Vec::new(), + }; + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.common.set_render_mode(RenderMode::Json), + Long("toon") => cli.common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + cli.common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + cli.common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + cli.common.color = + parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => cli.common.quiet = true, + Long("game-root") => { + cli.game_root = Some(parser_value_path(&mut parser, "--game-root")?); + } + Long("latest") => cli.latest = true, + Long("top") => { + cli.top = parse_usize_flag("--top", &parser_value_string(&mut parser, "--top")?)?; + } + Long("group-by") => { + cli.group_by = + parse_incident_group_mode(&parser_value_string(&mut parser, "--group-by")?)?; + } + Long("stack") => { + cli.stack_mode = parse_stack_mode(&parser_value_string(&mut parser, "--stack")?)?; + } + Long("include-warnings") => cli.include_warnings = true, + Long("include-info") => cli.include_info = true, + ArgValue(path) => cli.paths.push(PathBuf::from(path)), + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parser_value_path(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + if value.is_empty() { + Err(CliError::usage(format!("{flag} requires a path value"))) + } else { + Ok(PathBuf::from(value)) + } +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) +} + +fn run(cli: &Cli) -> Result { + if cli.top == 0 { + return Err(CliError::usage("--top must be greater than 0")); + } + + let options = UnityDiagOptions { + top: cli.top, + group_by: cli.group_by, + stack_mode: cli.stack_mode, + include_warnings: cli.include_warnings, + include_info: cli.include_info, + }; + + let report = match collect_input(cli)? { + InputSource::Paths(paths) => analyze_log_paths(&paths, &options)?, + InputSource::Text { label, content } => { + analyze_log_text(&label, &content, infer_stdin_kind(&content), &options) + } + }; + + match cli.common.render_mode() { + RenderMode::Json => print_json(&report)?, + RenderMode::Toon => print_structured(&report, RenderMode::Toon)?, + RenderMode::Text => { + let rendered = render_text(&report, cli); + if !rendered.is_empty() { + print_text(&rendered)?; + } + } + } + + Ok(if report.logs.is_empty() { + map_result_count(report.incidents.len()) + } else { + ExitCode::Success + }) +} + +fn collect_input(cli: &Cli) -> Result { + if !cli.paths.is_empty() || cli.game_root.is_some() || cli.latest { + let paths = discover_log_paths(&cli.paths, cli.game_root.as_deref(), cli.latest)?; + if !paths.is_empty() { + return Ok(InputSource::Paths(paths)); + } + } + + if !cli.common.stdin_is_terminal() { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + if !buffer.trim().is_empty() { + return parse_stdin_input(&buffer, cli.common.input_format); + } + } + + Err(CliError::usage( + "provide at least one log path, --latest, --game-root, or pipe log text into stdin", + )) +} + +fn parse_stdin_input(buffer: &str, input_format: InputFormat) -> Result { + let buffer = buffer.strip_prefix('\u{feff}').unwrap_or(buffer); + match input_format { + InputFormat::Jsonl => { + let mut content = String::new(); + for (index, line) in buffer.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value = serde_json::from_str::(trimmed).map_err(|error| { + CliError::usage(format!( + "invalid JSONL path record at line {}: {error}", + index + 1 + )) + })?; + match value { + Value::String(line_text) => { + let _ = writeln!(content, "{line_text}"); + } + Value::Object(object) => { + let line_text = object + .get("line") + .or_else(|| object.get("message")) + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::usage(format!( + "JSONL log record at line {} must contain a string field named line or message", + index + 1 + )) + })?; + let _ = writeln!(content, "{line_text}"); + } + _ => { + return Err(CliError::usage(format!( + "JSONL log record at line {} must be a string or object with line/message", + index + 1 + ))); + } + } + } + if content.trim().is_empty() { + return Err(CliError::usage( + "stdin JSONL did not contain any log lines".to_string(), + )); + } + Ok(InputSource::Text { + label: "stdin".to_string(), + content, + }) + } + InputFormat::Auto | InputFormat::Lines => { + match read_existing_stdin_paths(buffer, input_format, "unitydiag") { + Ok(Some(paths)) => Ok(InputSource::Paths(paths)), + Ok(None) | Err(CliError::Usage(_)) => Ok(InputSource::Text { + label: "stdin".to_string(), + content: buffer.to_string(), + }), + Err(error) => Err(error), + } + } + } +} + +fn infer_stdin_kind(content: &str) -> UnityLogKind { + if content.contains("BepInEx") || content.contains("[Info :") || content.contains("[Error :") + { + UnityLogKind::Bepinex + } else if content.contains("Initialize engine version") || content.contains("Player.log") { + UnityLogKind::Player + } else { + UnityLogKind::Other + } +} + +fn render_text(report: &UnityDiagReport, cli: &Cli) -> String { + let mut rendered = String::new(); + let raw_warning_count = report + .logs + .iter() + .map(|log| log.warning_count) + .sum::(); + let raw_info_count = report.logs.iter().map(|log| log.info_count).sum::(); + let _ = writeln!( + rendered, + "summary logs={} incidents={} errors={} warnings={} infos={} group_by={}", + report.summary.log_count, + report.summary.incident_count, + report.summary.error_count, + report.summary.warning_count, + report.summary.info_count, + group_by_label(report.summary.group_by) + ); + + for log in &report.logs { + let _ = writeln!( + rendered, + "log path={} kind={} events={} included={} errors={} warnings={} infos={}", + log.path, + log_kind_label(log.kind), + log.event_count, + log.included_event_count, + log.error_count, + log.warning_count, + log.info_count + ); + } + + if report.incidents.is_empty() { + if !cli.include_warnings && raw_warning_count > 0 { + let _ = writeln!( + rendered, + "hint warnings were present but filtered; rerun with --include-warnings to inspect {raw_warning_count} warning events" + ); + } + if !cli.include_info && raw_info_count > 0 { + let _ = writeln!( + rendered, + "hint infos were present but filtered; rerun with --include-info to inspect {raw_info_count} informational events" + ); + } + } + + for incident in &report.incidents { + let noise = if incident.likely_shutdown_noise { + " noise=likely_shutdown" + } else { + "" + }; + let _ = writeln!( + rendered, + "count={} severity={} domain={} type={} message={} frame={}{}", + incident.count, + severity_label(incident.severity), + incident.domain, + incident.exception_type.as_deref().unwrap_or("-"), + incident.message, + incident.primary_frame.as_deref().map_or("-", compact_frame), + noise + ); + if cli.stack_mode == StackMode::Full { + for frame in &incident.stack { + let _ = writeln!(rendered, "stack={}", compact_frame(frame)); + } + } + } + + rendered +} + +fn compact_frame(frame: &str) -> &str { + frame + .split(" [") + .next() + .unwrap_or(frame) + .split(" (") + .next() + .unwrap_or(frame) +} + +const fn group_by_label(mode: IncidentGroupMode) -> &'static str { + match mode { + IncidentGroupMode::Incident => "incident", + IncidentGroupMode::Message => "message", + IncidentGroupMode::Frame => "frame", + } +} + +const fn severity_label(severity: unitysupport::UnitySeverity) -> &'static str { + match severity { + unitysupport::UnitySeverity::Error => "error", + unitysupport::UnitySeverity::Warning => "warning", + unitysupport::UnitySeverity::Info => "info", + } +} + +const fn log_kind_label(kind: UnityLogKind) -> &'static str { + match kind { + UnityLogKind::Player => "player", + UnityLogKind::Bepinex => "bepinex", + UnityLogKind::Other => "other", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{suffix}")); + fs::create_dir_all(&path).expect("temp dir"); + path + } + + #[test] + fn parse_cli_supports_top_group_by_and_stack_flags() { + let (outcome, cli) = parse_cli_from([ + "unitydiag", + "--json", + "--top", + "7", + "--group-by", + "frame", + "--stack", + "full", + "--include-warnings", + "--include-info", + "Player.log", + ]) + .expect("cli"); + + assert_eq!(outcome, ParseOutcome::Run); + assert!(cli.common.json); + assert_eq!(cli.top, 7); + assert_eq!(cli.group_by, IncidentGroupMode::Frame); + assert_eq!(cli.stack_mode, StackMode::Full); + assert!(cli.include_warnings); + assert!(cli.include_info); + assert_eq!(cli.paths, vec![PathBuf::from("Player.log")]); + } + + #[test] + fn parse_cli_exposes_help_and_version_outcomes() { + let (help_outcome, _) = parse_cli_from(["unitydiag", "--help"]).expect("help"); + assert_eq!(help_outcome, ParseOutcome::Help); + + let (version_outcome, _) = parse_cli_from(["unitydiag", "-V"]).expect("version"); + assert_eq!(version_outcome, ParseOutcome::Version); + } + + #[test] + fn parse_cli_rejects_invalid_top_value() { + let error = parse_cli_from(["unitydiag", "--top", "NaN"]).expect_err("invalid top"); + let text = error.to_string(); + assert!(text.contains("invalid --top value")); + } + + #[test] + fn parse_stdin_lines_mode_promotes_existing_paths() { + let root = unique_temp_dir("unitydiag-paths"); + let first = root.join("Player.log"); + let second = root.join("LogOutput.log"); + fs::write(&first, "a").expect("first"); + fs::write(&second, "b").expect("second"); + + let input = format!("{}\n{}\n", first.display(), second.display()); + let parsed = parse_stdin_input(&input, InputFormat::Lines).expect("stdin"); + let InputSource::Paths(paths) = parsed else { + panic!("expected path input"); + }; + assert_eq!(paths, vec![second, first]); + } + + #[test] + fn parse_stdin_auto_mode_keeps_non_path_text() { + let parsed = parse_stdin_input("Error: nope\n", InputFormat::Auto).expect("stdin"); + let InputSource::Text { label, content } = parsed else { + panic!("expected text input"); + }; + assert_eq!(label, "stdin"); + assert_eq!(content, "Error: nope\n"); + } + + #[test] + fn parse_stdin_strips_utf8_bom_before_text_and_jsonl_modes() { + let parsed = parse_stdin_input("\u{feff}Error: nope\n", InputFormat::Auto).expect("stdin"); + let InputSource::Text { content, .. } = parsed else { + panic!("expected text input"); + }; + assert_eq!(content, "Error: nope\n"); + + let parsed = + parse_stdin_input("\u{feff}\"line one\"\n", InputFormat::Jsonl).expect("jsonl"); + let InputSource::Text { content, .. } = parsed else { + panic!("expected text input"); + }; + assert_eq!(content, "line one\n"); + } + + #[test] + fn parse_stdin_jsonl_supports_string_and_object_lines() { + let buffer = "\"line one\"\n{\"line\":\"line two\"}\n{\"message\":\"line three\"}\n"; + let parsed = parse_stdin_input(buffer, InputFormat::Jsonl).expect("jsonl"); + let InputSource::Text { label, content } = parsed else { + panic!("expected text"); + }; + assert_eq!(label, "stdin"); + assert_eq!(content, "line one\nline two\nline three\n"); + } + + #[test] + fn parse_stdin_jsonl_rejects_invalid_shape() { + let error = parse_stdin_input("{\"x\":1}\n", InputFormat::Jsonl) + .err() + .expect("jsonl error"); + assert!( + error + .to_string() + .contains("must contain a string field named line or message") + ); + } + + #[test] + fn infer_stdin_kind_recognizes_bepinex_player_and_other() { + assert_eq!( + infer_stdin_kind("[Info : BepInEx] loaded"), + UnityLogKind::Bepinex + ); + assert_eq!( + infer_stdin_kind("Initialize engine version: 2021.3.0f1"), + UnityLogKind::Player + ); + assert_eq!(infer_stdin_kind("plain text"), UnityLogKind::Other); + } + + #[test] + fn compact_frame_strips_location_suffixes() { + assert_eq!( + compact_frame("Game.AI.Tick() [0x00001] in file.cs:1"), + "Game.AI.Tick()" + ); + assert_eq!( + compact_frame("Game.AI.Tick() (at Assets/Scripts/Tick.cs:7)"), + "Game.AI.Tick()" + ); + } + + #[test] + fn render_text_includes_stack_lines_in_full_mode() { + let report = UnityDiagReport { + logs: vec![unitysupport::UnityLogSummary { + path: "stdin".to_string(), + kind: UnityLogKind::Other, + event_count: 1, + included_event_count: 1, + error_count: 1, + warning_count: 0, + info_count: 0, + }], + incidents: vec![unitysupport::UnityIncident { + group_by: IncidentGroupMode::Incident, + count: 1, + severity: unitysupport::UnitySeverity::Error, + domain: "game".to_string(), + exception_type: Some("ArgumentNullException".to_string()), + message: "boom".to_string(), + normalized_message: "boom".to_string(), + primary_frame: Some("Game.AI.Tick()".to_string()), + stack: vec![ + "Game.AI.Tick()".to_string(), + "System.Threading.Task.Run()".to_string(), + ], + channels: vec!["Default".to_string()], + paths: vec!["stdin".to_string()], + first_path: "stdin".to_string(), + first_line: 1, + likely_shutdown_noise: false, + }], + summary: unitysupport::UnityDiagSummary { + group_by: IncidentGroupMode::Incident, + log_count: 1, + event_count: 1, + incident_count: 1, + error_count: 1, + warning_count: 0, + info_count: 0, + domain_counts: vec![unitysupport::CountEntry { + name: "game".to_string(), + count: 1, + }], + }, + }; + let cli = Cli { + common: CommonArgs::default(), + game_root: None, + latest: false, + top: 20, + group_by: IncidentGroupMode::Incident, + stack_mode: StackMode::Full, + include_warnings: false, + include_info: false, + paths: Vec::new(), + }; + + let rendered = render_text(&report, &cli); + assert!(rendered.contains("summary logs=1 incidents=1")); + assert!(rendered.contains("count=1 severity=error domain=game")); + assert!(rendered.contains("stack=Game.AI.Tick()")); + assert!(rendered.contains("stack=System.Threading.Task.Run()")); + } + + #[test] + fn render_text_hints_when_only_filtered_warnings_or_infos_exist() { + let report = UnityDiagReport { + logs: vec![unitysupport::UnityLogSummary { + path: "stdin".to_string(), + kind: UnityLogKind::Bepinex, + event_count: 3, + included_event_count: 0, + error_count: 0, + warning_count: 1, + info_count: 2, + }], + incidents: Vec::new(), + summary: unitysupport::UnityDiagSummary { + group_by: IncidentGroupMode::Incident, + log_count: 1, + event_count: 0, + incident_count: 0, + error_count: 0, + warning_count: 0, + info_count: 0, + domain_counts: Vec::new(), + }, + }; + let cli = Cli { + common: CommonArgs::default(), + game_root: None, + latest: false, + top: 20, + group_by: IncidentGroupMode::Incident, + stack_mode: StackMode::Top, + include_warnings: false, + include_info: false, + paths: Vec::new(), + }; + + let rendered = render_text(&report, &cli); + assert!(rendered.contains("rerun with --include-warnings")); + assert!(rendered.contains("rerun with --include-info")); + } +} diff --git a/crates/unitydiag/src/lib.rs b/crates/unitydiag/src/lib.rs new file mode 100644 index 0000000..5995ad5 --- /dev/null +++ b/crates/unitydiag/src/lib.rs @@ -0,0 +1,6 @@ +//! Public entry point for the `unitydiag` command crate. +#![allow(clippy::multiple_crate_versions)] + +mod cli; + +pub use cli::main_entry; diff --git a/crates/unitydiag/src/main.rs b/crates/unitydiag/src/main.rs new file mode 100644 index 0000000..03516a5 --- /dev/null +++ b/crates/unitydiag/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `unitydiag`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(unitydiag::main_entry()); +} diff --git a/crates/unitydiag/tests/fixtures/LogOutput.log b/crates/unitydiag/tests/fixtures/LogOutput.log new file mode 100644 index 0000000..3178bbb --- /dev/null +++ b/crates/unitydiag/tests/fixtures/LogOutput.log @@ -0,0 +1,9 @@ +[Message: BepInEx] BepInEx 5.4.23.5 - Solar Expanse (2026/4/19 11:22:45) +[Info :Solar Expanse Trainer] Pipe client connected (1). +[Info :Solar Expanse Trainer] Pipe client connected (2). +[Warning: HarmonyX] AccessTools.Method: Could not find method for type Game.UI.Screen. +[Error :Solar Expanse Trainer] Pipe server loop failed: System.Threading.ThreadAbortException: Thread was being aborted. + at SolarExpanseTrainer.SolarExpanseTrainerPlugin.HandleCommand (SolarExpanseTrainer.PipeCommand command) [0x000b8] in <414166dfdcae444f95d8ea05ca6715f0>:0 + at SolarExpanseTrainer.SolarExpanseTrainerPlugin.PipeServerMain () [0x0007b] in <414166dfdcae444f95d8ea05ca6715f0>:0 +[Error : BepInEx] Chainloader startup failed: System.MissingMethodException: Method not found: 'Void Demo.Run(Int32)'. + at BepInEx.Bootstrap.Chainloader.Start () [0x0007b] in <414166dfdcae444f95d8ea05ca6715f0>:0 diff --git a/crates/unitydiag/tests/fixtures/player.log b/crates/unitydiag/tests/fixtures/player.log new file mode 100644 index 0000000..2d47213 --- /dev/null +++ b/crates/unitydiag/tests/fixtures/player.log @@ -0,0 +1,16 @@ +Initialize engine version: 2022.3.22f1 (887be4894c44) +ArgumentNullException: Value cannot be null. +Parameter name: source + at Game.AI.ContractHasObjective.OnUpdate () [0x0000d] in <70471b9615aa4ecfa3ed1abb95b73832>:0 + at UnityEngine.MonoBehaviour:Update() +ArgumentNullException: Value cannot be null. +Parameter name: source + at Game.AI.ContractHasObjective.OnUpdate () [0x0000d] in <70471b9615aa4ecfa3ed1abb95b73832>:0 + at UnityEngine.MonoBehaviour:Update() +The referenced script on this Behaviour (Game Object 'CycleMissionEditWindow') is missing! +A formatter has been created for the UnityEngine.Object type Sprite - this is *strongly* discouraged. + + Stacktrace: at Sirenix.Serialization.BaseFormatter`1[T]..cctor () [0x00000] in :0 + at System.Object.__icall_wrapper_mono_generic_class_init (System.IntPtr) [0x00000] in <27124aa0e30a41659b903b822b959bc7>:0 +Amplitude Event Sent Successfully +Amplitude Event Sent Successfully diff --git a/crates/unitydiag/tests/unitydiag_cli.rs b/crates/unitydiag/tests/unitydiag_cli.rs new file mode 100644 index 0000000..b0cf00a --- /dev/null +++ b/crates/unitydiag/tests/unitydiag_cli.rs @@ -0,0 +1,269 @@ +//! Integration tests for the `unitydiag` command. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use assert_cmd::Command; +use filetime::{FileTime, set_file_mtime}; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("unitydiag").expect("binary") +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(path) +} + +fn create_temp_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("unitydiag-{label}-{}-{unique}", std::process::id())); + fs::create_dir_all(&path).expect("temp dir"); + path +} + +fn write_fixture(target: &Path, source_name: &str) { + let content = fs::read(fixture(source_name)).expect("read fixture"); + fs::write(target, content).expect("write fixture"); +} + +fn set_mtime(target: &Path, unix_seconds: i64) { + set_file_mtime(target, FileTime::from_unix_time(unix_seconds, 0)).expect("set file mtime"); +} + +#[test] +fn emits_grouped_text_output_by_incident() { + let mut command = cargo_command(); + command + .arg(fixture("player.log")) + .arg(fixture("LogOutput.log")) + .assert() + .success() + .stdout(predicate::str::contains("summary logs=2")) + .stdout(predicate::str::contains("domain=game")) + .stdout(predicate::str::contains("domain=mod")) + .stdout(predicate::str::contains("domain=bepinex")) + .stdout(predicate::str::contains("count=2")) + .stdout(predicate::str::contains( + "message=ArgumentNullException: Value cannot be null. | Parameter name: source", + )) + .stdout(predicate::str::contains( + "frame=Game.AI.ContractHasObjective.OnUpdate", + )); +} + +#[test] +fn emits_stable_json_object_with_logs_incidents_and_summary() { + let mut command = cargo_command(); + command + .arg("--json") + .arg("--include-warnings") + .arg("--include-info") + .arg("--stack") + .arg("top") + .arg(fixture("player.log")) + .arg(fixture("LogOutput.log")) + .assert() + .success() + .stdout(predicate::str::contains("\"logs\":[")) + .stdout(predicate::str::contains("\"incidents\":[")) + .stdout(predicate::str::contains("\"summary\":{")) + .stdout(predicate::str::contains("\"group_by\":\"incident\"")) + .stdout(predicate::str::contains("\"domain\":\"telemetry\"")) + .stdout(predicate::str::contains("\"domain\":\"harmony\"")) + .stdout(predicate::str::contains("\"kind\":\"player\"")) + .stdout(predicate::str::contains("\"kind\":\"bepinex\"")); +} + +#[test] +fn supports_powershell_pipeline_input() { + let binary = assert_cmd::cargo::cargo_bin("unitydiag"); + let input = fixture("player.log"); + let script = format!( + "Get-Content '{}' | & '{}' --include-warnings --json", + input.display(), + binary.display() + ); + + let mut command = Command::new("pwsh"); + command + .current_dir(workspace_root()) + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"incidents\":[")) + .stdout(predicate::str::contains("\"domain\":\"game\"")); +} + +#[test] +fn discovers_logs_from_game_root_and_latest() { + let root = create_temp_dir("discover"); + let game_root = root.join("Solar Expanse"); + let bepinex_dir = game_root.join("BepInEx"); + let locallow_dir = root + .join("AppData") + .join("LocalLow") + .join("SpaceOps") + .join("Solar Expanse"); + fs::create_dir_all(&bepinex_dir).expect("bepinex dir"); + fs::create_dir_all(&locallow_dir).expect("locallow dir"); + + write_fixture(&locallow_dir.join("Player.log"), "player.log"); + write_fixture(&bepinex_dir.join("LogOutput.log"), "LogOutput.log"); + set_mtime(&locallow_dir.join("Player.log"), 1_700_000_000); + set_mtime(&bepinex_dir.join("LogOutput.log"), 1_700_000_002); + + let mut command = cargo_command(); + command + .env("USERPROFILE", &root) + .arg("--json") + .arg("--include-info") + .arg("--game-root") + .arg(&game_root) + .arg("--latest") + .assert() + .success() + .stdout(predicate::str::contains("\"logs\":[")) + .stdout(predicate::str::contains("LogOutput.log")) + .stdout(predicate::str::contains("\"log_count\":1")); + + fs::remove_dir_all(&root).expect("cleanup"); +} + +#[test] +fn game_root_without_latest_includes_bepinex_and_matching_player_log() { + let root = create_temp_dir("discover-both"); + let game_root = root.join("Solar Expanse"); + let bepinex_dir = game_root.join("BepInEx"); + let locallow_dir = root + .join("AppData") + .join("LocalLow") + .join("SpaceOps") + .join("Solar Expanse"); + fs::create_dir_all(&bepinex_dir).expect("bepinex dir"); + fs::create_dir_all(&locallow_dir).expect("locallow dir"); + + write_fixture(&locallow_dir.join("Player.log"), "player.log"); + write_fixture(&bepinex_dir.join("LogOutput.log"), "LogOutput.log"); + + let mut command = cargo_command(); + command + .env("USERPROFILE", &root) + .arg("--json") + .arg("--include-info") + .arg("--game-root") + .arg(&game_root) + .assert() + .success() + .stdout(predicate::str::contains("\"log_count\":2")) + .stdout(predicate::str::contains("LogOutput.log")) + .stdout(predicate::str::contains("Player.log")); + + fs::remove_dir_all(&root).expect("cleanup"); +} + +#[test] +fn discovers_logs_from_relative_dot_game_root() { + let root = create_temp_dir("discover-dot"); + let game_root = root.join("Solar Expanse"); + let locallow_root = root.join("AppData").join("LocalLow"); + let target_locallow = locallow_root.join("SpaceOps").join("Solar Expanse"); + let unrelated_locallow = locallow_root.join("OtherStudio").join("Another Game"); + fs::create_dir_all(&game_root).expect("game root"); + fs::create_dir_all(&target_locallow).expect("target locallow"); + fs::create_dir_all(&unrelated_locallow).expect("unrelated locallow"); + + write_fixture(&target_locallow.join("Player.log"), "player.log"); + write_fixture(&unrelated_locallow.join("Player.log"), "player.log"); + set_mtime(&target_locallow.join("Player.log"), 1_700_000_000); + set_mtime(&unrelated_locallow.join("Player.log"), 1_700_000_002); + + let mut command = cargo_command(); + command + .current_dir(&game_root) + .env("USERPROFILE", &root) + .arg("--json") + .arg("--include-info") + .arg("--game-root") + .arg(".") + .arg("--latest") + .assert() + .success() + .stdout(predicate::str::contains("\"logs\":[")) + .stdout(predicate::str::contains("SpaceOps")) + .stdout(predicate::str::contains("Solar Expanse")) + .stdout(predicate::str::contains("\"log_count\":1")) + .stdout(predicate::str::contains("\"kind\":\"player\"")); + + fs::remove_dir_all(&root).expect("cleanup"); +} + +#[test] +fn help_includes_examples_and_grouping_flags() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--group-by")) + .stdout(predicate::str::contains("--latest")) + .stdout(predicate::str::contains("ConvertFrom-Json")) + .stdout(predicate::str::contains( + "unitydiag --game-root 'C:\\game' --latest", + )); +} + +#[test] +fn utf8_bom_log_file_is_parsed_without_losing_incidents() { + let root = create_temp_dir("bom"); + let log_path = root.join("Player.log"); + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend(fs::read(fixture("player.log")).expect("fixture")); + fs::write(&log_path, bytes).expect("bom fixture"); + + let mut command = cargo_command(); + command + .arg("--json") + .arg(&log_path) + .assert() + .success() + .stdout(predicate::str::contains("\"log_count\":1")) + .stdout(predicate::str::contains("\"domain\":\"game\"")); + + fs::remove_dir_all(&root).expect("cleanup"); +} + +#[test] +fn invalid_utf8_log_file_reports_read_error_instead_of_summarizing() { + let root = create_temp_dir("invalid-utf8"); + let log_path = root.join("Player.log"); + fs::write(&log_path, b"Initialize engine version\n\xff\xfe\xfd\n").expect("invalid fixture"); + + let mut command = cargo_command(); + command + .arg(&log_path) + .assert() + .failure() + .stderr(predicate::str::contains("failed to read")) + .stderr(predicate::str::contains("valid UTF-8")); + + fs::remove_dir_all(&root).expect("cleanup"); +} diff --git a/crates/unityprobe/Cargo.toml b/crates/unityprobe/Cargo.toml new file mode 100644 index 0000000..ac3ae33 --- /dev/null +++ b/crates/unityprobe/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "unityprobe" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Read-only runtime inspection for BepInEx Mono Unity games." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json.workspace = true +unitysupport = { path = "../unitysupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/unityprobe/src/cli.rs b/crates/unityprobe/src/cli.rs new file mode 100644 index 0000000..db019af --- /dev/null +++ b/crates/unityprobe/src/cli.rs @@ -0,0 +1,1159 @@ +//! The `unityprobe` command talks to a read-only Unity bridge. +#![allow(clippy::multiple_crate_versions)] + +use std::cmp::Ordering; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::path::PathBuf; + +use common::{ + CliError, CommonArgs, ExitCode, RenderMode, expand_input_patterns, map_result_count, + parse_color_choice, parse_format_choice, print_json, print_quick_help_error, print_structured, + print_text, require_exactly_one_input_path, +}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use unitysupport::probe::ObjectRecord; +use unitysupport::{ + BRIDGE_PIPE_NAME, FindData, InspectData, InstallReport, ScenesData, StaticData, StatusReport, + UninstallReport, find_objects, inspect_object, inspect_static, install_bridge, query_scenes, + status_bridge, uninstall_bridge, +}; + +const HELP: &str = "\ +Inspect a running BepInEx Mono Unity game through an explicit read-only bridge. + +Windows only: install the bridge into BepInEx\\plugins, then query it over a named pipe JSON protocol. +No implicit injection is performed. + +Usage: + unityprobe [OPTIONS] [ARGS...] + +Subcommands: + install Compile the shared bridge source with local csc.exe and install it into BepInEx\\plugins + uninstall Remove the explicit bridge install from BepInEx\\plugins + status Report install state and named pipe status + scenes List loaded scenes from the running bridge + find Find runtime objects by type or name + inspect Inspect one runtime object by Unity instance id + static Inspect static fields and properties for a managed type + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Subcommand options: + status --game-root Inspect explicit install state for one game root + find --limit Maximum runtime matches to return (default: 25) + find --type-only Keep only managed type matches and rank type hits ahead of name hits + +Examples: + unityprobe install 'C:\\game' + unityprobe status --game-root 'C:\\game' + unityprobe --json scenes | ConvertFrom-Json + unityprobe --json find 'Game\\.UI\\.Windows' | ConvertFrom-Json + unityprobe find GameManager --type-only + unityprobe inspect 10432 + unityprobe inspect -- -938 + unityprobe static Manager.GameManager +"; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + command: Command, +} + +#[derive(Debug, Clone)] +enum Command { + Install { + game_root: PathBuf, + }, + Uninstall { + game_root: PathBuf, + }, + Status { + game_root: Option, + }, + Scenes, + Find { + query: String, + limit: usize, + type_only: bool, + }, + Inspect { + instance_id: i32, + }, + Static { + type_name: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("unityprobe {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + command: Command::Status { game_root: None }, + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + command: Command::Status { game_root: None }, + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + ArgValue(value) => { + let subcommand = os_string_to_string(value, "subcommand")?; + let command = parse_subcommand(&subcommand, &mut parser, &mut common)?; + return Ok((ParseOutcome::Run, Cli { common, command })); + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Err(CliError::usage( + "unityprobe requires a subcommand; use --help to see available options", + )) +} + +fn parse_subcommand( + subcommand: &str, + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + match subcommand { + "install" => parse_game_root_command(parser, common, "install") + .map(|game_root| Command::Install { game_root }), + "uninstall" => parse_game_root_command(parser, common, "uninstall") + .map(|game_root| Command::Uninstall { game_root }), + "status" => parse_status_command(parser, common), + "scenes" => parse_scenes_command(parser, common), + "find" => parse_find_command(parser, common), + "inspect" => parse_inspect_command(parser, common), + "static" => parse_static_command(parser, common), + _ => Err(CliError::usage( + "unsupported subcommand; use --help to see available options", + )), + } +} + +fn parse_game_root_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, + name: &str, +) -> Result { + let mut game_root = None; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("game-root") => { + game_root = Some(parser_value_path(parser, "--game-root")?); + } + ArgValue(value) => { + if game_root.is_some() { + return Err(CliError::usage(format!( + "{name} accepts only one game root path" + ))); + } + game_root = Some(PathBuf::from(value)); + } + _ => { + return Err(CliError::usage(format!( + "unsupported {name} argument; use --help to see available options" + ))); + } + } + } + + game_root.ok_or_else(|| CliError::usage(format!("{name} requires a game root path"))) +} + +fn parse_status_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut game_root = None; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("game-root") => game_root = Some(parser_value_path(parser, "--game-root")?), + _ => { + return Err(CliError::usage( + "unsupported status argument; use --help to see available options", + )); + } + } + } + Ok(Command::Status { game_root }) +} + +fn parse_scenes_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + _ => { + return Err(CliError::usage( + "scenes does not accept positional arguments", + )); + } + } + } + Ok(Command::Scenes) +} + +fn parse_find_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut query = None; + let mut limit = 25_usize; + let mut type_only = false; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + Long("limit") => { + limit = parse_usize_flag("--limit", &parser_value_string(parser, "--limit")?)?; + } + Long("type-only") => type_only = true, + ArgValue(value) => { + if query.is_some() { + return Err(CliError::usage("find accepts only one query string")); + } + query = Some(os_string_to_string(value, "find query")?); + } + _ => { + return Err(CliError::usage( + "unsupported find argument; use --help to see available options", + )); + } + } + } + + Ok(Command::Find { + query: query.ok_or_else(|| CliError::usage("find requires a query string"))?, + limit, + type_only, + }) +} + +fn parse_inspect_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut instance_id = None; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + ArgValue(value) => { + if instance_id.is_some() { + return Err(CliError::usage("inspect accepts only one instance id")); + } + instance_id = Some(os_string_to_string(value, "inspect instance id")?); + } + _ => { + return Err(CliError::usage( + "unsupported inspect argument; use --help to see available options", + )); + } + } + } + let text = instance_id.ok_or_else(|| CliError::usage("inspect requires an instance id"))?; + Ok(Command::Inspect { + instance_id: text.parse::().map_err(|error| { + CliError::usage(format!("inspect expects an integer instance id: {error}")) + })?, + }) +} + +fn parse_static_command( + parser: &mut lexopt::Parser, + common: &mut CommonArgs, +) -> Result { + let mut type_name = None; + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("quiet") => common.quiet = true, + Long("color") => { + common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?; + } + ArgValue(value) => { + if type_name.is_some() { + return Err(CliError::usage("static accepts only one type name")); + } + type_name = Some(os_string_to_string(value, "static type name")?); + } + _ => { + return Err(CliError::usage( + "unsupported static argument; use --help to see available options", + )); + } + } + } + Ok(Command::Static { + type_name: type_name + .ok_or_else(|| CliError::usage("static requires a managed type name"))?, + }) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + os_string_to_string(value, flag) +} + +fn parser_value_path(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + if value.is_empty() { + Err(CliError::usage(format!("{flag} requires a path value"))) + } else { + require_exactly_one_input_path( + &expand_input_patterns(&[PathBuf::from(value)], "unityprobe")?, + "unityprobe", + ) + } +} + +fn os_string_to_string(value: OsString, label: &str) -> Result { + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{label} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_usize_flag(flag: &str, value: &str) -> Result { + let parsed = value.parse::().map_err(|error| { + CliError::usage(format!( + "{flag} expects a positive integer, got '{value}': {error}" + )) + })?; + if parsed == 0 { + return Err(CliError::usage(format!( + "{flag} expects a positive integer, got '{value}'" + ))); + } + Ok(parsed) +} + +fn run(cli: &Cli) -> Result { + match &cli.command { + Command::Install { game_root } => { + let report = install_bridge(game_root)?; + emit_report( + cli.common.render_mode(), + &report, + render_install_text(&report), + )?; + Ok(ExitCode::Success) + } + Command::Uninstall { game_root } => { + let report = uninstall_bridge(game_root)?; + emit_report( + cli.common.render_mode(), + &report, + render_uninstall_text(&report), + )?; + Ok(ExitCode::Success) + } + Command::Status { game_root } => { + let report = status_bridge(game_root.as_deref())?; + emit_report( + cli.common.render_mode(), + &report, + render_status_text(&report), + )?; + Ok(ExitCode::Success) + } + Command::Scenes => { + let response = query_scenes()?; + let data = require_data(response, "scenes")?; + emit_report(cli.common.render_mode(), &data, render_scenes_text(&data))?; + Ok(map_result_count(data.scenes.len())) + } + Command::Find { + query, + limit, + type_only, + } => { + let response = find_objects(query, expanded_probe_limit(*limit, *type_only))?; + let data = rerank_find_data(require_data(response, "find")?, *limit, *type_only); + emit_report(cli.common.render_mode(), &data, render_find_text(&data))?; + Ok(map_result_count(data.matches.len())) + } + Command::Inspect { instance_id } => { + let response = inspect_object(*instance_id)?; + let data = require_data(response, "inspect")?; + emit_report(cli.common.render_mode(), &data, render_inspect_text(&data))?; + Ok(ExitCode::Success) + } + Command::Static { type_name } => { + let response = inspect_static(type_name)?; + let data = require_data(response, "static")?; + emit_report(cli.common.render_mode(), &data, render_static_text(&data))?; + Ok(ExitCode::Success) + } + } +} + +fn require_data(envelope: unitysupport::ProbeEnvelope, kind: &str) -> Result { + envelope.data.ok_or_else(|| { + CliError::runtime(format!( + "{kind} returned an empty bridge payload on pipe {BRIDGE_PIPE_NAME}" + )) + }) +} + +fn expanded_probe_limit(limit: usize, type_only: bool) -> usize { + let safe_limit = limit.max(1); + let multiplier = if type_only { 8 } else { 4 }; + safe_limit.saturating_mul(multiplier).clamp(safe_limit, 256) +} + +fn rerank_find_data(mut data: FindData, limit: usize, type_only: bool) -> FindData { + let normalized_query = normalize_find_query(&data.query); + data.matches + .sort_by(|left, right| compare_find_matches(left, right, &normalized_query)); + if type_only { + data.matches + .retain(|item| find_type_match_score(item, &normalized_query) > 0); + } + data.matches.truncate(limit); + data +} + +fn compare_find_matches( + left: &ObjectRecord, + right: &ObjectRecord, + normalized_query: &str, +) -> Ordering { + let left_type_score = find_type_match_score(left, normalized_query); + let right_type_score = find_type_match_score(right, normalized_query); + let left_name_score = find_name_match_score(left, normalized_query); + let right_name_score = find_name_match_score(right, normalized_query); + right_type_score + .cmp(&left_type_score) + .then_with(|| right_name_score.cmp(&left_name_score)) + .then_with(|| { + right_name_score + .max(right_type_score) + .cmp(&left_name_score.max(left_type_score)) + }) + .then_with(|| left.type_name.cmp(&right.type_name)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.hierarchy_path.cmp(&right.hierarchy_path)) + .then_with(|| left.instance_id.cmp(&right.instance_id)) +} + +fn find_type_match_score(item: &ObjectRecord, normalized_query: &str) -> usize { + let full_type = item.type_name.to_ascii_lowercase(); + let type_tail = full_type.rsplit('.').next().unwrap_or(full_type.as_str()); + score_text_candidate(&full_type, type_tail, normalized_query, 600, 540, 480) +} + +fn find_name_match_score(item: &ObjectRecord, normalized_query: &str) -> usize { + let name = item.name.to_ascii_lowercase(); + let hierarchy_tail = item + .hierarchy_path + .rsplit('/') + .find(|segment: &&str| !segment.is_empty()) + .map_or_else(String::new, |segment: &str| segment.to_ascii_lowercase()); + score_text_candidate(&name, &hierarchy_tail, normalized_query, 320, 260, 180) +} + +fn score_text_candidate( + full_text: &str, + tail_text: &str, + normalized_query: &str, + exact_full: usize, + exact_tail: usize, + contains_score: usize, +) -> usize { + if full_text == normalized_query { + exact_full + } else if tail_text == normalized_query { + exact_tail + } else if full_text.contains(normalized_query) || tail_text.contains(normalized_query) { + contains_score + } else { + 0 + } +} + +fn normalize_find_query(query: &str) -> String { + query.trim().to_ascii_lowercase() +} + +fn emit_report(mode: RenderMode, value: &T, text: String) -> Result<(), CliError> +where + T: Serialize, +{ + match mode { + RenderMode::Json => print_json(value)?, + RenderMode::Toon => print_structured(value, RenderMode::Toon)?, + RenderMode::Text => print_text(text)?, + } + Ok(()) +} + +fn render_install_text(report: &InstallReport) -> String { + format!( + "installed game_root={} plugin_dir={} dll={} pipe={} compiler={} refs={}", + report.game_root, + report.plugin_dir, + report.dll_path, + report.pipe_name, + report.compiler_path, + report.reference_paths.len() + ) +} + +fn render_uninstall_text(report: &UninstallReport) -> String { + format!( + "uninstall game_root={} plugin_dir={} removed={}", + report.game_root, report.plugin_dir, report.removed + ) +} + +fn render_status_text(report: &StatusReport) -> String { + let mut text = String::new(); + write!( + text, + "status windows_supported={} installed={} pipe={} pipe_reachable={}", + report.windows_supported, report.installed, report.pipe_name, report.pipe_reachable + ) + .expect("writing to a String cannot fail"); + if let Some(game_root) = &report.game_root { + write!(text, " game_root={game_root}").expect("writing to a String cannot fail"); + } + if let Some(running) = report.game_process_running { + write!(text, " game_process_running={running}").expect("writing to a String cannot fail"); + } + if let Some(name) = &report.game_process_name { + write!(text, " game_process={name}").expect("writing to a String cannot fail"); + } + if let Some(pid) = report.game_process_id { + write!(text, " game_pid={pid}").expect("writing to a String cannot fail"); + } + if let Some(runtime) = &report.runtime_status { + write!( + text, + " unity={} pid={} scenes={}", + runtime.unity_version, runtime.process_id, runtime.scene_count + ) + .expect("writing to a String cannot fail"); + } + if !report.problems.is_empty() { + write!(text, " problems={}", report.problems.join(" | ")) + .expect("writing to a String cannot fail"); + } + text +} + +fn render_scenes_text(data: &ScenesData) -> String { + if data.scenes.is_empty() { + return "scenes count=0".to_string(); + } + + let mut text = String::new(); + for (index, scene) in data.scenes.iter().enumerate() { + if index > 0 { + text.push('\n'); + } + write!( + text, + "scene name={} build_index={} loaded={} roots={} path={}", + scene.name, scene.build_index, scene.loaded, scene.root_count, scene.path + ) + .expect("writing to a String cannot fail"); + } + text +} + +fn render_find_text(data: &FindData) -> String { + if data.matches.is_empty() { + return format!("find query={} matches=0", data.query); + } + + let mut text = format!("find query={} matches={}", data.query, data.matches.len()); + for item in &data.matches { + write!( + text, + "\nobject id={} type={} name={} scene={} path={} active={}", + item.instance_id, + item.type_name, + item.name, + item.scene_name, + item.hierarchy_path, + item.active + ) + .expect("writing to a String cannot fail"); + } + text +} + +fn render_inspect_text(data: &InspectData) -> String { + let mut text = format!( + "inspect id={} type={} name={} scene={} path={} active={} components={} fields={} properties={}", + data.object.instance_id, + data.object.type_name, + data.object.name, + data.object.scene_name, + data.object.hierarchy_path, + data.object.active, + data.components.len(), + data.fields.len(), + data.properties.len() + ); + for component in &data.components { + write!( + text, + "\ncomponent id={} type={}", + component.instance_id, component.type_name + ) + .expect("writing to a String cannot fail"); + } + for field in &data.fields { + write!( + text, + "\nfield name={} type={} value={}", + field.name, field.declared_type, field.value + ) + .expect("writing to a String cannot fail"); + } + for property in &data.properties { + write!( + text, + "\nproperty name={} type={} value={}", + property.name, property.declared_type, property.value + ) + .expect("writing to a String cannot fail"); + } + text +} + +fn render_static_text(data: &StaticData) -> String { + let mut text = format!( + "static type={} assembly={} fields={} properties={}", + data.type_name, + data.assembly_name, + data.fields.len(), + data.properties.len() + ); + for field in &data.fields { + write!( + text, + "\nfield name={} type={} value={}", + field.name, field.declared_type, field.value + ) + .expect("writing to a String cannot fail"); + } + for property in &data.properties { + write!( + text, + "\nproperty name={} type={} value={}", + property.name, property.declared_type, property.value + ) + .expect("writing to a String cannot fail"); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn sample_find_data() -> FindData { + serde_json::from_value(json!({ + "query": "GameManager", + "matches": [ + { + "instance_id": 42, + "name": "GameManager", + "type_name": "Game.Core.GameManager", + "scene_name": "Main", + "hierarchy_path": "/Root/GameManager", + "active": true + } + ] + })) + .expect("find fixture") + } + + fn sample_inspect_data() -> InspectData { + serde_json::from_value(json!({ + "object": { + "instance_id": 42, + "name": "GameManager", + "type_name": "Game.Core.GameManager", + "scene_name": "Main", + "hierarchy_path": "/Root/GameManager", + "active": true + }, + "components": [ + { + "instance_id": 77, + "type_name": "UnityEngine.Transform" + } + ], + "fields": [ + { + "name": "state", + "declared_type": "System.String", + "value": "Ready" + } + ], + "properties": [ + { + "name": "Enabled", + "declared_type": "System.Boolean", + "value": "True" + } + ] + })) + .expect("inspect fixture") + } + + fn sample_static_data() -> StaticData { + serde_json::from_value(json!({ + "type_name": "Game.Core.Globals", + "assembly_name": "GameAssembly", + "fields": [ + { + "name": "Build", + "declared_type": "System.Int32", + "value": "42" + } + ], + "properties": [ + { + "name": "Version", + "declared_type": "System.String", + "value": "1.2.3" + } + ] + })) + .expect("static fixture") + } + + #[test] + fn parse_find_supports_limit() { + let (_, cli) = parse_cli_from(["unityprobe", "--json", "find", "--limit", "8", "Player"]) + .expect("cli"); + assert!(cli.common.json); + let Command::Find { + query, + limit, + type_only, + } = cli.command + else { + panic!("expected find command"); + }; + assert_eq!(query, "Player"); + assert_eq!(limit, 8); + assert!(!type_only); + } + + #[test] + fn parse_find_supports_type_only() { + let (_, cli) = + parse_cli_from(["unityprobe", "find", "--type-only", "GameManager"]).expect("cli"); + let Command::Find { + query, + limit, + type_only, + } = cli.command + else { + panic!("expected find command"); + }; + assert_eq!(query, "GameManager"); + assert_eq!(limit, 25); + assert!(type_only); + } + + #[test] + fn parse_status_supports_optional_game_root() { + let (_, cli) = + parse_cli_from(["unityprobe", "status", "--game-root", "C:\\Games\\Mercury"]) + .expect("cli"); + let Command::Status { game_root } = cli.command else { + panic!("expected status command"); + }; + assert_eq!(game_root.as_deref(), Some(Path::new("C:\\Games\\Mercury"))); + } + + #[test] + fn parse_common_flags_after_subcommand() { + let (_, cli) = parse_cli_from([ + "unityprobe", + "status", + "--game-root", + "C:\\Games\\Mercury", + "--json", + ]) + .expect("cli"); + assert!(cli.common.json); + } + + #[test] + fn help_text_mentions_windows_only_named_pipe_bridge() { + assert!(HELP.contains("Windows only")); + assert!(HELP.contains("named pipe JSON")); + assert!(HELP.contains("BepInEx\\plugins")); + } + + #[test] + fn parse_cli_requires_subcommand() { + let error = parse_cli_from(["unityprobe"]).expect_err("missing subcommand"); + assert!(error.to_string().contains("requires a subcommand")); + } + + #[test] + fn parse_find_rejects_invalid_limit() { + let error = + parse_cli_from(["unityprobe", "find", "--limit", "abc", "Player"]).expect_err("bad"); + assert!( + error + .to_string() + .contains("--limit expects a positive integer") + ); + } + + #[test] + fn parse_inspect_rejects_non_integer_instance_id() { + let error = parse_cli_from(["unityprobe", "inspect", "abc"]).expect_err("bad inspect"); + assert!( + error + .to_string() + .contains("inspect expects an integer instance id") + ); + } + + #[test] + fn parse_static_rejects_duplicate_type_name() { + let error = parse_cli_from(["unityprobe", "static", "Type.One", "Type.Two"]) + .expect_err("duplicate static arg"); + assert!( + error + .to_string() + .contains("static accepts only one type name") + ); + } + + #[test] + fn require_data_returns_runtime_error_when_payload_is_missing() { + let envelope: unitysupport::ProbeEnvelope = + unitysupport::ProbeEnvelope { + ok: true, + kind: "scenes".to_string(), + data: None, + error: None, + }; + let error = require_data(envelope, "scenes").expect_err("missing payload"); + assert!( + error + .to_string() + .contains("scenes returned an empty bridge payload") + ); + } + + #[test] + fn require_data_returns_payload_when_present() { + let expected: ScenesData = serde_json::from_value(json!({ + "scenes": [ + { + "name": "Main", + "path": "Assets/Scenes/Main.unity", + "build_index": 0, + "loaded": true, + "root_count": 10 + } + ] + })) + .expect("scenes fixture"); + let envelope = unitysupport::ProbeEnvelope { + ok: true, + kind: "scenes".to_string(), + data: Some(expected.clone()), + error: None, + }; + let data = require_data(envelope, "scenes").expect("payload"); + assert_eq!(data, expected); + } + + #[test] + fn render_status_text_includes_optional_runtime_and_problems() { + let report = StatusReport { + windows_supported: true, + game_root: Some("C:\\Game".to_string()), + plugin_dir: Some("C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe".to_string()), + dll_path: Some( + "C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\Mercury.UnityProbe.dll" + .to_string(), + ), + manifest_path: Some( + "C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\mercury-unityprobe.install.json" + .to_string(), + ), + installed: true, + game_process_running: Some(true), + game_process_id: Some(1234), + game_process_name: Some("Solar Expanse.exe".to_string()), + pipe_name: BRIDGE_PIPE_NAME.to_string(), + pipe_reachable: true, + runtime_status: Some( + serde_json::from_value(json!({ + "pipe_name": BRIDGE_PIPE_NAME, + "plugin_version": "1.0.0", + "unity_version": "2021.3.0f1", + "process_id": 1234, + "scene_count": 2, + "loaded_scene_names": ["Main", "Gameplay"] + })) + .expect("runtime fixture"), + ), + compiler_path: Some( + "C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe".to_string(), + ), + managed_dir: Some("C:\\Game\\Example_Data\\Managed".to_string()), + problems: vec!["warning one".to_string(), "warning two".to_string()], + }; + + let text = render_status_text(&report); + assert!(text.contains("status windows_supported=true installed=true")); + assert!(text.contains("game_process_running=true")); + assert!(text.contains("game_process=Solar Expanse.exe")); + assert!(text.contains("unity=2021.3.0f1 pid=1234 scenes=2")); + assert!(text.contains("problems=warning one | warning two")); + } + + #[test] + fn render_helpers_emit_expected_empty_and_populated_forms() { + assert_eq!( + render_scenes_text(&unitysupport::ScenesData { scenes: Vec::new() }), + "scenes count=0" + ); + assert_eq!( + render_find_text(&unitysupport::FindData { + query: "Manager".to_string(), + matches: Vec::new(), + }), + "find query=Manager matches=0" + ); + + let scenes: ScenesData = serde_json::from_value(json!({ + "scenes": [ + { + "name": "Main", + "path": "Assets/Scenes/Main.unity", + "build_index": 0, + "loaded": true, + "root_count": 12 + } + ] + })) + .expect("scenes fixture"); + let find = sample_find_data(); + let inspect = sample_inspect_data(); + let static_data = sample_static_data(); + + let scenes_text = render_scenes_text(&scenes); + assert!(scenes_text.contains("scene name=Main")); + + let find_text = render_find_text(&find); + assert!(find_text.contains("find query=GameManager matches=1")); + assert!(find_text.contains("object id=42")); + + let inspect_text = render_inspect_text(&inspect); + assert!(inspect_text.contains("inspect id=42")); + assert!(inspect_text.contains("component id=77")); + assert!(inspect_text.contains("field name=state")); + assert!(inspect_text.contains("property name=Enabled")); + + let static_text = render_static_text(&static_data); + assert!(static_text.contains("static type=Game.Core.Globals assembly=GameAssembly")); + assert!(static_text.contains("field name=Build")); + assert!(static_text.contains("property name=Version")); + } + + #[test] + fn rerank_find_data_prefers_type_matches_and_supports_type_only() { + let mixed: FindData = serde_json::from_value(json!({ + "query": "GameManager", + "matches": [ + { + "instance_id": 7, + "name": "GameManager", + "type_name": "UnityEngine.TextAsset", + "scene_name": "Main", + "hierarchy_path": "/Root/GameManagerAsset", + "active": true + }, + { + "instance_id": 42, + "name": "Bootstrap", + "type_name": "Game.Core.GameManager", + "scene_name": "Main", + "hierarchy_path": "/Root/Managers/GameManager", + "active": true + } + ] + })) + .expect("find fixture"); + + let reranked = rerank_find_data(mixed.clone(), 25, false); + assert_eq!(reranked.matches.len(), 2); + assert_eq!(reranked.matches[0].type_name, "Game.Core.GameManager"); + + let type_only = rerank_find_data(mixed, 25, true); + assert_eq!(type_only.matches.len(), 1); + assert_eq!(type_only.matches[0].type_name, "Game.Core.GameManager"); + } +} diff --git a/crates/unityprobe/src/lib.rs b/crates/unityprobe/src/lib.rs new file mode 100644 index 0000000..baaeea2 --- /dev/null +++ b/crates/unityprobe/src/lib.rs @@ -0,0 +1,6 @@ +//! Public entry point for the `unityprobe` command crate. +#![allow(clippy::multiple_crate_versions)] + +mod cli; + +pub use cli::main_entry; diff --git a/crates/unityprobe/src/main.rs b/crates/unityprobe/src/main.rs new file mode 100644 index 0000000..bddd028 --- /dev/null +++ b/crates/unityprobe/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `unityprobe`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(unityprobe::main_entry()); +} diff --git a/crates/unityprobe/tests/unityprobe_cli.rs b/crates/unityprobe/tests/unityprobe_cli.rs new file mode 100644 index 0000000..37bdab3 --- /dev/null +++ b/crates/unityprobe/tests/unityprobe_cli.rs @@ -0,0 +1,107 @@ +//! Integration tests for the `unityprobe` command. + +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; + +fn cargo_command() -> Command { + Command::cargo_bin("unityprobe").expect("binary") +} + +#[test] +fn no_args_prints_quick_help_card() { + let mut command = cargo_command(); + command + .assert() + .code(2) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "error: unityprobe requires a subcommand", + )) + .stderr(predicate::str::contains("unityprobe - Mercury Toolbox")) + .stderr(predicate::str::contains("Usage:")) + .stderr(predicate::str::contains( + "unityprobe [OPTIONS] [ARGS...]", + )) + .stderr(predicate::str::contains("install ")) + .stderr(predicate::str::contains( + "Type 'unityprobe --help' for the full command reference.", + )); +} + +fn unique_temp_dir(prefix: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{suffix}")); + fs::create_dir_all(&path).expect("temp dir"); + path +} + +#[test] +fn help_calls_out_windows_only_bridge_workflow() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Windows only")) + .stdout(predicate::str::contains("named pipe JSON")) + .stdout(predicate::str::contains("BepInEx\\plugins")) + .stdout(predicate::str::contains("unityprobe status --game-root")) + .stdout(predicate::str::contains("unityprobe --json find")) + .stdout(predicate::str::contains("unityprobe inspect -- -938")); +} + +#[cfg(windows)] +#[test] +fn status_reports_uninstalled_game_root_install_state_as_json() { + let game_root = unique_temp_dir("unityprobe-status"); + let mut command = cargo_command(); + let output = command + .arg("--json") + .arg("status") + .arg("--game-root") + .arg(&game_root) + .assert() + .success() + .stdout(predicate::str::contains("\"windows_supported\":true")) + .get_output() + .stdout + .clone(); + let report: Value = serde_json::from_slice(&output).expect("status json"); + assert_eq!( + report.get("game_root").and_then(Value::as_str), + Some(game_root.to_string_lossy().as_ref()) + ); + assert_eq!( + report.get("installed").and_then(Value::as_bool), + Some(false) + ); + assert!( + report + .get("pipe_reachable") + .and_then(Value::as_bool) + .is_some() + ); + assert!(report.get("runtime_status").is_some()); +} + +#[cfg(windows)] +#[test] +fn install_requires_explicit_bepinex_and_managed_layout() { + let game_root = unique_temp_dir("unityprobe-install"); + let mut command = cargo_command(); + command + .arg("install") + .arg(&game_root) + .assert() + .code(3) + .stderr(predicate::str::contains("BepInEx")) + .stderr(predicate::str::contains("Managed")); +} diff --git a/crates/unitysupport/Cargo.toml b/crates/unitysupport/Cargo.toml new file mode 100644 index 0000000..396be35 --- /dev/null +++ b/crates/unitysupport/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "unitysupport" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared Unity asset, log, and bridge helpers for Mercury Toolbox." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +regex-lite.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +unity-asset.workspace = true +unity-asset-binary.workspace = true +windowsupport = { path = "../windowsupport", default-features = false } + +[dev-dependencies] +filetime.workspace = true +tempfile.workspace = true diff --git a/crates/unitysupport/src/assets.rs b/crates/unitysupport/src/assets.rs new file mode 100644 index 0000000..fcfc3c7 --- /dev/null +++ b/crates/unitysupport/src/assets.rs @@ -0,0 +1,2242 @@ +//! Shared Unity asset indexing, reference, dump, and extraction helpers. + +use crate::UnitySupportError; +use serde::Serialize; +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use unity_asset::environment::{ + BinaryObjectKey, BinarySource, BinarySourceKind, Environment, EnvironmentObjectRef, +}; +use unity_asset::{UnityClass, UnityDocument, UnityValue}; + +/// A stable indexed Unity object summary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssetIndexRow { + /// Stable object identifier in `file#path_id` form. + pub id: String, + /// Logical format family for the object. + pub format: String, + /// Concrete source kind. + pub source_kind: String, + /// Unity class id. + pub class_id: i32, + /// Unity class name. + pub class_name: String, + /// Best-effort object name. + pub name: Option, + /// Best-effort display label with lightweight fallbacks such as script class names. + pub best_name: Option, + /// Script class name for `MonoScript` objects when available. + pub script_class: Option, + /// Script namespace for `MonoScript` objects when available. + pub script_namespace: Option, + /// Script assembly name for `MonoScript` objects when available. + pub script_assembly: Option, + /// Best-effort source label. + pub source: String, + /// Bundle asset index when the source is an `AssetBundle`. + pub asset_index: Option, + /// Serialized object byte size when available. + pub byte_size: Option, +} + +/// A resolved or unresolved reference edge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssetReferenceEntry { + /// Target or source object identifier. + pub id: String, + /// Whether the reference resolved to a loaded object. + pub resolved: bool, + /// Best-effort class name. + pub class_name: Option, + /// Best-effort object name. + pub name: Option, + /// Binary `file_id` when the reference originated from a `PPtr`. + pub file_id: Option, + /// YAML field path when available. + pub path: Option, + /// External GUID for unresolved YAML references. + pub guid: Option, +} + +/// Reference summary for one selected Unity object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssetReferences { + /// Selected object summary. + pub target: AssetIndexRow, + /// Objects the selected object references. + pub outgoing: Vec, + /// Objects that reference the selected object. + pub incoming: Vec, +} + +/// Describes how complete the dump field view is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AssetDumpFieldMode { + /// Fields came from a decoded typetree-backed object. + Typetree, + /// Fields are metadata-only or synthesized from stripped raw layouts. + MetadataOnly, +} + +/// Dump payload for one selected Unity object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssetDump { + /// Selected object summary. + pub object: AssetIndexRow, + /// Whether the field view came from a typetree or a metadata-only fallback. + pub field_mode: AssetDumpFieldMode, + /// Suggested next step when the dump had to fall back to metadata-only output. + pub next_hint: Option, + /// Serialized properties for the object. + pub properties: serde_json::Value, + /// Best-effort parse warnings for typetree-backed binary objects. + pub warnings: Vec, +} + +/// Describes how extracted payloads were derived. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtractionMode { + /// Text extracted from a `TextAsset`. + TextAsset, + /// Text synthesized from `MonoScript` metadata. + MonoScript, + /// Best-effort streamed or inline bytes for a `VideoClip`. + VideoClip, + /// Raw object bytes. + Raw, +} + +/// One extracted output payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssetExtractOutput { + /// Extraction strategy used for the payload. + pub mode: ExtractionMode, + /// Suggested output file name. + pub file_name: String, + /// Extracted bytes. + pub bytes: Vec, + /// Whether the bytes should be rendered as UTF-8 text by default. + pub is_text: bool, +} + +/// Extraction summary for one selected Unity object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssetExtractReport { + /// Selected object summary. + pub object: AssetIndexRow, + /// Extracted payloads. + pub outputs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CatalogObjectKey { + Binary(BinaryObjectKey), + Yaml { path: PathBuf, anchor: String }, +} + +#[derive(Debug, Clone)] +struct CatalogObject { + row: AssetIndexRow, + key: CatalogObjectKey, + aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EdgeScan { + source_id: String, + target_id: Option, + entry: AssetReferenceEntry, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct MonoScriptMetadata { + class_name: Option, + namespace: Option, + assembly_name: Option, +} + +impl MonoScriptMetadata { + const fn is_empty(&self) -> bool { + self.class_name.is_none() && self.namespace.is_none() && self.assembly_name.is_none() + } +} + +struct Catalog { + env: Environment, + objects: Vec, + object_index_by_id: HashMap, + direct_alias_matches: HashMap>, +} + +/// Indexes Unity YAML files, serialized assets, and bundle-contained serialized assets. +/// +/// # Errors +/// +/// Returns an error when the roots cannot be loaded or when metadata extraction fails. +pub fn index_unity_assets(roots: &[PathBuf]) -> Result, UnitySupportError> { + let catalog = Catalog::load(roots)?; + Ok(catalog.objects.into_iter().map(|item| item.row).collect()) +} + +/// Computes incoming and outgoing references for one selected Unity object. +/// +/// # Errors +/// +/// Returns an error when the roots cannot be loaded, the selector is ambiguous, or the object +/// cannot be scanned. +pub fn refs_unity_asset( + roots: &[PathBuf], + selector: &str, +) -> Result { + let catalog = Catalog::load(roots)?; + let target = catalog.resolve(selector)?; + let target_id = target.row.id.clone(); + let outgoing = catalog.scan_outgoing(target)?; + let incoming = catalog.scan_incoming(target_id.as_str())?; + + Ok(AssetReferences { + target: target.row.clone(), + outgoing, + incoming, + }) +} + +/// Dumps one Unity object's serialized fields and metadata. +/// +/// # Errors +/// +/// Returns an error when the roots cannot be loaded, the selector is ambiguous, or the object +/// cannot be parsed. +pub fn dump_unity_asset(roots: &[PathBuf], selector: &str) -> Result { + let catalog = Catalog::load(roots)?; + let object = catalog.resolve(selector)?; + + match &object.key { + CatalogObjectKey::Yaml { path, anchor } => { + let class = catalog.yaml_class(path, anchor)?; + Ok(AssetDump { + object: object.row.clone(), + field_mode: AssetDumpFieldMode::Typetree, + next_hint: None, + properties: unity_value_to_json(&UnityValue::Object( + class.serialized_properties(), + ))?, + warnings: Vec::new(), + }) + } + CatalogObjectKey::Binary(key) => { + let parsed = catalog + .env + .read_binary_object_key(key) + .map_err(|error| UnitySupportError::message(error.to_string()))?; + let mut properties = unity_value_to_json(&UnityValue::Object( + parsed.as_unity_class().serialized_properties(), + ))?; + let field_mode = binary_dump_field_mode(&properties); + augment_binary_dump_properties( + &catalog, + object, + key, + parsed.raw_data(), + &mut properties, + ); + Ok(AssetDump { + object: object.row.clone(), + field_mode, + next_hint: dump_next_hint(selector, field_mode), + properties, + warnings: parsed + .typetree_warnings() + .iter() + .map(|warning| format!("{}: {}", warning.field, warning.error)) + .collect(), + }) + } + } +} + +fn dump_next_hint(selector: &str, field_mode: AssetDumpFieldMode) -> Option { + if field_mode == AssetDumpFieldMode::MetadataOnly { + return Some(format!( + "metadata-only dump; try `unityasset refs {selector}` for clearer links, rerun with `--json` for full raw bytes, or pass `--type-tree-db ` for stripped assets" + )); + } + None +} + +/// Extracts one Unity object into text or bytes. +/// +/// # Errors +/// +/// Returns an error when the roots cannot be loaded, the selector is ambiguous, or extraction +/// fails for the selected object. +pub fn extract_unity_asset( + roots: &[PathBuf], + selector: &str, +) -> Result { + let catalog = Catalog::load(roots)?; + let object = catalog.resolve(selector)?; + + let outputs = match &object.key { + CatalogObjectKey::Yaml { path, anchor } => { + let class = catalog.yaml_class(path, anchor)?; + extract_from_yaml_class(&object.row, class) + } + CatalogObjectKey::Binary(key) => extract_from_binary_key(&catalog.env, &object.row, key)?, + }; + + Ok(AssetExtractReport { + object: object.row.clone(), + outputs, + }) +} + +fn binary_dump_field_mode(properties: &serde_json::Value) -> AssetDumpFieldMode { + let Some(object) = properties.as_object() else { + return AssetDumpFieldMode::Typetree; + }; + if object.keys().all(|key| key.starts_with("_raw_")) { + AssetDumpFieldMode::MetadataOnly + } else { + AssetDumpFieldMode::Typetree + } +} + +fn augment_binary_dump_properties( + catalog: &Catalog, + object: &CatalogObject, + key: &BinaryObjectKey, + raw_data: &[u8], + properties: &mut serde_json::Value, +) { + let Some(map) = properties.as_object_mut() else { + return; + }; + + if let Some(name) = display_name(&object.row) { + map.entry("m_Name".to_string()) + .or_insert_with(|| serde_json::Value::String(name.to_string())); + } + + if object.row.class_name == "MonoBehaviour" { + if !map.contains_key("m_GameObject") + && let Some((file_id, path_id)) = mono_behaviour_game_object_pointer_from_raw(raw_data) + { + map.insert( + "m_GameObject".to_string(), + raw_reference_json(catalog, key, file_id, path_id), + ); + } + if !map.contains_key("m_Script") + && let Some((file_id, path_id)) = mono_behaviour_script_pointer_from_raw(raw_data) + { + map.insert( + "m_Script".to_string(), + raw_reference_json(catalog, key, file_id, path_id), + ); + } + } + + let heuristic_refs = raw_resolved_reference_json(catalog, object, key, raw_data); + if !heuristic_refs.is_empty() { + map.insert( + "_raw_resolved_references".to_string(), + serde_json::Value::Array(heuristic_refs), + ); + } +} + +fn raw_reference_json( + catalog: &Catalog, + key: &BinaryObjectKey, + file_id: i32, + path_id: i64, +) -> serde_json::Value { + let mut object = serde_json::Map::new(); + object.insert("file_id".to_string(), serde_json::Value::from(file_id)); + object.insert("path_id".to_string(), serde_json::Value::from(path_id)); + + if let Some(source) = binary_object_source(&catalog.env, key) + && let Some(target_key) = catalog.env.resolve_binary_pptr(&source, file_id, path_id) + && let Some(target) = catalog.object_for_binary_key(&target_key) + { + object.insert( + "resolved_id".to_string(), + serde_json::Value::String(target.row.id.clone()), + ); + object.insert( + "resolved_class_name".to_string(), + serde_json::Value::String(target.row.class_name.clone()), + ); + if let Some(name) = display_name(&target.row) { + object.insert( + "resolved_name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + } + + serde_json::Value::Object(object) +} + +fn raw_resolved_reference_json( + catalog: &Catalog, + object: &CatalogObject, + key: &BinaryObjectKey, + raw_data: &[u8], +) -> Vec { + let Some(source) = binary_object_source(&catalog.env, key) else { + return Vec::new(); + }; + let mut references = Vec::new(); + let mut seen = BTreeSet::::new(); + + for edge in raw_fallback_binary_edges(catalog, object, &source, raw_data) { + let dedupe_key = format!( + "{}:{}", + edge.entry.id, + edge.entry.path.as_deref().unwrap_or_default() + ); + if !seen.insert(dedupe_key) { + continue; + } + + let mut value = serde_json::Map::new(); + value.insert( + "id".to_string(), + serde_json::Value::String(edge.entry.id.clone()), + ); + value.insert( + "resolved".to_string(), + serde_json::Value::Bool(edge.entry.resolved), + ); + if let Some(path) = edge.entry.path { + value.insert("path".to_string(), serde_json::Value::String(path)); + } + if let Some(class_name) = edge.entry.class_name { + value.insert( + "class_name".to_string(), + serde_json::Value::String(class_name), + ); + } + if let Some(name) = edge.entry.name { + value.insert("name".to_string(), serde_json::Value::String(name)); + } + if let Some(file_id) = edge.entry.file_id { + value.insert("file_id".to_string(), serde_json::Value::from(file_id)); + } + references.push(serde_json::Value::Object(value)); + } + + references +} + +impl Catalog { + fn load(roots: &[PathBuf]) -> Result { + let roots = if roots.is_empty() { + vec![PathBuf::from(".")] + } else { + roots.to_vec() + }; + + let mut env = Environment::new(); + for root in &roots { + env.load(root) + .map_err(|error| UnitySupportError::message(error.to_string()))?; + } + + let mut objects = Self::yaml_objects(&env)?; + let binary_rows = Self::binary_objects(&env); + objects.extend(binary_rows); + objects.sort_unstable_by(|left, right| left.row.id.cmp(&right.row.id)); + + let mut object_index_by_id = HashMap::with_capacity(objects.len()); + let mut direct_alias_matches = HashMap::>::new(); + for (index, object) in objects.iter().enumerate() { + object_index_by_id.insert(object.row.id.clone(), index); + for alias in &object.aliases { + let matches = direct_alias_matches.entry(alias.clone()).or_default(); + if matches.last() != Some(&index) { + matches.push(index); + } + } + } + + Ok(Self { + env, + objects, + object_index_by_id, + direct_alias_matches, + }) + } + + fn yaml_objects(env: &Environment) -> Result, UnitySupportError> { + let mut objects = Vec::new(); + let mut yaml_paths = env.yaml_documents().keys().cloned().collect::>(); + yaml_paths.sort(); + for path in yaml_paths { + let document = env + .yaml_documents() + .get(&path) + .ok_or_else(|| UnitySupportError::message("yaml document disappeared"))?; + for class in document.entries() { + objects.push(Self::yaml_catalog_object(&path, class)); + } + } + Ok(objects) + } + + fn yaml_catalog_object(path: &Path, class: &UnityClass) -> CatalogObject { + let script_metadata = mono_script_metadata(class); + let source_label = compact_path_label(path); + let id = format!("{source_label}#{}", class.anchor); + let aliases = vec![ + id.clone(), + format!("{}#{}", path.display(), class.anchor), + format!("{}#{}", source_label, class.anchor), + ]; + CatalogObject { + row: AssetIndexRow { + id, + format: "yaml".to_string(), + source_kind: "yaml".to_string(), + class_id: class.class_id, + class_name: class.class_name.clone(), + name: unity_class_name(class), + best_name: unity_class_name(class), + script_class: script_metadata.class_name, + script_namespace: script_metadata.namespace, + script_assembly: script_metadata.assembly_name, + source: source_label, + asset_index: None, + byte_size: None, + }, + aliases, + key: CatalogObjectKey::Yaml { + path: path.to_path_buf(), + anchor: class.anchor.clone(), + }, + } + } + + fn binary_objects(env: &Environment) -> Vec { + env.objects() + .filter_map(|item| match item { + EnvironmentObjectRef::Binary(binary) => Some(binary), + EnvironmentObjectRef::Yaml(_) => None, + }) + .map(|binary| Self::binary_catalog_object(env, &binary)) + .collect() + } + + fn binary_catalog_object( + env: &Environment, + binary: &unity_asset::environment::BinaryObjectRef<'_>, + ) -> CatalogObject { + let key = binary.key(); + let source_label = binary_source_label(env, &key); + let id = format!("{source_label}#{}", key.path_id); + let mut name = env.peek_binary_object_name(&key).ok().flatten(); + let class_id = binary.object.class_id(); + let class_name = resolve_unity_class_name(class_id); + let script_metadata = if matches!(class_name.as_str(), "MonoScript" | "MonoBehaviour") { + let script_metadata = binary_script_metadata(env, &key, &class_name); + if name.is_none() { + name.clone_from(&script_metadata.class_name); + } + script_metadata + } else { + MonoScriptMetadata::default() + }; + let best_name = derive_best_name( + name.as_deref(), + script_metadata.class_name.as_deref(), + script_metadata.namespace.as_deref(), + ); + + CatalogObject { + row: AssetIndexRow { + id, + format: "serialized".to_string(), + source_kind: match key.source_kind { + BinarySourceKind::SerializedFile => "serialized_file".to_string(), + BinarySourceKind::AssetBundle => "asset_bundle".to_string(), + }, + class_id, + class_name, + name, + best_name, + script_class: script_metadata.class_name, + script_namespace: script_metadata.namespace, + script_assembly: script_metadata.assembly_name, + source: source_label.clone(), + asset_index: key.asset_index, + byte_size: Some(binary.object.byte_size()), + }, + aliases: binary_aliases(&key, &source_label), + key: CatalogObjectKey::Binary(key), + } + } + + fn resolve(&self, selector: &str) -> Result<&CatalogObject, UnitySupportError> { + if let Some(matches) = self.direct_alias_matches.get(selector) { + if matches.len() == 1 { + return Ok(&self.objects[matches[0]]); + } + return Err(UnitySupportError::message(format!( + "selector matched multiple objects: {selector}" + ))); + } + + let (selector_file, selector_path_id) = split_selector(selector)?; + let suffix_matches = self + .objects + .iter() + .filter(|item| { + item.row + .id + .ends_with(&format!("{selector_file}#{selector_path_id}")) + || item.aliases.iter().any(|alias| { + alias.ends_with(&format!("{selector_file}#{selector_path_id}")) + }) + }) + .collect::>(); + if suffix_matches.len() == 1 { + Ok(suffix_matches[0]) + } else if suffix_matches.is_empty() { + Err(UnitySupportError::message(format!( + "object not found: {selector}" + ))) + } else { + Err(UnitySupportError::message(format!( + "selector matched multiple objects: {selector}" + ))) + } + } + + fn yaml_class<'a>( + &'a self, + path: &Path, + anchor: &str, + ) -> Result<&'a UnityClass, UnitySupportError> { + let document = self.env.yaml_documents().get(path).ok_or_else(|| { + UnitySupportError::message(format!("yaml source not loaded: {}", path.display())) + })?; + document + .entries() + .iter() + .find(|class| class.anchor == anchor) + .ok_or_else(|| { + UnitySupportError::message(format!( + "yaml object not found: {}#{anchor}", + path.display() + )) + }) + } + + fn scan_outgoing( + &self, + object: &CatalogObject, + ) -> Result, UnitySupportError> { + let mut edges = self + .scan_object_edges(object)? + .into_iter() + .map(|edge| edge.entry) + .collect::>(); + sort_asset_reference_entries(&mut edges); + Ok(edges) + } + + fn scan_incoming( + &self, + target_id: &str, + ) -> Result, UnitySupportError> { + let mut incoming = Vec::new(); + for object in &self.objects { + for edge in self.scan_object_edges(object)? { + if edge.target_id.as_deref() != Some(target_id) { + continue; + } + let source = self.object_by_id(&edge.source_id); + incoming.push(AssetReferenceEntry { + id: edge.source_id, + resolved: true, + class_name: source.map(|item| item.row.class_name.clone()), + name: source.and_then(|item| display_name(&item.row).map(ToOwned::to_owned)), + file_id: edge.entry.file_id, + path: edge.entry.path, + guid: edge.entry.guid, + }); + } + } + sort_asset_reference_entries(&mut incoming); + Ok(incoming) + } + + fn scan_object_edges( + &self, + object: &CatalogObject, + ) -> Result, UnitySupportError> { + match &object.key { + CatalogObjectKey::Yaml { path, anchor } => { + let class = self.yaml_class(path, anchor)?; + let mut edges = Vec::new(); + scan_yaml_edges( + &object.row.id, + &compact_path_label(path), + &UnityValue::Object(class.serialized_properties()), + String::new(), + &mut edges, + ); + Ok(edges) + } + CatalogObjectKey::Binary(key) => { + let source = self + .env + .find_binary_object_in_source_id(&key.source, key.path_id) + .or_else(|| { + key.asset_index.and_then(|asset_index| { + self.env.find_binary_object_in_bundle_asset_source( + &key.source, + asset_index, + key.path_id, + ) + }) + }) + .ok_or_else(|| { + UnitySupportError::message(format!( + "binary object not found for {}", + object.row.id + )) + })?; + let scanned = source + .object + .scan_pptrs() + .map_err(|error| UnitySupportError::message(error.to_string()))?; + let mut edges = Vec::new(); + if let Some(scanned) = scanned { + for path_id in scanned.internal { + let resolved = self + .env + .resolve_binary_pptr(&source, 0, path_id) + .map(|target_key| self.object_for_binary_key(&target_key)); + edges.push(edge_from_binary( + &object.row.id, + 0, + path_id, + resolved.flatten(), + None, + )); + } + for (file_id, path_id) in scanned.external { + let resolved = self + .env + .resolve_binary_pptr(&source, file_id, path_id) + .map(|target_key| self.object_for_binary_key(&target_key)); + edges.push(edge_from_binary( + &object.row.id, + file_id, + path_id, + resolved.flatten(), + None, + )); + } + } + if edges.is_empty() + && let Ok(raw_data) = source.object.raw_data() + { + edges.extend(raw_fallback_binary_edges(self, object, &source, raw_data)); + } + Ok(edges) + } + } + } + + fn object_for_binary_key(&self, key: &BinaryObjectKey) -> Option<&CatalogObject> { + let id = binary_object_id(&self.env, key); + self.object_by_id(&id) + } + + fn object_by_id(&self, id: &str) -> Option<&CatalogObject> { + self.object_index_by_id + .get(id) + .map(|index| &self.objects[*index]) + } +} + +fn raw_fallback_binary_edges( + catalog: &Catalog, + object: &CatalogObject, + source: &unity_asset::environment::BinaryObjectRef<'_>, + raw_data: &[u8], +) -> Vec { + let mut edges = Vec::new(); + let mut seen = BTreeSet::::new(); + + let mut push_edge = |path: String, file_id: i32, path_id: i64, require_resolved: bool| { + let resolved = catalog + .env + .resolve_binary_pptr(source, file_id, path_id) + .and_then(|target_key| catalog.object_for_binary_key(&target_key)); + if require_resolved && resolved.is_none() { + return; + } + let edge = edge_from_binary( + &object.row.id, + file_id, + path_id, + resolved, + Some(path.clone()), + ); + let dedupe_key = format!("{}:{path}", edge.entry.id); + if seen.insert(dedupe_key) { + edges.push(edge); + } + }; + + if object.row.class_name == "MonoBehaviour" { + if let Some((file_id, path_id)) = mono_behaviour_game_object_pointer_from_raw(raw_data) { + push_edge("m_GameObject".to_string(), file_id, path_id, false); + } + if let Some((file_id, path_id)) = mono_behaviour_script_pointer_from_raw(raw_data) { + push_edge("m_Script".to_string(), file_id, path_id, false); + } + } + + for (offset, file_id, path_id) in probable_raw_pptr_windows(raw_data) { + if matches!(offset, 0 | 16) { + continue; + } + push_edge(format!("_raw_pptr@0x{offset:04x}"), file_id, path_id, true); + } + + edges +} + +fn split_selector(selector: &str) -> Result<(&str, i64), UnitySupportError> { + let (file, path_id) = selector + .rsplit_once('#') + .ok_or_else(|| UnitySupportError::message("selector must use the form file#path_id"))?; + let parsed = path_id.parse::().map_err(|error| { + UnitySupportError::message(format!("invalid selector path id '{path_id}': {error}")) + })?; + Ok((file, parsed)) +} + +fn unity_class_name(class: &UnityClass) -> Option { + class + .get("m_Name") + .and_then(UnityValue::as_str) + .map(ToOwned::to_owned) +} + +fn mono_script_metadata(class: &UnityClass) -> MonoScriptMetadata { + if class.class_name != "MonoScript" { + return MonoScriptMetadata::default(); + } + + MonoScriptMetadata { + class_name: class + .get("m_ClassName") + .and_then(UnityValue::as_str) + .map(ToOwned::to_owned), + namespace: class + .get("m_Namespace") + .and_then(UnityValue::as_str) + .map(ToOwned::to_owned), + assembly_name: class + .get("m_AssemblyName") + .and_then(UnityValue::as_str) + .map(ToOwned::to_owned), + } +} + +fn binary_script_metadata( + env: &Environment, + key: &BinaryObjectKey, + class_name: &str, +) -> MonoScriptMetadata { + if class_name == "MonoScript" { + return env.read_binary_object_key(key).ok().map_or_else( + MonoScriptMetadata::default, + |parsed| { + let from_properties = mono_script_metadata(parsed.as_unity_class()); + if from_properties.is_empty() { + mono_script_metadata_from_raw(parsed.raw_data()) + } else { + from_properties + } + }, + ); + } + + if class_name != "MonoBehaviour" { + return MonoScriptMetadata::default(); + } + + let Ok(parsed) = env.read_binary_object_key(key) else { + return MonoScriptMetadata::default(); + }; + let properties = UnityValue::Object(parsed.as_unity_class().serialized_properties()); + let Some((file_id, path_id)) = script_pointer_from_properties(&properties) + .or_else(|| mono_behaviour_script_pointer_from_raw(parsed.raw_data())) + else { + return MonoScriptMetadata::default(); + }; + let Some(source) = binary_object_source(env, key) else { + return MonoScriptMetadata::default(); + }; + let Some(script_key) = env.resolve_binary_pptr(&source, file_id, path_id) else { + return MonoScriptMetadata::default(); + }; + binary_script_metadata(env, &script_key, "MonoScript") +} + +fn mono_script_metadata_from_raw(raw_data: &[u8]) -> MonoScriptMetadata { + let blocks = aligned_utf8_blocks(raw_data); + if blocks.len() >= 3 { + let start = blocks.len() - 3; + return MonoScriptMetadata { + class_name: Some(blocks[start].clone()), + namespace: Some(blocks[start + 1].clone()), + assembly_name: Some(blocks[start + 2].clone()), + }; + } + if blocks.len() == 2 { + return MonoScriptMetadata { + class_name: Some(blocks[0].clone()), + namespace: None, + assembly_name: Some(blocks[1].clone()), + }; + } + MonoScriptMetadata::default() +} + +fn binary_object_source<'a>( + env: &'a Environment, + key: &BinaryObjectKey, +) -> Option> { + env.find_binary_object_in_source_id(&key.source, key.path_id) + .or_else(|| { + key.asset_index.and_then(|asset_index| { + env.find_binary_object_in_bundle_asset_source(&key.source, asset_index, key.path_id) + }) + }) +} + +fn script_pointer_from_properties(properties: &UnityValue) -> Option<(i32, i64)> { + let object = properties.as_object()?; + let script = object.get("m_Script")?.as_object()?; + let file_id = script + .get("m_FileID") + .or_else(|| script.get("fileID")) + .and_then(UnityValue::as_i64) + .unwrap_or(0); + let path_id = script + .get("m_PathID") + .or_else(|| script.get("pathID")) + .and_then(UnityValue::as_i64)?; + Some((i32::try_from(file_id).ok()?, path_id)) +} + +fn mono_behaviour_game_object_pointer_from_raw(raw_data: &[u8]) -> Option<(i32, i64)> { + let file_id = i32::from_le_bytes(raw_data.get(0..4)?.try_into().ok()?); + let path_id = i64::from_le_bytes(raw_data.get(4..12)?.try_into().ok()?); + (path_id != 0).then_some((file_id, path_id)) +} + +fn mono_behaviour_script_pointer_from_raw(raw_data: &[u8]) -> Option<(i32, i64)> { + let file_id = i32::from_le_bytes(raw_data.get(16..20)?.try_into().ok()?); + let path_id = i64::from_le_bytes(raw_data.get(20..28)?.try_into().ok()?); + (path_id != 0).then_some((file_id, path_id)) +} + +fn probable_raw_pptr_windows(raw_data: &[u8]) -> Vec<(usize, i32, i64)> { + let mut windows = Vec::new(); + let Some(limit) = raw_data.len().checked_sub(12) else { + return windows; + }; + + for offset in (0..=limit).step_by(4) { + let Some(file_id_bytes) = raw_data.get(offset..offset + 4) else { + continue; + }; + let Some(path_id_bytes) = raw_data.get(offset + 4..offset + 12) else { + continue; + }; + let file_id = i32::from_le_bytes(file_id_bytes.try_into().ok().unwrap_or([0; 4])); + let path_id = i64::from_le_bytes(path_id_bytes.try_into().ok().unwrap_or([0; 8])); + if (0..=16).contains(&file_id) && path_id != 0 { + windows.push((offset, file_id, path_id)); + } + } + + windows +} + +fn aligned_utf8_blocks(raw_data: &[u8]) -> Vec { + let mut blocks = Vec::new(); + let mut cursor = 0_usize; + while cursor + .checked_add(4) + .is_some_and(|value| value <= raw_data.len()) + { + if let Some((text, next_cursor)) = read_aligned_utf8_block(raw_data, cursor) { + if !text.is_empty() { + blocks.push(text); + } + if next_cursor <= cursor { + break; + } + cursor = next_cursor; + continue; + } + cursor = align_to_4(cursor.saturating_add(4)); + } + blocks +} + +fn compact_path_label(path: &Path) -> String { + path.file_name().map_or_else( + || path.display().to_string(), + |name| name.to_string_lossy().to_string(), + ) +} + +fn binary_source_label(env: &Environment, key: &BinaryObjectKey) -> String { + let source_label = match &key.source { + BinarySource::Path(path) => compact_path_label(path), + BinarySource::WebEntry { + web_path, + entry_name, + } => format!("{}::{}", compact_path_label(web_path), entry_name), + }; + + match key.source_kind { + BinarySourceKind::SerializedFile => source_label, + BinarySourceKind::AssetBundle => { + let asset_name = key + .asset_index + .and_then(|asset_index| { + env.bundles() + .get(&key.source) + .and_then(|bundle| bundle.asset_names.get(asset_index)) + .cloned() + }) + .unwrap_or_else(|| format!("asset[{}]", key.asset_index.unwrap_or_default())); + format!("{source_label}::{asset_name}") + } + } +} + +fn resolve_unity_class_name(class_id: i32) -> String { + unity_asset::get_class_name(class_id) + .or_else(|| best_known_unity_class_name(class_id).map(str::to_string)) + .unwrap_or_else(|| format!("Class_{class_id}")) +} + +const fn best_known_unity_class_name(class_id: i32) -> Option<&'static str> { + match class_id { + 222 => Some("CanvasRenderer"), + 223 => Some("Canvas"), + 224 => Some("RectTransform"), + 225 => Some("CanvasGroup"), + _ => None, + } +} + +fn binary_object_id(env: &Environment, key: &BinaryObjectKey) -> String { + format!("{}#{}", binary_source_label(env, key), key.path_id) +} + +fn binary_aliases(key: &BinaryObjectKey, source_label: &str) -> Vec { + let mut aliases = vec![format!("{source_label}#{}", key.path_id)]; + let full_source = match &key.source { + BinarySource::Path(path) => path.display().to_string(), + BinarySource::WebEntry { + web_path, + entry_name, + } => format!("{}::{}", web_path.display(), entry_name), + }; + aliases.push(format!("{full_source}#{}", key.path_id)); + aliases.sort(); + aliases.dedup(); + aliases +} + +fn edge_from_binary( + source_id: &str, + file_id: i32, + path_id: i64, + target: Option<&CatalogObject>, + path: Option, +) -> EdgeScan { + if let Some(target) = target { + EdgeScan { + source_id: source_id.to_string(), + target_id: Some(target.row.id.clone()), + entry: AssetReferenceEntry { + id: target.row.id.clone(), + resolved: true, + class_name: Some(target.row.class_name.clone()), + name: display_name(&target.row).map(ToOwned::to_owned), + file_id: Some(file_id), + path, + guid: None, + }, + } + } else { + let unresolved_id = format!("external:{file_id}#{path_id}"); + EdgeScan { + source_id: source_id.to_string(), + target_id: None, + entry: AssetReferenceEntry { + id: unresolved_id, + resolved: false, + class_name: None, + name: None, + file_id: Some(file_id), + path, + guid: None, + }, + } + } +} + +fn sort_asset_reference_entries(entries: &mut [AssetReferenceEntry]) { + entries.sort_unstable_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.file_id.cmp(&right.file_id)) + .then_with(|| left.guid.cmp(&right.guid)) + .then_with(|| left.class_name.cmp(&right.class_name)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.resolved.cmp(&right.resolved)) + }); +} + +fn scan_yaml_edges( + source_id: &str, + file_label: &str, + value: &UnityValue, + path: String, + edges: &mut Vec, +) { + match value { + UnityValue::Array(items) => { + for (index, item) in items.iter().enumerate() { + let next_path = if path.is_empty() { + format!("[{index}]") + } else { + format!("{path}[{index}]") + }; + scan_yaml_edges(source_id, file_label, item, next_path, edges); + } + } + UnityValue::Object(map) => { + let file_id = map + .get("fileID") + .or_else(|| map.get("m_FileID")) + .and_then(UnityValue::as_i64); + let guid = map.get("guid").and_then(UnityValue::as_str); + if let Some(file_id) = file_id { + if let Some(guid) = guid { + edges.push(EdgeScan { + source_id: source_id.to_string(), + target_id: None, + entry: AssetReferenceEntry { + id: format!("guid:{guid}#{file_id}"), + resolved: false, + class_name: None, + name: None, + file_id: i32::try_from(file_id).ok(), + path: (!path.is_empty()).then_some(path), + guid: Some(guid.to_string()), + }, + }); + return; + } + if file_id != 0 { + let target_id = format!("{file_label}#{file_id}"); + edges.push(EdgeScan { + source_id: source_id.to_string(), + target_id: Some(target_id.clone()), + entry: AssetReferenceEntry { + id: target_id, + resolved: true, + class_name: None, + name: None, + file_id: i32::try_from(file_id).ok(), + path: (!path.is_empty()).then_some(path), + guid: None, + }, + }); + return; + } + } + + for (key, child) in map { + let next_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + scan_yaml_edges(source_id, file_label, child, next_path, edges); + } + } + UnityValue::Null + | UnityValue::Bool(_) + | UnityValue::Integer(_) + | UnityValue::Float(_) + | UnityValue::String(_) + | UnityValue::Bytes(_) => {} + } +} + +fn unity_value_to_json(value: &UnityValue) -> Result { + serde_json::to_value(value).map_err(|error| UnitySupportError::message(error.to_string())) +} + +fn extract_from_yaml_class(row: &AssetIndexRow, class: &UnityClass) -> Vec { + let properties = UnityValue::Object(class.serialized_properties()); + extract_from_properties(row, &properties, None, None, None) +} + +fn extract_from_binary_key( + env: &Environment, + row: &AssetIndexRow, + key: &BinaryObjectKey, +) -> Result, UnitySupportError> { + let parsed = env + .read_binary_object_key(key) + .map_err(|error| UnitySupportError::message(error.to_string()))?; + let properties = UnityValue::Object(parsed.as_unity_class().serialized_properties()); + let context = Some((&key.source, key.source_kind)); + Ok(extract_from_properties( + row, + &properties, + Some(parsed.raw_data()), + context, + Some(env), + )) +} + +fn extract_from_properties( + row: &AssetIndexRow, + properties: &UnityValue, + raw_data: Option<&[u8]>, + context: Option<(&BinarySource, BinarySourceKind)>, + env: Option<&Environment>, +) -> Vec { + let mut outputs = Vec::new(); + let base_name = default_base_name(row); + let Some(properties) = properties.as_object() else { + return outputs; + }; + + if row.class_name == "TextAsset" { + if let Some(text) = properties.get("m_Script").and_then(UnityValue::as_str) { + outputs.push(AssetExtractOutput { + mode: ExtractionMode::TextAsset, + file_name: format!("{base_name}.txt"), + bytes: text.as_bytes().to_vec(), + is_text: true, + }); + return outputs; + } + if let Some(bytes) = properties.get("m_Script").and_then(UnityValue::as_bytes) { + outputs.push(AssetExtractOutput { + mode: ExtractionMode::TextAsset, + file_name: format!("{base_name}.txt"), + bytes: bytes.to_vec(), + is_text: std::str::from_utf8(bytes).is_ok(), + }); + return outputs; + } + if let Some(text) = raw_data.and_then(text_asset_text_from_raw) { + outputs.push(AssetExtractOutput { + mode: ExtractionMode::TextAsset, + file_name: format!("{base_name}.txt"), + bytes: text.into_bytes(), + is_text: true, + }); + return outputs; + } + } + + if row.class_name == "MonoScript" { + let class_name = properties + .get("m_ClassName") + .and_then(UnityValue::as_str) + .unwrap_or("Script"); + let namespace = properties + .get("m_Namespace") + .and_then(UnityValue::as_str) + .unwrap_or_default(); + let assembly_name = properties + .get("m_AssemblyName") + .and_then(UnityValue::as_str) + .unwrap_or_default(); + let mut script_text = String::new(); + if namespace.is_empty() { + let _ = writeln!(script_text, "public class {class_name} {{ }}"); + } else { + let _ = writeln!(script_text, "namespace {namespace}"); + let _ = writeln!(script_text, "{{"); + let _ = writeln!(script_text, " public class {class_name} {{ }}"); + let _ = writeln!(script_text, "}}"); + } + if !assembly_name.is_empty() { + let _ = writeln!(script_text, "// Assembly: {assembly_name}"); + } + outputs.push(AssetExtractOutput { + mode: ExtractionMode::MonoScript, + file_name: format!("{base_name}.cs"), + bytes: script_text.into_bytes(), + is_text: true, + }); + return outputs; + } + + if row.class_name == "VideoClip" + && let Some((stream_path, offset, size)) = + read_stream_spec(&UnityValue::Object(properties.clone())) + && let Some((source, source_kind)) = context + && let Some(env) = env + { + let bytes = env + .read_stream_data_source(source, source_kind, &stream_path, offset, size) + .unwrap_or_default(); + if !bytes.is_empty() { + outputs.push(AssetExtractOutput { + mode: ExtractionMode::VideoClip, + file_name: format!("{base_name}.bin"), + bytes, + is_text: false, + }); + return outputs; + } + } + + outputs.push(AssetExtractOutput { + mode: ExtractionMode::Raw, + file_name: format!("{base_name}.bin"), + bytes: raw_data.unwrap_or_default().to_vec(), + is_text: false, + }); + outputs +} + +fn read_stream_spec(properties: &UnityValue) -> Option<(String, u64, u32)> { + let object = properties.as_object()?; + let stream = object + .get("m_ExternalResources") + .or_else(|| object.get("m_StreamData")) + .and_then(UnityValue::as_object)?; + let source = stream + .get("m_Source") + .or_else(|| stream.get("source")) + .and_then(UnityValue::as_str)? + .to_string(); + let offset = stream + .get("m_Offset") + .or_else(|| stream.get("offset")) + .and_then(UnityValue::as_i64)?; + let size = stream + .get("m_Size") + .or_else(|| stream.get("size")) + .and_then(UnityValue::as_i64)?; + Some(( + source, + u64::try_from(offset).ok()?, + u32::try_from(size).ok()?, + )) +} + +fn text_asset_text_from_raw(raw_data: &[u8]) -> Option { + if raw_data.len() < 4 { + return std::str::from_utf8(raw_data).ok().map(ToOwned::to_owned); + } + let mut best = None::<(String, usize)>; + let mut cursor = 0_usize; + while let Some((text, next_cursor)) = read_aligned_utf8_block(raw_data, cursor) { + if best.as_ref().is_none_or(|(current, current_offset)| { + text.len() > current.len() || (text.len() == current.len() && cursor > *current_offset) + }) { + best = Some((text, cursor)); + } + if next_cursor <= cursor { + break; + } + cursor = next_cursor; + } + best.map(|(text, _)| text) + .or_else(|| std::str::from_utf8(raw_data).ok().map(ToOwned::to_owned)) +} + +fn read_aligned_utf8_block(raw_data: &[u8], cursor: usize) -> Option<(String, usize)> { + let header_end = cursor.checked_add(4)?; + let length_bytes: [u8; 4] = raw_data.get(cursor..header_end)?.try_into().ok()?; + let declared_len = usize::try_from(u32::from_le_bytes(length_bytes)).ok()?; + let body_end = header_end.checked_add(declared_len)?; + let bytes = raw_data.get(header_end..body_end)?; + let text = std::str::from_utf8(bytes).ok()?.to_owned(); + Some((text, align_to_4(body_end))) +} + +const fn align_to_4(offset: usize) -> usize { + let remainder = offset % 4; + if remainder == 0 { + offset + } else { + offset + (4 - remainder) + } +} + +fn default_base_name(row: &AssetIndexRow) -> String { + display_name(row) + .filter(|name| !name.trim().is_empty()) + .map_or_else( + || sanitize_file_name(&row.id.replace('#', "-")), + sanitize_file_name, + ) +} + +fn derive_best_name( + name: Option<&str>, + script_class: Option<&str>, + script_namespace: Option<&str>, +) -> Option { + if let Some(name) = name.filter(|value| !value.trim().is_empty()) { + return Some(name.to_string()); + } + if let Some(script_class) = script_class.filter(|value| !value.trim().is_empty()) { + if let Some(script_namespace) = script_namespace.filter(|value| !value.trim().is_empty()) { + return Some(format!("{script_namespace}.{script_class}")); + } + return Some(script_class.to_string()); + } + None +} + +fn display_name(row: &AssetIndexRow) -> Option<&str> { + row.best_name + .as_deref() + .or(row.name.as_deref()) + .filter(|value| !value.trim().is_empty()) +} + +fn sanitize_file_name(name: &str) -> String { + let sanitized = name + .chars() + .map(|character| match character { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + _ => character, + }) + .collect::(); + if sanitized.is_empty() { + "unityasset".to_string() + } else { + sanitized + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + const ALIGN_BYTES_FLAG: i32 = 0x4000; + + #[derive(Clone, Copy)] + struct PPtrValue { + file_id: i32, + path_id: i64, + } + + #[derive(Clone)] + enum FixtureObject { + GameObject { + name: &'static str, + }, + MonoScript { + name: &'static str, + class_name: &'static str, + namespace: &'static str, + assembly_name: &'static str, + }, + StrippedMonoBehaviour { + game_object: PPtrValue, + script: PPtrValue, + extra_refs: Vec, + }, + } + + #[derive(Clone)] + struct FixtureEntry { + path_id: i64, + class_id: i32, + object: FixtureObject, + } + + fn sample_row(class_name: &str, name: Option<&str>) -> AssetIndexRow { + AssetIndexRow { + id: "sample.assets#1".to_string(), + format: "serialized".to_string(), + source_kind: "serialized_file".to_string(), + class_id: 1, + class_name: class_name.to_string(), + name: name.map(ToOwned::to_owned), + best_name: name.map(ToOwned::to_owned), + script_class: None, + script_namespace: None, + script_assembly: None, + source: "sample.assets".to_string(), + asset_index: None, + byte_size: Some(8), + } + } + + fn to_i32(value: usize) -> i32 { + i32::try_from(value).expect("fixture value fits in i32") + } + + fn to_u32(value: usize) -> u32 { + u32::try_from(value).expect("fixture value fits in u32") + } + + fn write_stripped_fixture_workspace(root: &Path) { + let entries = vec![ + FixtureEntry { + path_id: 29, + class_id: 1, + object: FixtureObject::GameObject { + name: "StrippedWindow", + }, + }, + FixtureEntry { + path_id: 30, + class_id: 1, + object: FixtureObject::GameObject { + name: "LinkedChild", + }, + }, + FixtureEntry { + path_id: 1182, + class_id: 115, + object: FixtureObject::MonoScript { + name: "StrippedWindowBehaviour", + class_name: "StrippedWindowBehaviour", + namespace: "Mercury.Stripped", + assembly_name: "Assembly-CSharp", + }, + }, + FixtureEntry { + path_id: 2000, + class_id: 114, + object: FixtureObject::StrippedMonoBehaviour { + game_object: PPtrValue { + file_id: 0, + path_id: 29, + }, + script: PPtrValue { + file_id: 0, + path_id: 1182, + }, + extra_refs: vec![PPtrValue { + file_id: 0, + path_id: 30, + }], + }, + }, + ]; + fs::write( + root.join("stripped.assets"), + build_serialized_file(&entries), + ) + .expect("stripped asset"); + } + + fn build_serialized_file(entries: &[FixtureEntry]) -> Vec { + let type_descriptors = entries + .iter() + .map(|entry| (entry.class_id, type_tree_blob_for(&entry.object))) + .collect::>(); + let object_payloads = entries + .iter() + .map(|entry| object_bytes(&entry.object)) + .collect::>(); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2019.4.0f1\0"); + metadata.extend_from_slice(&0i32.to_le_bytes()); + metadata.push(1u8); + metadata.extend_from_slice(&to_i32(type_descriptors.len()).to_le_bytes()); + + for (class_id, tree_blob) in &type_descriptors { + metadata.extend_from_slice(&class_id.to_le_bytes()); + metadata.push(0u8); + metadata.extend_from_slice(&(-1i16).to_le_bytes()); + if *class_id == 114 { + metadata.extend_from_slice(&[0xAB; 16]); + } + metadata.extend_from_slice(&[0xCD; 16]); + metadata.extend_from_slice(tree_blob); + } + + metadata.extend_from_slice(&to_i32(entries.len()).to_le_bytes()); + let mut relative_offset = 0u32; + for (index, entry) in entries.iter().enumerate() { + while metadata.len() % 4 != 0 { + metadata.push(0u8); + } + metadata.extend_from_slice(&entry.path_id.to_le_bytes()); + metadata.extend_from_slice(&relative_offset.to_le_bytes()); + metadata.extend_from_slice(&to_u32(object_payloads[index].len()).to_le_bytes()); + metadata.extend_from_slice(&to_i32(index).to_le_bytes()); + relative_offset = relative_offset + .checked_add(to_u32(object_payloads[index].len())) + .expect("relative offset overflow"); + } + + metadata.extend_from_slice(&0i32.to_le_bytes()); + metadata.extend_from_slice(&0i32.to_le_bytes()); + metadata.push(0u8); + + let header_size = 20u32; + let data_offset = header_size + .checked_add(to_u32(metadata.len())) + .expect("data offset overflow"); + let file_size = data_offset + .checked_add( + object_payloads + .iter() + .map(|bytes| to_u32(bytes.len())) + .sum::(), + ) + .expect("file size overflow"); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&data_offset.to_be_bytes()); + bytes.extend_from_slice(&file_size.to_be_bytes()); + bytes.extend_from_slice(&19u32.to_be_bytes()); + bytes.extend_from_slice(&data_offset.to_be_bytes()); + bytes.push(0u8); + bytes.extend_from_slice(&[0u8; 3]); + bytes.extend_from_slice(&metadata); + for payload in object_payloads { + bytes.extend_from_slice(&payload); + } + bytes + } + + fn type_tree_blob_for(object: &FixtureObject) -> Vec { + let nodes = match object { + FixtureObject::GameObject { .. } => vec![ + node(0, "GameObject", "Base", -1, 0), + node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), + ], + FixtureObject::MonoScript { .. } => vec![ + node(0, "MonoScript", "Base", -1, 0), + node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_ClassName", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_Namespace", -1, ALIGN_BYTES_FLAG), + node(1, "string", "m_AssemblyName", -1, ALIGN_BYTES_FLAG), + ], + FixtureObject::StrippedMonoBehaviour { .. } => return empty_type_tree_blob(), + }; + + let mut string_buffer = Vec::new(); + let mut records = Vec::new(); + for (index, (level, type_name, field_name, byte_size, meta_flags)) in + nodes.iter().enumerate() + { + let type_offset = push_cstring(&mut string_buffer, type_name); + let name_offset = push_cstring(&mut string_buffer, field_name); + records.push(( + *level, + type_offset, + name_offset, + *byte_size, + to_i32(index), + *meta_flags, + )); + } + + let mut blob = Vec::new(); + blob.extend_from_slice(&to_i32(records.len()).to_le_bytes()); + blob.extend_from_slice(&to_i32(string_buffer.len()).to_le_bytes()); + for (level, type_offset, name_offset, byte_size, index, meta_flags) in records { + blob.extend_from_slice(&1u16.to_le_bytes()); + blob.push(level); + blob.push(0u8); + blob.extend_from_slice(&type_offset.to_le_bytes()); + blob.extend_from_slice(&name_offset.to_le_bytes()); + blob.extend_from_slice(&byte_size.to_le_bytes()); + blob.extend_from_slice(&index.to_le_bytes()); + blob.extend_from_slice(&meta_flags.to_le_bytes()); + blob.extend_from_slice(&0u64.to_le_bytes()); + } + blob.extend_from_slice(&string_buffer); + blob + } + + fn empty_type_tree_blob() -> Vec { + let mut blob = Vec::new(); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob + } + + const fn node( + level: u8, + type_name: &'static str, + field_name: &'static str, + byte_size: i32, + meta_flags: i32, + ) -> (u8, &'static str, &'static str, i32, i32) { + (level, type_name, field_name, byte_size, meta_flags) + } + + fn push_cstring(buffer: &mut Vec, value: &str) -> u32 { + let offset = to_u32(buffer.len()); + buffer.extend_from_slice(value.as_bytes()); + buffer.push(0u8); + offset + } + + fn object_bytes(object: &FixtureObject) -> Vec { + let mut bytes = Vec::new(); + match object { + FixtureObject::GameObject { name } => { + push_aligned_string(&mut bytes, name); + } + FixtureObject::MonoScript { + name, + class_name, + namespace, + assembly_name, + } => { + push_aligned_string(&mut bytes, name); + push_aligned_string(&mut bytes, class_name); + push_aligned_string(&mut bytes, namespace); + push_aligned_string(&mut bytes, assembly_name); + } + FixtureObject::StrippedMonoBehaviour { + game_object, + script, + extra_refs, + } => { + push_pptr(&mut bytes, *game_object); + bytes.extend_from_slice(&1i32.to_le_bytes()); + push_pptr(&mut bytes, *script); + for value in extra_refs { + push_pptr(&mut bytes, *value); + } + } + } + bytes + } + + fn push_aligned_string(bytes: &mut Vec, value: &str) { + bytes.extend_from_slice(&to_i32(value.len()).to_le_bytes()); + bytes.extend_from_slice(value.as_bytes()); + while bytes.len() % 4 != 0 { + bytes.push(0u8); + } + } + + fn push_pptr(bytes: &mut Vec, value: PPtrValue) { + bytes.extend_from_slice(&value.file_id.to_le_bytes()); + bytes.extend_from_slice(&value.path_id.to_le_bytes()); + } + + #[test] + fn split_selector_accepts_file_and_path_id() { + let (file, path_id) = split_selector("demo.assets#42").expect("selector"); + assert_eq!(file, "demo.assets"); + assert_eq!(path_id, 42); + } + + #[test] + fn best_known_unity_class_names_cover_common_ui_ids() { + assert_eq!(best_known_unity_class_name(222), Some("CanvasRenderer")); + assert_eq!(best_known_unity_class_name(224), Some("RectTransform")); + assert_eq!(best_known_unity_class_name(999_999), None); + assert_eq!(resolve_unity_class_name(999_999), "Class_999999"); + } + + #[test] + fn split_selector_rejects_missing_separator_or_invalid_number() { + let missing = split_selector("demo.assets").expect_err("missing separator"); + assert!( + missing + .to_string() + .contains("selector must use the form file#path_id") + ); + + let invalid = split_selector("demo.assets#abc").expect_err("invalid id"); + assert!( + invalid + .to_string() + .contains("invalid selector path id 'abc'") + ); + } + + #[test] + fn sanitize_file_name_replaces_invalid_characters() { + assert_eq!(sanitize_file_name("a:c*"), "a_b__c_"); + } + + #[test] + fn default_base_name_falls_back_to_object_id_when_name_is_missing() { + let row = sample_row("TextAsset", None); + assert_eq!(default_base_name(&row), "sample.assets-1"); + } + + #[test] + fn read_stream_spec_supports_primary_and_fallback_field_names() { + let mut stream = UnityClass::new(0, "Stream".to_string(), "1".to_string()); + stream.set( + "m_Source".to_string(), + UnityValue::String("archive.bin".to_string()), + ); + stream.set("m_Offset".to_string(), UnityValue::Integer(9)); + stream.set("m_Size".to_string(), UnityValue::Integer(4)); + + let mut props = UnityClass::new(0, "Props".to_string(), "2".to_string()); + props.set( + "m_ExternalResources".to_string(), + UnityValue::Object(stream.serialized_properties()), + ); + let parsed = read_stream_spec(&UnityValue::Object(props.serialized_properties())); + assert_eq!(parsed, Some(("archive.bin".to_string(), 9, 4))); + + let mut fallback = UnityClass::new(0, "Fallback".to_string(), "3".to_string()); + fallback.set( + "source".to_string(), + UnityValue::String("x.bin".to_string()), + ); + fallback.set("offset".to_string(), UnityValue::Integer(12)); + fallback.set("size".to_string(), UnityValue::Integer(7)); + let mut props = UnityClass::new(0, "Props".to_string(), "4".to_string()); + props.set( + "m_StreamData".to_string(), + UnityValue::Object(fallback.serialized_properties()), + ); + let parsed = read_stream_spec(&UnityValue::Object(props.serialized_properties())); + assert_eq!(parsed, Some(("x.bin".to_string(), 12, 7))); + } + + #[test] + fn read_stream_spec_rejects_negative_or_oversized_values() { + let mut stream = UnityClass::new(0, "Stream".to_string(), "1".to_string()); + stream.set( + "m_Source".to_string(), + UnityValue::String("video".to_string()), + ); + stream.set("m_Offset".to_string(), UnityValue::Integer(-1)); + stream.set("m_Size".to_string(), UnityValue::Integer(4)); + let mut props = UnityClass::new(0, "Props".to_string(), "2".to_string()); + props.set( + "m_ExternalResources".to_string(), + UnityValue::Object(stream.serialized_properties()), + ); + assert!(read_stream_spec(&UnityValue::Object(props.serialized_properties())).is_none()); + + let mut stream = UnityClass::new(0, "Stream".to_string(), "3".to_string()); + stream.set( + "m_Source".to_string(), + UnityValue::String("video".to_string()), + ); + stream.set("m_Offset".to_string(), UnityValue::Integer(1)); + stream.set( + "m_Size".to_string(), + UnityValue::Integer(i64::from(u32::MAX) + 1), + ); + let mut props = UnityClass::new(0, "Props".to_string(), "4".to_string()); + props.set( + "m_ExternalResources".to_string(), + UnityValue::Object(stream.serialized_properties()), + ); + assert!(read_stream_spec(&UnityValue::Object(props.serialized_properties())).is_none()); + } + + #[test] + fn raw_pptr_window_scanner_ignores_corrupt_binary_reference_candidates() { + assert!(probable_raw_pptr_windows(&[1, 2, 3]).is_empty()); + + let mut invalid_file_id = Vec::new(); + push_pptr( + &mut invalid_file_id, + PPtrValue { + file_id: 17, + path_id: 44, + }, + ); + assert!(probable_raw_pptr_windows(&invalid_file_id).is_empty()); + + let mut zero_path_id = Vec::new(); + push_pptr( + &mut zero_path_id, + PPtrValue { + file_id: 1, + path_id: 0, + }, + ); + assert!(probable_raw_pptr_windows(&zero_path_id).is_empty()); + + let mut valid = Vec::new(); + push_pptr( + &mut valid, + PPtrValue { + file_id: 1, + path_id: 45, + }, + ); + + assert_eq!(probable_raw_pptr_windows(&valid), vec![(0, 1, 45)]); + } + + #[test] + fn extract_from_properties_handles_text_asset_and_raw_fallbacks() { + let row = sample_row("TextAsset", Some("Script")); + let mut text = UnityClass::new(0, "Text".to_string(), "1".to_string()); + text.set( + "m_Script".to_string(), + UnityValue::String("hello".to_string()), + ); + let outputs = extract_from_properties( + &row, + &UnityValue::Object(text.serialized_properties()), + None, + None, + None, + ); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].mode, ExtractionMode::TextAsset); + assert_eq!(outputs[0].file_name, "Script.txt"); + assert!(outputs[0].is_text); + + let row = sample_row("Texture2D", Some("Icon")); + let outputs = + extract_from_properties(&row, &UnityValue::Null, Some(b"\xAA\xBB"), None, None); + assert!(outputs.is_empty()); + + let mut props = UnityClass::new(0, "Raw".to_string(), "2".to_string()); + props.set("value".to_string(), UnityValue::Integer(1)); + let outputs = extract_from_properties( + &row, + &UnityValue::Object(props.serialized_properties()), + Some(b"\xAA\xBB"), + None, + None, + ); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].mode, ExtractionMode::Raw); + assert_eq!(outputs[0].bytes, b"\xAA\xBB"); + assert_eq!(outputs[0].file_name, "Icon.bin"); + + let unnamed_row = sample_row("TextAsset", None); + let mut raw_text = Vec::new(); + raw_text.extend_from_slice(&5_u32.to_le_bytes()); + raw_text.extend_from_slice(b"en-US"); + raw_text.extend_from_slice(&[0_u8; 3]); + raw_text.extend_from_slice(&5_u32.to_le_bytes()); + raw_text.extend_from_slice(b"hello"); + let empty = UnityClass::new(0, "Text".to_string(), "3".to_string()); + let outputs = extract_from_properties( + &unnamed_row, + &UnityValue::Object(empty.serialized_properties()), + Some(&raw_text), + None, + None, + ); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].mode, ExtractionMode::TextAsset); + assert_eq!(outputs[0].file_name, "sample.assets-1.txt"); + assert_eq!(outputs[0].bytes, b"hello"); + } + + #[test] + fn text_asset_text_from_raw_prefers_longest_aligned_utf8_block() { + let mut raw_text = Vec::new(); + raw_text.extend_from_slice(&2_u32.to_le_bytes()); + raw_text.extend_from_slice(b"id"); + raw_text.extend_from_slice(&[0_u8; 2]); + raw_text.extend_from_slice(&11_u32.to_le_bytes()); + raw_text.extend_from_slice(b"hello world"); + raw_text.extend_from_slice(&[0_u8; 1]); + + assert_eq!( + text_asset_text_from_raw(&raw_text).as_deref(), + Some("hello world") + ); + } + + #[test] + fn mono_script_metadata_from_raw_recovers_class_namespace_and_assembly() { + let mut raw_data = Vec::new(); + raw_data.extend_from_slice(&17_u32.to_le_bytes()); + raw_data.extend_from_slice(b"ProjectPathFinder"); + raw_data.extend_from_slice(&[0_u8; 3]); + raw_data.extend_from_slice(&[0xAA_u8; 16]); + raw_data.extend_from_slice(&17_u32.to_le_bytes()); + raw_data.extend_from_slice(b"ProjectPathFinder"); + raw_data.extend_from_slice(&[0_u8; 3]); + raw_data.extend_from_slice(&17_u32.to_le_bytes()); + raw_data.extend_from_slice(b"Sirenix.Utilities"); + raw_data.extend_from_slice(&[0_u8; 3]); + raw_data.extend_from_slice(&21_u32.to_le_bytes()); + raw_data.extend_from_slice(b"Sirenix.Utilities.dll"); + raw_data.extend_from_slice(&[0_u8; 3]); + + let metadata = mono_script_metadata_from_raw(&raw_data); + assert_eq!( + metadata, + MonoScriptMetadata { + class_name: Some("ProjectPathFinder".to_string()), + namespace: Some("Sirenix.Utilities".to_string()), + assembly_name: Some("Sirenix.Utilities.dll".to_string()), + } + ); + } + + #[test] + fn mono_behaviour_script_pointer_from_raw_reads_stripped_header_layout() { + let raw_data = [ + 0_u8, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 158, 4, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + ]; + assert_eq!( + mono_behaviour_script_pointer_from_raw(&raw_data), + Some((1, 1182)) + ); + } + + #[test] + fn mono_behaviour_game_object_pointer_from_raw_reads_component_header_layout() { + let raw_data = [ + 0_u8, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 158, 4, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + ]; + assert_eq!( + mono_behaviour_game_object_pointer_from_raw(&raw_data), + Some((0, 29)) + ); + } + + #[test] + fn binary_dump_field_mode_detects_raw_only_payloads() { + let raw_only = serde_json::json!({"_raw_data_len": 12, "_raw_data": [1, 2, 3]}); + let typed = serde_json::json!({"m_Name": "Demo", "_raw_data_len": 12}); + + assert_eq!( + binary_dump_field_mode(&raw_only), + AssetDumpFieldMode::MetadataOnly + ); + assert_eq!(binary_dump_field_mode(&typed), AssetDumpFieldMode::Typetree); + } + + #[test] + fn raw_fallback_edges_and_dump_augmentation_cover_stripped_mono_behaviour() { + let temp = tempdir().expect("tempdir"); + write_stripped_fixture_workspace(temp.path()); + let catalog = Catalog::load(&[temp.path().to_path_buf()]).expect("catalog"); + let object = catalog.resolve("stripped.assets#2000").expect("object"); + let CatalogObjectKey::Binary(key) = &object.key else { + panic!("expected binary object"); + }; + let source = binary_object_source(&catalog.env, key).expect("binary source"); + let raw_data = source.object.raw_data().expect("raw data"); + + let edges = raw_fallback_binary_edges(&catalog, object, &source, raw_data); + assert!(edges.iter().any(|edge| { + edge.entry.path.as_deref() == Some("m_GameObject") + && edge.entry.id == "stripped.assets#29" + })); + assert!(edges.iter().any(|edge| { + edge.entry.path.as_deref() == Some("m_Script") + && edge.entry.id == "stripped.assets#1182" + })); + assert!(edges.iter().any(|edge| { + edge.entry + .path + .as_deref() + .is_some_and(|path| path.starts_with("_raw_pptr@")) + && edge.entry.id == "stripped.assets#30" + })); + + let parsed = catalog + .env + .read_binary_object_key(key) + .expect("parsed object"); + let mut properties = unity_value_to_json(&UnityValue::Object( + parsed.as_unity_class().serialized_properties(), + )) + .expect("json"); + assert_eq!( + binary_dump_field_mode(&properties), + AssetDumpFieldMode::MetadataOnly + ); + augment_binary_dump_properties(&catalog, object, key, parsed.raw_data(), &mut properties); + + assert_eq!( + properties + .get("m_GameObject") + .and_then(|value| value.get("resolved_id")) + .and_then(serde_json::Value::as_str), + Some("stripped.assets#29") + ); + assert_eq!( + properties + .get("m_Script") + .and_then(|value| value.get("resolved_id")) + .and_then(serde_json::Value::as_str), + Some("stripped.assets#1182") + ); + assert!( + properties + .get("_raw_resolved_references") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| !items.is_empty()) + ); + } + + #[test] + fn extract_from_properties_formats_mono_script_output() { + let row = sample_row("MonoScript", Some("Player/Controller")); + let mut props = UnityClass::new(0, "Script".to_string(), "1".to_string()); + props.set( + "m_ClassName".to_string(), + UnityValue::String("PlayerController".to_string()), + ); + props.set( + "m_Namespace".to_string(), + UnityValue::String("Game.Runtime".to_string()), + ); + props.set( + "m_AssemblyName".to_string(), + UnityValue::String("Assembly-CSharp".to_string()), + ); + let outputs = extract_from_properties( + &row, + &UnityValue::Object(props.serialized_properties()), + None, + None, + None, + ); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].mode, ExtractionMode::MonoScript); + assert_eq!(outputs[0].file_name, "Player_Controller.cs"); + let body = String::from_utf8(outputs[0].bytes.clone()).expect("utf8"); + assert!(body.contains("namespace Game.Runtime")); + assert!(body.contains("public class PlayerController")); + assert!(body.contains("// Assembly: Assembly-CSharp")); + } + + #[test] + fn edge_from_binary_marks_resolved_and_unresolved_variants() { + let unresolved = edge_from_binary("source#1", 2, 33, None, Some("m_Link".to_string())); + assert!(!unresolved.entry.resolved); + assert_eq!(unresolved.entry.id, "external:2#33"); + assert_eq!(unresolved.target_id, None); + assert_eq!(unresolved.entry.path.as_deref(), Some("m_Link")); + + let target = CatalogObject { + row: sample_row("GameObject", Some("Target")), + key: CatalogObjectKey::Yaml { + path: PathBuf::from("scene.unity"), + anchor: "100".to_string(), + }, + aliases: vec!["scene.unity#100".to_string()], + }; + let resolved = edge_from_binary("source#1", 0, 100, Some(&target), None); + assert!(resolved.entry.resolved); + assert_eq!(resolved.target_id.as_deref(), Some("sample.assets#1")); + assert_eq!(resolved.entry.class_name.as_deref(), Some("GameObject")); + } + + #[test] + fn yaml_scanner_tracks_local_and_guid_references() { + let mut local = UnityClass::new(0, "Local".to_string(), "1".to_string()); + local.set("fileID".to_string(), UnityValue::Integer(2000)); + let mut external = UnityClass::new(0, "External".to_string(), "2".to_string()); + external.set("fileID".to_string(), UnityValue::Integer(11_500_000)); + external.set("guid".to_string(), UnityValue::String("abcd".to_string())); + let mut root = UnityClass::new(0, "Root".to_string(), "3".to_string()); + root.set( + "local".to_string(), + UnityValue::Object(local.serialized_properties()), + ); + root.set( + "external".to_string(), + UnityValue::Object(external.serialized_properties()), + ); + let value = UnityValue::Object(root.serialized_properties()); + + let mut edges = Vec::new(); + scan_yaml_edges( + "scene.unity#1000", + "scene.unity", + &value, + String::new(), + &mut edges, + ); + + assert_eq!(edges.len(), 2); + assert!(edges.iter().any(|edge| { + edge.target_id.as_deref() == Some("scene.unity#2000") + && edge.entry.path.as_deref() == Some("local") + })); + assert!(edges.iter().any(|edge| { + edge.entry.id == "guid:abcd#11500000" && edge.entry.path.as_deref() == Some("external") + })); + } + + #[test] + fn yaml_scanner_ignores_corrupt_reference_shapes_and_continues() { + let mut corrupt = UnityClass::new(0, "Corrupt".to_string(), "1".to_string()); + corrupt.set( + "fileID".to_string(), + UnityValue::String("not-an-integer".to_string()), + ); + corrupt.set( + "guid".to_string(), + UnityValue::String("bad-guid".to_string()), + ); + + let mut valid = UnityClass::new(0, "Valid".to_string(), "2".to_string()); + valid.set("fileID".to_string(), UnityValue::Integer(3000)); + + let mut root = UnityClass::new(0, "Root".to_string(), "3".to_string()); + root.set( + "corrupt".to_string(), + UnityValue::Object(corrupt.serialized_properties()), + ); + root.set( + "valid".to_string(), + UnityValue::Object(valid.serialized_properties()), + ); + let value = UnityValue::Object(root.serialized_properties()); + + let mut edges = Vec::new(); + scan_yaml_edges( + "scene.unity#1000", + "scene.unity", + &value, + String::new(), + &mut edges, + ); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].entry.id, "scene.unity#3000"); + assert_eq!(edges[0].entry.path.as_deref(), Some("valid")); + } +} diff --git a/crates/unitysupport/src/lib.rs b/crates/unitysupport/src/lib.rs new file mode 100644 index 0000000..c612863 --- /dev/null +++ b/crates/unitysupport/src/lib.rs @@ -0,0 +1,48 @@ +//! Shared Unity-focused helpers for Mercury Toolbox commands. +#![allow(clippy::multiple_crate_versions)] + +use common::CliError; +use thiserror::Error; + +pub mod assets; +pub mod logs; +pub mod probe; + +pub use assets::{ + AssetDump, AssetDumpFieldMode, AssetExtractOutput, AssetExtractReport, AssetIndexRow, + AssetReferenceEntry, AssetReferences, ExtractionMode, dump_unity_asset, extract_unity_asset, + index_unity_assets, refs_unity_asset, +}; +pub use logs::{ + CountEntry, IncidentGroupMode, StackMode, UnityDiagOptions, UnityDiagReport, UnityDiagSummary, + UnityIncident, UnityLogKind, UnityLogSummary, UnitySeverity, analyze_log_paths, + analyze_log_text, discover_log_paths, parse_incident_group_mode, parse_stack_mode, +}; +pub use probe::{ + BRIDGE_PIPE_NAME, FindData, InspectData, InstallReport, ProbeEnvelope, RuntimeStatusData, + ScenesData, StaticData, StatusReport, UninstallReport, find_objects, inspect_object, + inspect_static, install_bridge, query_runtime_status, query_scenes, status_bridge, + uninstall_bridge, +}; + +/// Errors produced by the shared Unity support layer. +#[derive(Debug, Error)] +pub enum UnitySupportError { + /// Message-style runtime or validation error. + #[error("{0}")] + Message(String), +} + +impl UnitySupportError { + /// Builds a new message-style error. + #[must_use] + pub fn message(message: impl Into) -> Self { + Self::Message(message.into()) + } +} + +impl From for CliError { + fn from(value: UnitySupportError) -> Self { + Self::runtime(value.to_string()) + } +} diff --git a/crates/unitysupport/src/logs.rs b/crates/unitysupport/src/logs.rs new file mode 100644 index 0000000..b4ac92d --- /dev/null +++ b/crates/unitysupport/src/logs.rs @@ -0,0 +1,1298 @@ +//! Unity and `BepInEx` log parsing helpers shared by `unitydiag`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::SystemTime; + +use regex_lite::Regex; +use serde::Serialize; + +use crate::UnitySupportError; + +/// Groups repeated incidents by full incident signature, message, or frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IncidentGroupMode { + /// Group by normalized message, domain, exception type, and primary interesting frame. + Incident, + /// Group only by normalized message and exception type. + Message, + /// Group only by the primary interesting frame. + Frame, +} + +/// Controls how much stack data to retain per incident. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StackMode { + /// Drop stack details from incidents. + None, + /// Retain only the primary interesting frame. + Top, + /// Retain every parsed frame. + Full, +} + +/// Stable severity label for parsed Unity incidents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnitySeverity { + /// Error-grade incident. + Error, + /// Warning-grade incident. + Warning, + /// Informational incident. + Info, +} + +/// Stable source-kind label for analyzed logs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnityLogKind { + /// A Unity `Player.log`. + Player, + /// A `BepInEx/LogOutput.log`. + Bepinex, + /// Any other text log. + Other, +} + +/// High-level knobs for `unitydiag`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnityDiagOptions { + /// Maximum grouped incidents to retain. + pub top: usize, + /// Grouping mode used while folding repeated events. + pub group_by: IncidentGroupMode, + /// Stack retention policy. + pub stack_mode: StackMode, + /// Whether warning incidents should be included. + pub include_warnings: bool, + /// Whether info incidents should be included. + pub include_info: bool, +} + +impl Default for UnityDiagOptions { + fn default() -> Self { + Self { + top: 20, + group_by: IncidentGroupMode::Incident, + stack_mode: StackMode::Top, + include_warnings: false, + include_info: false, + } + } +} + +/// Stable per-log summary emitted by `unitydiag`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UnityLogSummary { + /// Source path or synthetic label. + pub path: String, + /// Source-kind label. + pub kind: UnityLogKind, + /// Number of parsed events before severity filtering. + pub event_count: usize, + /// Number of retained events after severity filtering. + pub included_event_count: usize, + /// Number of parsed error events. + pub error_count: usize, + /// Number of parsed warning events. + pub warning_count: usize, + /// Number of parsed info events. + pub info_count: usize, +} + +/// Stable grouped incident emitted by `unitydiag`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UnityIncident { + /// Grouping mode used for the report. + pub group_by: IncidentGroupMode, + /// Number of raw events folded into this incident. + pub count: usize, + /// Effective incident severity. + pub severity: UnitySeverity, + /// Coarse blame bucket. + pub domain: String, + /// Parsed exception type when one was found. + pub exception_type: Option, + /// Representative message for the incident group. + pub message: String, + /// Normalized message used while grouping. + pub normalized_message: String, + /// Primary interesting frame when one was found. + pub primary_frame: Option, + /// Retained stack frames based on the chosen stack mode. + pub stack: Vec, + /// Distinct log channels that produced this incident. + pub channels: Vec, + /// Distinct paths that produced this incident. + pub paths: Vec, + /// First path where the incident appeared. + pub first_path: String, + /// First line where the incident appeared. + pub first_line: usize, + /// Whether this incident looks like expected shutdown noise rather than a durable fault. + pub likely_shutdown_noise: bool, +} + +/// Stable summary block emitted by `unitydiag`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UnityDiagSummary { + /// Grouping mode used across the report. + pub group_by: IncidentGroupMode, + /// Number of analyzed log sources. + pub log_count: usize, + /// Number of retained events before grouping. + pub event_count: usize, + /// Number of retained grouped incidents. + pub incident_count: usize, + /// Count of retained error events. + pub error_count: usize, + /// Count of retained warning events. + pub warning_count: usize, + /// Count of retained info events. + pub info_count: usize, + /// Counts grouped by incident domain. + pub domain_counts: Vec, +} + +/// Stable name/count pair used in summaries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CountEntry { + /// Domain or bucket label. + pub name: String, + /// Number of events or incidents in that bucket. + pub count: usize, +} + +/// Stable JSON payload emitted by `unitydiag`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UnityDiagReport { + /// Per-log source summaries. + pub logs: Vec, + /// Grouped incidents sorted by severity and frequency. + pub incidents: Vec, + /// Aggregate report summary. + pub summary: UnityDiagSummary, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LogInput { + path: String, + kind: UnityLogKind, + content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UnityEvent { + path: String, + line: usize, + severity: UnitySeverity, + channel: Option, + domain: String, + exception_type: Option, + message: String, + normalized_message: String, + stack: Vec, + primary_frame: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IncidentAccumulator { + count: usize, + severity: UnitySeverity, + domain: String, + exception_type: Option, + message: String, + normalized_message: String, + primary_frame: Option, + stack: Vec, + channels: BTreeSet, + paths: BTreeSet, + first_path: String, + first_line: usize, + likely_shutdown_noise: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedLog { + path: String, + kind: UnityLogKind, + raw_event_count: usize, + error_count: usize, + warning_count: usize, + info_count: usize, + events: Vec, +} + +/// Parses the public `--group-by` values used by `unitydiag`. +/// +/// # Errors +/// +/// Returns an error when the value is not one of the supported grouping modes. +pub fn parse_incident_group_mode(value: &str) -> Result { + match value { + "incident" => Ok(IncidentGroupMode::Incident), + "message" => Ok(IncidentGroupMode::Message), + "frame" => Ok(IncidentGroupMode::Frame), + other => Err(UnitySupportError::message(format!( + "invalid --group-by value '{other}'; expected incident, message, or frame" + ))), + } +} + +/// Parses the public `--stack` values used by `unitydiag`. +/// +/// # Errors +/// +/// Returns an error when the value is not one of the supported stack policies. +pub fn parse_stack_mode(value: &str) -> Result { + match value { + "none" => Ok(StackMode::None), + "top" => Ok(StackMode::Top), + "full" => Ok(StackMode::Full), + other => Err(UnitySupportError::message(format!( + "invalid --stack value '{other}'; expected none, top, or full" + ))), + } +} + +/// Discovers Unity log paths from explicit inputs, `--game-root`, and `--latest`. +/// +/// # Errors +/// +/// Returns an error when a requested game root cannot be read. +pub fn discover_log_paths( + explicit_paths: &[PathBuf], + game_root: Option<&Path>, + latest: bool, +) -> Result, UnitySupportError> { + let mut candidates = explicit_paths.to_vec(); + if let Some(root) = game_root { + let bepinex_log = root.join("BepInEx").join("LogOutput.log"); + if bepinex_log.is_file() { + candidates.push(bepinex_log); + } + candidates.extend(matching_locallow_logs(root, locallow_root().as_deref())?); + } + + if latest { + if let Some(path) = latest_candidate_path(&candidates, game_root)? { + return Ok(vec![path]); + } + } + + dedupe_paths(candidates) +} + +fn matching_locallow_logs( + game_root: &Path, + locallow_root: Option<&Path>, +) -> Result, UnitySupportError> { + let Some(root) = locallow_root else { + return Ok(Vec::new()); + }; + + let mut candidates = Vec::new(); + scan_for_common_logs(root, 4, &mut candidates)?; + let hints = game_root_log_hints(game_root); + candidates.retain(|path| path_matches_game_hints(path, &hints)); + dedupe_paths(candidates) +} + +/// Analyzes on-disk Unity logs and returns a stable grouped report. +/// +/// # Errors +/// +/// Returns an error when a requested path cannot be read. +pub fn analyze_log_paths( + paths: &[PathBuf], + options: &UnityDiagOptions, +) -> Result { + let mut inputs = Vec::new(); + for path in paths { + let content = fs::read_to_string(path).map_err(|error| { + UnitySupportError::message(format!("failed to read {}: {error}", path.display())) + })?; + inputs.push(LogInput { + path: path.display().to_string(), + kind: infer_log_kind(path), + content, + }); + } + Ok(analyze_inputs(&inputs, options)) +} + +/// Analyzes raw Unity log text from stdin or an in-memory fixture. +#[must_use] +pub fn analyze_log_text( + label: &str, + content: &str, + kind: UnityLogKind, + options: &UnityDiagOptions, +) -> UnityDiagReport { + analyze_inputs( + &[LogInput { + path: label.to_string(), + kind, + content: content.to_string(), + }], + options, + ) +} + +fn analyze_inputs(inputs: &[LogInput], options: &UnityDiagOptions) -> UnityDiagReport { + let parsed_logs = inputs + .iter() + .map(|input| parse_log_input(input, options)) + .collect::>(); + let grouped = group_incidents(&parsed_logs, options.group_by); + let (retained_event_count, retained_errors, retained_warnings, retained_infos) = + retained_counts(&parsed_logs); + + let mut incidents = grouped + .into_values() + .map(|group| UnityIncident { + group_by: options.group_by, + count: group.count, + severity: group.severity, + domain: group.domain, + exception_type: group.exception_type, + message: group.message, + normalized_message: group.normalized_message, + primary_frame: group.primary_frame, + stack: apply_stack_mode(&group.stack, options.stack_mode), + channels: group.channels.into_iter().collect::>(), + paths: group.paths.into_iter().collect::>(), + first_path: group.first_path, + first_line: group.first_line, + likely_shutdown_noise: group.likely_shutdown_noise, + }) + .collect::>(); + incidents.sort_by(|left, right| { + severity_rank(right.severity) + .cmp(&severity_rank(left.severity)) + .then_with(|| right.count.cmp(&left.count)) + .then_with(|| left.domain.cmp(&right.domain)) + .then_with(|| left.message.cmp(&right.message)) + }); + incidents.truncate(options.top); + + let mut domain_counts = BTreeMap::::new(); + for incident in &incidents { + *domain_counts.entry(incident.domain.clone()).or_insert(0) += incident.count; + } + + UnityDiagReport { + logs: parsed_logs + .into_iter() + .map(|parsed| UnityLogSummary { + path: parsed.path, + kind: parsed.kind, + event_count: parsed.raw_event_count, + included_event_count: parsed.events.len(), + error_count: parsed.error_count, + warning_count: parsed.warning_count, + info_count: parsed.info_count, + }) + .collect::>(), + summary: UnityDiagSummary { + group_by: options.group_by, + log_count: inputs.len(), + event_count: retained_event_count, + incident_count: incidents.len(), + error_count: retained_errors, + warning_count: retained_warnings, + info_count: retained_infos, + domain_counts: domain_counts + .into_iter() + .map(|(name, count)| CountEntry { name, count }) + .collect::>(), + }, + incidents, + } +} + +fn group_incidents( + parsed_logs: &[ParsedLog], + group_by: IncidentGroupMode, +) -> BTreeMap { + let mut grouped = BTreeMap::::new(); + for parsed in parsed_logs { + for event in &parsed.events { + let key = grouping_key(event, group_by); + let entry = grouped.entry(key).or_insert_with(|| IncidentAccumulator { + count: 0, + severity: event.severity, + domain: event.domain.clone(), + exception_type: event.exception_type.clone(), + message: event.message.clone(), + normalized_message: event.normalized_message.clone(), + primary_frame: event.primary_frame.clone(), + stack: event.stack.clone(), + channels: BTreeSet::new(), + paths: BTreeSet::new(), + first_path: event.path.clone(), + first_line: event.line, + likely_shutdown_noise: event_is_likely_shutdown_noise(event), + }); + entry.count += 1; + entry.likely_shutdown_noise |= event_is_likely_shutdown_noise(event); + if let Some(channel) = &event.channel { + entry.channels.insert(channel.clone()); + } + entry.paths.insert(event.path.clone()); + } + } + grouped +} + +fn retained_counts(parsed_logs: &[ParsedLog]) -> (usize, usize, usize, usize) { + let mut retained_event_count = 0_usize; + let mut retained_errors = 0_usize; + let mut retained_warnings = 0_usize; + let mut retained_infos = 0_usize; + + for parsed in parsed_logs { + retained_event_count += parsed.events.len(); + retained_errors += parsed + .events + .iter() + .filter(|event| event.severity == UnitySeverity::Error) + .count(); + retained_warnings += parsed + .events + .iter() + .filter(|event| event.severity == UnitySeverity::Warning) + .count(); + retained_infos += parsed + .events + .iter() + .filter(|event| event.severity == UnitySeverity::Info) + .count(); + } + + ( + retained_event_count, + retained_errors, + retained_warnings, + retained_infos, + ) +} + +fn parse_log_input(input: &LogInput, options: &UnityDiagOptions) -> ParsedLog { + let raw_events = parse_events(&input.path, &input.content); + let error_count = raw_events + .iter() + .filter(|event| event.severity == UnitySeverity::Error) + .count(); + let warning_count = raw_events + .iter() + .filter(|event| event.severity == UnitySeverity::Warning) + .count(); + let info_count = raw_events + .iter() + .filter(|event| event.severity == UnitySeverity::Info) + .count(); + let events = raw_events + .into_iter() + .filter(|event| should_keep(event.severity, options)) + .collect::>(); + ParsedLog { + path: input.path.clone(), + kind: input.kind, + raw_event_count: error_count + warning_count + info_count, + error_count, + warning_count, + info_count, + events, + } +} + +const fn should_keep(severity: UnitySeverity, options: &UnityDiagOptions) -> bool { + match severity { + UnitySeverity::Error => true, + UnitySeverity::Warning => options.include_warnings, + UnitySeverity::Info => options.include_info, + } +} + +fn parse_events(path: &str, content: &str) -> Vec { + let lines = content.lines().collect::>(); + let mut events = Vec::new(); + let mut index = 0_usize; + while index < lines.len() { + let line = lines[index].trim_end(); + if line.trim().is_empty() { + index += 1; + continue; + } + + if let Some(mut event) = parse_bepinex_event(path, line, index + 1) { + let mut cursor = index + 1; + while cursor < lines.len() && lines[cursor].trim().is_empty() { + cursor += 1; + } + while cursor < lines.len() && is_stack_line(lines[cursor]) { + event.stack.push(clean_stack_line(lines[cursor])); + cursor += 1; + } + event.primary_frame = interesting_frame(&event.stack); + event.domain = classify_domain( + event.channel.as_deref(), + &event.message, + event.exception_type.as_deref(), + &event.stack, + ); + events.push(event); + index = cursor; + continue; + } + + if let Some((exception_type, mut message)) = parse_exception_header(line) { + let mut cursor = index + 1; + while cursor < lines.len() + && !lines[cursor].trim().is_empty() + && !is_stack_line(lines[cursor]) + && parse_bepinex_event(path, lines[cursor].trim_end(), cursor + 1).is_none() + && parse_exception_header(lines[cursor].trim_end()).is_none() + { + if !message.is_empty() { + message.push_str(" | "); + } + message.push_str(lines[cursor].trim()); + cursor += 1; + } + while cursor < lines.len() && lines[cursor].trim().is_empty() { + cursor += 1; + } + let mut stack = Vec::new(); + while cursor < lines.len() && is_stack_line(lines[cursor]) { + stack.push(clean_stack_line(lines[cursor])); + cursor += 1; + } + let primary_frame = interesting_frame(&stack); + let domain = classify_domain(None, &message, exception_type.as_deref(), &stack); + events.push(UnityEvent { + path: path.to_string(), + line: index + 1, + severity: UnitySeverity::Error, + channel: None, + domain, + exception_type, + message: message.clone(), + normalized_message: normalize_message(&message), + stack, + primary_frame, + }); + index = cursor; + continue; + } + + if let Some(mut event) = parse_generic_event(path, line, index + 1) { + let mut cursor = index + 1; + while cursor < lines.len() && lines[cursor].trim().is_empty() { + cursor += 1; + } + while cursor < lines.len() && is_stack_line(lines[cursor]) { + event.stack.push(clean_stack_line(lines[cursor])); + cursor += 1; + } + event.primary_frame = interesting_frame(&event.stack); + event.domain = classify_domain( + event.channel.as_deref(), + &event.message, + event.exception_type.as_deref(), + &event.stack, + ); + events.push(event); + index = cursor; + continue; + } + index += 1; + } + events +} + +fn parse_bepinex_event(path: &str, line: &str, line_number: usize) -> Option { + let captures = bepinex_regex().captures(line)?; + let severity = match captures.get(1)?.as_str() { + "Error" => UnitySeverity::Error, + "Warning" => UnitySeverity::Warning, + "Info" | "Message" => UnitySeverity::Info, + _ => return None, + }; + let channel = captures.get(2)?.as_str().trim().to_string(); + let raw_message = captures.get(3)?.as_str().trim().to_string(); + let (exception_type, message) = + extract_exception_from_message(&raw_message).unwrap_or((None, raw_message)); + let stack = Vec::new(); + Some(UnityEvent { + path: path.to_string(), + line: line_number, + severity, + channel: Some(channel.clone()), + domain: classify_domain(Some(&channel), &message, exception_type.as_deref(), &stack), + exception_type, + normalized_message: normalize_message(&message), + message, + stack, + primary_frame: None, + }) +} + +fn parse_generic_event(path: &str, line: &str, line_number: usize) -> Option { + if line.contains("Amplitude Event Sent Successfully") { + return Some(UnityEvent { + path: path.to_string(), + line: line_number, + severity: UnitySeverity::Info, + channel: None, + domain: "telemetry".to_string(), + exception_type: None, + normalized_message: normalize_message(line), + message: line.to_string(), + stack: Vec::new(), + primary_frame: None, + }); + } + + if line.contains("Amplitude") || line.contains("Telemetry") { + let severity = if line.contains("Error") { + UnitySeverity::Warning + } else { + UnitySeverity::Info + }; + return Some(UnityEvent { + path: path.to_string(), + line: line_number, + severity, + channel: None, + domain: "telemetry".to_string(), + exception_type: None, + normalized_message: normalize_message(line), + message: line.to_string(), + stack: Vec::new(), + primary_frame: None, + }); + } + + if line.contains("Harmony") { + return Some(UnityEvent { + path: path.to_string(), + line: line_number, + severity: if line.contains("Error") || line.contains("Exception") { + UnitySeverity::Error + } else { + UnitySeverity::Warning + }, + channel: None, + domain: "harmony".to_string(), + exception_type: None, + normalized_message: normalize_message(line), + message: line.to_string(), + stack: Vec::new(), + primary_frame: None, + }); + } + + if contains_any( + &[line], + &[ + "missing!", + "discouraged", + "could not", + "failed", + "disabled audio source", + "assertion failed", + ], + ) { + return Some(UnityEvent { + path: path.to_string(), + line: line_number, + severity: if line.contains("Exception") || line.contains("Error") { + UnitySeverity::Error + } else { + UnitySeverity::Warning + }, + channel: None, + domain: classify_domain(None, line, None, &[]), + exception_type: None, + normalized_message: normalize_message(line), + message: line.to_string(), + stack: Vec::new(), + primary_frame: None, + }); + } + + None +} + +fn parse_exception_header(line: &str) -> Option<(Option, String)> { + let captures = exception_regex().captures(line.trim())?; + let exception_type = captures.get(1).map(|value| value.as_str().to_string()); + let detail = captures.get(2).map_or("", |value| value.as_str().trim()); + let message = if detail.is_empty() { + line.trim().to_string() + } else { + format!( + "{}: {}", + exception_type.as_deref().unwrap_or("Exception"), + detail + ) + }; + Some((exception_type, message)) +} + +fn extract_exception_from_message(message: &str) -> Option<(Option, String)> { + let captures = embedded_exception_regex().captures(message)?; + let exception_type = captures.get(1).map(|value| value.as_str().to_string()); + let detail = captures.get(2).map_or("", |value| value.as_str().trim()); + let cleaned = if detail.is_empty() { + message.to_string() + } else { + format!( + "{}: {}", + exception_type.as_deref().unwrap_or("Exception"), + detail + ) + }; + Some((exception_type, cleaned)) +} + +fn classify_domain( + channel: Option<&str>, + message: &str, + exception_type: Option<&str>, + stack: &[String], +) -> String { + let channel_text = channel.unwrap_or_default(); + let joined_stack = stack.join(" "); + if contains_any( + &[channel_text, message, &joined_stack], + &["Amplitude", "telemetry"], + ) { + return "telemetry".to_string(); + } + if contains_any( + &[channel_text, message, &joined_stack], + &["Harmony", "HarmonyLib"], + ) { + return "harmony".to_string(); + } + if contains_any( + &[channel_text, message, &joined_stack], + &[ + "Trainer", + "MercuryUnityBridge", + "Mercury Unity Probe", + "SolarExpanseTrainer", + ], + ) { + return "mod".to_string(); + } + if contains_any( + &[channel_text, message, &joined_stack], + &["BepInEx", "Chainloader", "Preloader"], + ) { + return "bepinex".to_string(); + } + if stack.iter().any(|frame| is_game_frame(frame)) + || contains_any( + &[message, &joined_stack], + &["Game.", "SpaceOps", "ObjectInfo"], + ) + { + return "game".to_string(); + } + if contains_any( + &[channel_text, message, &joined_stack], + &["Sirenix", "Odin", "Serializer", "System.", "Mono."], + ) { + return "framework".to_string(); + } + if contains_any( + &[channel_text, message, &joined_stack], + &[ + "UnityEngine", + "UnityException", + "MonoBehaviour", + "GameObject", + "Game Object", + "referenced script on this Behaviour", + ], + ) { + return "unity".to_string(); + } + if exception_type.is_some_and(is_framework_exception) + || stack.iter().any(|frame| is_framework_frame(frame)) + { + return "framework".to_string(); + } + "unknown".to_string() +} + +fn is_framework_exception(exception_type: &str) -> bool { + matches!( + exception_type, + "ArgumentNullException" + | "NullReferenceException" + | "TimeoutException" + | "ThreadAbortException" + | "InvalidOperationException" + | "TargetInvocationException" + | "ReflectionTypeLoadException" + ) +} + +fn is_framework_frame(frame: &str) -> bool { + starts_with_any( + frame, + &["System.", "mscorlib", "Mono.", "netstandard", "Microsoft."], + ) +} + +fn is_game_frame(frame: &str) -> bool { + !is_framework_frame(frame) + && !starts_with_any(frame, &["UnityEngine.", "BepInEx.", "HarmonyLib."]) + && frame.contains('.') +} + +fn event_is_likely_shutdown_noise(event: &UnityEvent) -> bool { + let joined_stack = event.stack.join(" "); + event + .exception_type + .as_deref() + .is_some_and(|kind| kind.ends_with("IOException")) + && contains_any( + &[&event.message, &event.normalized_message, &joined_stack], + &[ + "pipe is being closed", + "管道正在被关闭", + "NamedPipeServerStream.WaitForConnection", + "WaitForConnection", + ], + ) +} + +fn contains_any(haystacks: &[&str], needles: &[&str]) -> bool { + haystacks.iter().any(|haystack| { + let lowered = haystack.to_ascii_lowercase(); + needles + .iter() + .any(|needle| lowered.contains(&needle.to_ascii_lowercase())) + }) +} + +fn starts_with_any(haystack: &str, prefixes: &[&str]) -> bool { + prefixes.iter().any(|prefix| haystack.starts_with(prefix)) +} + +fn interesting_frame(stack: &[String]) -> Option { + stack + .iter() + .find(|frame| { + !is_framework_frame(frame) + && !starts_with_any(frame, &["UnityEngine.", "BepInEx.", "HarmonyLib."]) + }) + .cloned() + .or_else(|| stack.first().cloned()) +} + +fn is_stack_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("at ") + || trimmed.starts_with("(Filename:") + || trimmed.starts_with("--- End of") +} + +fn clean_stack_line(line: &str) -> String { + line.trim().trim_start_matches("at ").trim().to_string() +} + +fn normalize_message(message: &str) -> String { + let paths = path_regex().replace_all(message, "").to_string(); + let quoted = quoted_regex().replace_all(&paths, "").to_string(); + let hex = hex_regex().replace_all("ed, "").to_string(); + number_regex().replace_all(&hex, "").to_string() +} + +fn apply_stack_mode(stack: &[String], mode: StackMode) -> Vec { + match mode { + StackMode::None => Vec::new(), + StackMode::Top => stack.first().cloned().into_iter().collect::>(), + StackMode::Full => stack.to_vec(), + } +} + +fn grouping_key(event: &UnityEvent, mode: IncidentGroupMode) -> String { + match mode { + IncidentGroupMode::Incident => format!( + "{:?}|{}|{}|{}|{}", + event.severity, + event.domain, + event.exception_type.as_deref().unwrap_or("-"), + event.normalized_message, + event.primary_frame.as_deref().unwrap_or("-") + ), + IncidentGroupMode::Message => format!( + "{:?}|{}|{}|{}", + event.severity, + event.domain, + event.exception_type.as_deref().unwrap_or("-"), + event.normalized_message + ), + IncidentGroupMode::Frame => format!( + "{:?}|{}|{}", + event.severity, + event.domain, + event + .primary_frame + .as_deref() + .unwrap_or(event.normalized_message.as_str()) + ), + } +} + +const fn severity_rank(severity: UnitySeverity) -> u8 { + match severity { + UnitySeverity::Error => 3, + UnitySeverity::Warning => 2, + UnitySeverity::Info => 1, + } +} + +fn infer_log_kind(path: &Path) -> UnityLogKind { + match path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("player.log" | "output_log.txt") => UnityLogKind::Player, + Some("logoutput.log") => UnityLogKind::Bepinex, + _ => UnityLogKind::Other, + } +} + +fn latest_candidate_path( + candidates: &[PathBuf], + game_root: Option<&Path>, +) -> Result, UnitySupportError> { + latest_candidate_path_with_locallow(candidates, game_root, locallow_root().as_deref()) +} + +fn latest_candidate_path_with_locallow( + candidates: &[PathBuf], + game_root: Option<&Path>, + locallow_root: Option<&Path>, +) -> Result, UnitySupportError> { + let mut all = candidates.to_vec(); + if let Some(root) = locallow_root { + let mut locallow_candidates = Vec::new(); + scan_for_common_logs(root, 4, &mut locallow_candidates)?; + if let Some(target_root) = game_root { + let hints = game_root_log_hints(target_root); + locallow_candidates.retain(|path| path_matches_game_hints(path, &hints)); + } + all.extend(locallow_candidates); + } + if let Some(root) = game_root { + let bepinex_log = root.join("BepInEx").join("LogOutput.log"); + if bepinex_log.is_file() { + all.push(bepinex_log); + } + } + + let deduped = dedupe_paths(all)?; + let mut newest: Option<(SystemTime, PathBuf)> = None; + for path in deduped { + let Ok(modified) = fs::metadata(&path).and_then(|metadata| metadata.modified()) else { + continue; + }; + match &newest { + Some((current, _)) if *current >= modified => {} + _ => newest = Some((modified, path)), + } + } + Ok(newest.map(|(_, path)| path)) +} + +fn dedupe_paths(paths: Vec) -> Result, UnitySupportError> { + let mut seen = BTreeSet::::new(); + let mut deduped = Vec::new(); + for path in paths { + let key = path.display().to_string().to_ascii_lowercase(); + if seen.insert(key) { + deduped.push(path); + } + } + if deduped.iter().any(|path| !path.exists()) { + return Err(UnitySupportError::message( + "one or more requested log paths do not exist".to_string(), + )); + } + Ok(deduped) +} + +fn locallow_root() -> Option { + let user_profile = std::env::var_os("USERPROFILE")?; + let root = PathBuf::from(user_profile).join("AppData").join("LocalLow"); + root.is_dir().then_some(root) +} + +fn game_root_log_hints(game_root: &Path) -> Vec { + let mut hints = Vec::new(); + if let Some(name) = game_root.file_name().and_then(|value| value.to_str()) { + hints.push(normalize_game_hint(name)); + } + if let Ok(canonical_root) = fs::canonicalize(game_root) + && let Some(name) = canonical_root.file_name().and_then(|value| value.to_str()) + { + hints.push(normalize_game_hint(name)); + } + hints.retain(|hint| !hint.is_empty()); + hints.sort(); + hints.dedup(); + hints +} + +fn path_matches_game_hints(path: &Path, hints: &[String]) -> bool { + if hints.is_empty() { + return true; + } + let normalized_path = normalize_game_hint(&path.display().to_string()); + hints + .iter() + .any(|hint| !hint.is_empty() && normalized_path.contains(hint)) +} + +fn normalize_game_hint(value: &str) -> String { + value + .chars() + .filter(char::is_ascii_alphanumeric) + .flat_map(char::to_lowercase) + .collect() +} + +fn scan_for_common_logs( + root: &Path, + depth_remaining: usize, + results: &mut Vec, +) -> Result<(), UnitySupportError> { + if depth_remaining == 0 { + return Ok(()); + } + let entries = fs::read_dir(root).map_err(|error| { + UnitySupportError::message(format!("failed to read {}: {error}", root.display())) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + UnitySupportError::message(format!( + "failed to read child entry under {}: {error}", + root.display() + )) + })?; + let path = entry.path(); + if path.is_dir() { + scan_for_common_logs(&path, depth_remaining - 1, results)?; + continue; + } + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if matches!(name, "Player.log" | "output_log.txt" | "LogOutput.log") { + results.push(path); + } + } + Ok(()) +} + +fn bepinex_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^\[(Message|Info|Warning|Error)\s*:\s*(.*?)\]\s*(.+)$") + .expect("valid BepInEx regex") + }) +} + +fn exception_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^([A-Za-z0-9_.]+(?:Exception|Error|Failure))(?::\s*(.+))?$") + .expect("valid exception regex") + }) +} + +fn embedded_exception_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"([A-Za-z0-9_.]+(?:Exception|Error|Failure))\s*(?:-|:)\s*(.+)$") + .expect("valid embedded exception regex") + }) +} + +fn quoted_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r#"'[^']*'|"[^"]*""#).expect("valid quoted regex")) +} + +fn hex_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"\b0x[0-9A-Fa-f]+\b").expect("valid hex regex")) +} + +fn number_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"\b\d+\b").expect("valid number regex")) +} + +fn path_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"[A-Za-z]:[\\/][^\s]+").expect("valid path regex")) +} + +#[cfg(test)] +mod tests { + use super::*; + use filetime::{FileTime, set_file_mtime}; + use tempfile::tempdir; + + fn set_mtime(path: &Path, unix_seconds: i64) { + set_file_mtime(path, FileTime::from_unix_time(unix_seconds, 0)).expect("set file mtime"); + } + + #[test] + fn parse_incident_modes_and_stack_modes_validate_public_flags() { + assert_eq!( + parse_incident_group_mode("incident").expect("group"), + IncidentGroupMode::Incident + ); + assert_eq!(parse_stack_mode("full").expect("stack"), StackMode::Full); + assert!(parse_incident_group_mode("invalid").is_err()); + assert!(parse_stack_mode("invalid").is_err()); + } + + #[test] + fn analyze_text_groups_game_and_mod_incidents() { + let report = analyze_log_text( + "player.log", + "[Error :Solar Expanse Trainer] Pipe server loop failed: System.Threading.ThreadAbortException: Thread was being aborted.\nArgumentNullException: Value cannot be null. | Parameter name: source\nat Game.AI.ContractHasObjective.OnUpdate ()\nArgumentNullException: Value cannot be null. | Parameter name: source\nat Game.AI.ContractHasObjective.OnUpdate ()\n", + UnityLogKind::Player, + &UnityDiagOptions::default(), + ); + assert_eq!(report.summary.log_count, 1); + assert!( + report + .incidents + .iter() + .any(|incident| incident.domain == "mod") + ); + assert!( + report + .incidents + .iter() + .any(|incident| incident.domain == "game" && incident.count == 2) + ); + } + + #[test] + fn analyze_text_marks_pipe_close_waitforconnection_as_shutdown_noise() { + let report = analyze_log_text( + "bepinex.log", + "System.IO.IOException: 管道正在被关闭。\nat System.IO.Pipes.NamedPipeServerStream.WaitForConnection ()\n", + UnityLogKind::Bepinex, + &UnityDiagOptions::default(), + ); + assert_eq!(report.incidents.len(), 1); + assert!(report.incidents[0].likely_shutdown_noise); + } + + #[test] + fn latest_discovery_prefers_newest_log() { + let temp = tempdir().expect("tempdir"); + let locallow = temp + .path() + .join("AppData") + .join("LocalLow") + .join("Studio") + .join("Game"); + fs::create_dir_all(&locallow).expect("locallow"); + let older = locallow.join("Player.log"); + fs::write(&older, "older").expect("older"); + set_mtime(&older, 1_700_000_000); + + let game_root = temp.path().join("GameRoot"); + let bepinex = game_root.join("BepInEx"); + fs::create_dir_all(&bepinex).expect("bepinex"); + let newer = bepinex.join("LogOutput.log"); + fs::write(&newer, "newer").expect("newer"); + set_mtime(&newer, 1_700_000_002); + + let discovered = + latest_candidate_path_with_locallow(&[], Some(&game_root), Some(&locallow)) + .expect("discover"); + + assert_eq!(discovered, Some(newer)); + } + + #[test] + fn game_root_latest_ignores_unrelated_locallow_logs() { + let temp = tempdir().expect("tempdir"); + let locallow_root = temp.path().join("AppData").join("LocalLow"); + let target_locallow = locallow_root.join("SpaceOps").join("Solar Expanse"); + let unrelated_locallow = locallow_root.join("OtherStudio").join("Another Game"); + fs::create_dir_all(&target_locallow).expect("target locallow"); + fs::create_dir_all(&unrelated_locallow).expect("unrelated locallow"); + + let target_log = target_locallow.join("Player.log"); + fs::write(&target_log, "target").expect("target log"); + set_mtime(&target_log, 1_700_000_000); + let unrelated_log = unrelated_locallow.join("Player.log"); + fs::write(&unrelated_log, "unrelated").expect("unrelated log"); + set_mtime(&unrelated_log, 1_700_000_002); + + let game_root = temp.path().join("Solar Expanse"); + fs::create_dir_all(game_root.join("BepInEx")).expect("game root bepinex"); + + let discovered = + latest_candidate_path_with_locallow(&[], Some(&game_root), Some(&locallow_root)) + .expect("discover") + .expect("matching log"); + + assert_eq!(discovered, target_log); + } + + #[test] + fn game_root_latest_supports_dot_path_hints() { + let temp = tempdir().expect("tempdir"); + let locallow_root = temp.path().join("AppData").join("LocalLow"); + let target_locallow = locallow_root.join("SpaceOps").join("Solar Expanse"); + let unrelated_locallow = locallow_root.join("OtherStudio").join("Another Game"); + fs::create_dir_all(&target_locallow).expect("target locallow"); + fs::create_dir_all(&unrelated_locallow).expect("unrelated locallow"); + + let target_log = target_locallow.join("Player.log"); + fs::write(&target_log, "target").expect("target log"); + set_mtime(&target_log, 1_700_000_000); + let unrelated_log = unrelated_locallow.join("Player.log"); + fs::write(&unrelated_log, "unrelated").expect("unrelated log"); + set_mtime(&unrelated_log, 1_700_000_002); + + let game_root = temp.path().join("Solar Expanse"); + fs::create_dir_all(&game_root).expect("game root"); + + let discovered = latest_candidate_path_with_locallow( + &[], + Some(&game_root.join(".")), + Some(&locallow_root), + ) + .expect("discover") + .expect("matching log"); + + assert_eq!(discovered, target_log); + } +} diff --git a/crates/unitysupport/src/probe.rs b/crates/unitysupport/src/probe.rs new file mode 100644 index 0000000..49ab65c --- /dev/null +++ b/crates/unitysupport/src/probe.rs @@ -0,0 +1,1729 @@ +//! Windows-only Unity runtime probe helpers shared by `unityprobe`. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use windowsupport::{ProcessDescriptor, snapshot_processes}; + +use crate::UnitySupportError; + +/// Stable named pipe used by the installed Unity bridge. +pub const BRIDGE_PIPE_NAME: &str = "MercuryUnityProbePipe_v1"; + +const INSTALL_DIR_NAME: &str = "Mercury.UnityProbe"; +const DLL_NAME: &str = "Mercury.UnityProbe.dll"; +const MANIFEST_NAME: &str = "mercury-unityprobe.install.json"; +const SOURCE_DIR_NAME: &str = "Source"; +const BUILD_SOURCE_NAME: &str = "MercuryUnityProbeBuild.cs"; +const TEMPLATE_PROTOCOL_NAME: &str = "MercuryUnityProbeProtocol.cs"; +const TEMPLATE_PLUGIN_NAME: &str = "MercuryUnityProbePlugin.cs"; +const TEMPLATE_FILE_NAMES: [&str; 2] = [TEMPLATE_PROTOCOL_NAME, TEMPLATE_PLUGIN_NAME]; +const PLUGIN_GUID: &str = "local.mercury.unityprobe"; +const PLUGIN_NAME: &str = "Mercury Unity Probe"; +const PIPE_CONNECT_ATTEMPTS: usize = 5; +const PIPE_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(75); +const ERROR_FILE_NOT_FOUND: i32 = 2; +const ERROR_BROKEN_PIPE: i32 = 109; +const ERROR_SEM_TIMEOUT: i32 = 121; +const ERROR_PIPE_BUSY: i32 = 231; +const ERROR_NO_DATA: i32 = 232; + +/// Reports whether the bridge workflow is supported on the current platform. +#[must_use] +pub const fn windows_supported() -> bool { + cfg!(windows) +} + +/// Stable summary returned after installing the bridge into a game root. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct InstallReport { + /// Whether the command is supported on the current host OS. + pub windows_supported: bool, + /// Target game root passed to the installer. + pub game_root: String, + /// Explicit `BepInEx` plugin directory used by the install. + pub plugin_dir: String, + /// Compiled bridge assembly path. + pub dll_path: String, + /// Install manifest written next to the bridge. + pub manifest_path: String, + /// Copied bridge source directory under the plugin install. + pub source_dir: String, + /// Named pipe exposed by the bridge at runtime. + pub pipe_name: String, + /// Local `C#` compiler used for the build. + pub compiler_path: String, + /// Managed reference directory discovered under the game root. + pub managed_dir: String, + /// Source files copied from the shared template. + pub source_files: Vec, + /// Reference files used when compiling the bridge assembly. + pub reference_paths: Vec, +} + +/// Stable summary returned after uninstalling the bridge from a game root. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UninstallReport { + /// Whether the command is supported on the current host OS. + pub windows_supported: bool, + /// Target game root passed to uninstall. + pub game_root: String, + /// Explicit `BepInEx` plugin directory used by the install. + pub plugin_dir: String, + /// Whether any bridge-owned file or directory was removed. + pub removed: bool, +} + +/// Stable local status for a game-root install plus the live named-pipe state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StatusReport { + /// Whether the command is supported on the current host OS. + pub windows_supported: bool, + /// Optional game root whose install state was inspected. + pub game_root: Option, + /// Explicit `BepInEx` plugin directory used by the install, when known. + pub plugin_dir: Option, + /// Compiled bridge assembly path, when known. + pub dll_path: Option, + /// Install manifest path, when known. + pub manifest_path: Option, + /// Whether an explicit bridge install is present under the inspected game root. + pub installed: bool, + /// Whether the main game process appears to be running for the inspected root. + pub game_process_running: Option, + /// Process identifier for the detected main game process when available. + pub game_process_id: Option, + /// Executable image name for the detected main game process when available. + pub game_process_name: Option, + /// Named pipe exposed by the bridge at runtime. + pub pipe_name: String, + /// Whether the named pipe path is currently reachable. + pub pipe_reachable: bool, + /// Live runtime status from the bridge when the pipe is reachable. + pub runtime_status: Option, + /// Discovered `C#` compiler path when available. + pub compiler_path: Option, + /// Discovered managed directory when available. + pub managed_dir: Option, + /// Non-fatal issues discovered while probing status. + pub problems: Vec, +} + +/// Common response envelope returned by the named-pipe JSON protocol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProbeEnvelope { + /// Whether the bridge handled the command successfully. + pub ok: bool, + /// Response kind matching the request command. + pub kind: String, + /// Structured payload for successful responses. + pub data: Option, + /// Error detail for unsuccessful responses. + pub error: Option, +} + +/// Live runtime bridge status returned from the Unity process. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeStatusData { + /// Stable pipe name bound by the plugin. + pub pipe_name: String, + /// Bridge/plugin version string. + pub plugin_version: String, + /// Unity engine version reported by the runtime. + pub unity_version: String, + /// Current Unity process identifier. + pub process_id: u32, + /// Count of scenes currently loaded. + pub scene_count: usize, + /// Names of loaded scenes in index order. + pub loaded_scene_names: Vec, +} + +/// Scene list payload returned by the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScenesData { + /// Loaded scenes known to the runtime. + pub scenes: Vec, +} + +/// Stable scene summary returned by the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SceneRecord { + /// Scene display name. + pub name: String, + /// Scene asset path when available. + pub path: String, + /// Unity build index. + pub build_index: i32, + /// Whether the scene is currently loaded. + pub loaded: bool, + /// Count of root objects in the scene. + pub root_count: usize, +} + +/// Find payload returned by the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FindData { + /// User-provided find query echoed by the bridge. + pub query: String, + /// Matching runtime objects. + pub matches: Vec, +} + +/// Stable runtime object summary returned by the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectRecord { + /// Unity instance identifier. + pub instance_id: i32, + /// Unity object name. + pub name: String, + /// Full managed type name. + pub type_name: String, + /// Scene name when the object belongs to a scene. + pub scene_name: String, + /// Hierarchy path when the object belongs to a scene graph. + pub hierarchy_path: String, + /// Whether the object is currently active in hierarchy when applicable. + pub active: bool, +} + +/// Inspect payload returned by the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectData { + /// Summary of the inspected Unity object. + pub object: ObjectRecord, + /// Components on the underlying `GameObject` when applicable. + pub components: Vec, + /// Read-only field values exposed for the object. + pub fields: Vec, + /// Read-only property values exposed for the object. + pub properties: Vec, +} + +/// Static inspection payload returned by the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StaticData { + /// Full managed type name that was inspected. + pub type_name: String, + /// Assembly name that resolved the target type. + pub assembly_name: String, + /// Static field values exposed for the type. + pub fields: Vec, + /// Static property values exposed for the type. + pub properties: Vec, +} + +/// Stable component summary attached to an inspected object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComponentRecord { + /// Unity instance identifier. + pub instance_id: i32, + /// Full managed type name. + pub type_name: String, +} + +/// Read-only value surfaced from a field or property. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValueMember { + /// Member name. + pub name: String, + /// Declared managed type name. + pub declared_type: String, + /// Stringified value captured by the bridge. + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstallLayout { + game_root: PathBuf, + plugin_dir: PathBuf, + dll_path: PathBuf, + manifest_path: PathBuf, + source_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct InstallManifest { + plugin_guid: String, + plugin_name: String, + plugin_version: String, + pipe_name: String, + game_root: String, + plugin_dir: String, + dll_path: String, + manifest_path: String, + source_dir: String, + compiler_path: String, + managed_dir: String, + source_files: Vec, + reference_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ProbeRequest<'a> { + command: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + query: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + instance_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + type_name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, +} + +/// Builds, compiles, and explicitly installs the read-only bridge into a game root. +/// +/// # Errors +/// +/// Returns an error when the current platform is unsupported, the game layout is missing +/// required `BepInEx` or managed references, template sources are unavailable, or the local `C#` +/// compiler fails. +pub fn install_bridge(game_root: &Path) -> Result { + ensure_windows()?; + + let layout = install_layout(game_root); + validate_install_layout(game_root)?; + let compiler_path = find_csharp_compiler()?; + let managed_dir = find_managed_dir(game_root)?; + let reference_paths = collect_reference_paths(game_root, &managed_dir, &compiler_path)?; + + fs::create_dir_all(&layout.plugin_dir).map_err(|error| { + UnitySupportError::message(format!( + "failed to create {}: {error}", + layout.plugin_dir.display() + )) + })?; + if layout.source_dir.exists() { + fs::remove_dir_all(&layout.source_dir).map_err(|error| { + UnitySupportError::message(format!( + "failed to refresh {}: {error}", + layout.source_dir.display() + )) + })?; + } + fs::create_dir_all(&layout.source_dir).map_err(|error| { + UnitySupportError::message(format!( + "failed to create {}: {error}", + layout.source_dir.display() + )) + })?; + + let mut source_files = copy_template_sources(&layout.source_dir)?; + let build_source = layout.source_dir.join(BUILD_SOURCE_NAME); + fs::write(&build_source, render_build_source()).map_err(|error| { + UnitySupportError::message(format!( + "failed to write {}: {error}", + build_source.display() + )) + })?; + source_files.push(build_source); + + compile_bridge( + &compiler_path, + &layout.dll_path, + &source_files, + &reference_paths, + )?; + + let game_root = path_to_string(&layout.game_root); + let plugin_dir = path_to_string(&layout.plugin_dir); + let dll_path = path_to_string(&layout.dll_path); + let manifest_path = path_to_string(&layout.manifest_path); + let source_dir = path_to_string(&layout.source_dir); + let compiler_path = path_to_string(&compiler_path); + let managed_dir = path_to_string(&managed_dir); + + let manifest = InstallManifest { + plugin_guid: PLUGIN_GUID.to_string(), + plugin_name: PLUGIN_NAME.to_string(), + plugin_version: env!("CARGO_PKG_VERSION").to_string(), + pipe_name: BRIDGE_PIPE_NAME.to_string(), + game_root: game_root.clone(), + plugin_dir: plugin_dir.clone(), + dll_path: dll_path.clone(), + manifest_path: manifest_path.clone(), + source_dir: source_dir.clone(), + compiler_path: compiler_path.clone(), + managed_dir: managed_dir.clone(), + source_files: display_paths(&source_files), + reference_paths: display_paths(&reference_paths), + }; + write_manifest(&layout.manifest_path, &manifest)?; + + Ok(InstallReport { + windows_supported: true, + game_root, + plugin_dir, + dll_path, + manifest_path, + source_dir, + pipe_name: BRIDGE_PIPE_NAME.to_string(), + compiler_path, + managed_dir, + source_files: manifest.source_files, + reference_paths: manifest.reference_paths, + }) +} + +/// Removes the explicitly installed bridge from a game root. +/// +/// # Errors +/// +/// Returns an error when the current platform is unsupported or bridge-owned files cannot be +/// removed. +pub fn uninstall_bridge(game_root: &Path) -> Result { + ensure_windows()?; + + let layout = install_layout(game_root); + let removed_manifest = remove_file_if_exists(&layout.manifest_path)?; + let removed_dll = remove_file_if_exists(&layout.dll_path)?; + let removed_source = remove_dir_all_if_exists(&layout.source_dir)?; + let removed_plugin_dir = + if layout.plugin_dir.exists() && is_directory_empty(&layout.plugin_dir)? { + fs::remove_dir(&layout.plugin_dir).map_err(|error| { + UnitySupportError::message(format!( + "failed to remove {}: {error}", + layout.plugin_dir.display() + )) + })?; + true + } else { + false + }; + let removed = removed_manifest || removed_dll || removed_source || removed_plugin_dir; + + Ok(UninstallReport { + windows_supported: true, + game_root: path_to_string(&layout.game_root), + plugin_dir: path_to_string(&layout.plugin_dir), + removed, + }) +} + +/// Reports explicit install state and current named-pipe reachability. +/// +/// # Errors +/// +/// Returns an error only when the inspected game-root path cannot be read due to I/O issues. +pub fn status_bridge(game_root: Option<&Path>) -> Result { + if !windows_supported() { + let layout = game_root.map(install_layout); + return Ok(StatusReport { + windows_supported: false, + game_root: game_root.map(path_to_string), + plugin_dir: layout.as_ref().map(|layout| path_to_string(&layout.plugin_dir)), + dll_path: layout.as_ref().map(|layout| path_to_string(&layout.dll_path)), + manifest_path: layout + .as_ref() + .map(|layout| path_to_string(&layout.manifest_path)), + installed: false, + game_process_running: None, + game_process_id: None, + game_process_name: None, + pipe_name: BRIDGE_PIPE_NAME.to_string(), + pipe_reachable: false, + runtime_status: None, + compiler_path: None, + managed_dir: None, + problems: vec![ + "unityprobe currently only works on Windows with an explicitly installed BepInEx bridge".to_string(), + ], + }); + } + + let mut plugin_dir = None; + let mut dll_path = None; + let mut manifest_path = None; + let mut compiler_path = find_csharp_compiler() + .ok() + .map(|path| path_to_string(&path)); + let mut managed_dir = None; + let mut installed = false; + let mut game_process_running = None; + let mut game_process_id = None; + let mut game_process_name = None; + let mut problems = Vec::new(); + + if let Some(root) = game_root { + let layout = install_layout(root); + plugin_dir = Some(path_to_string(&layout.plugin_dir)); + dll_path = Some(path_to_string(&layout.dll_path)); + manifest_path = Some(path_to_string(&layout.manifest_path)); + installed = + layout.dll_path.exists() && layout.manifest_path.exists() && layout.source_dir.exists(); + if layout.manifest_path.exists() { + if let Ok(manifest) = read_manifest(&layout.manifest_path) { + compiler_path = Some(manifest.compiler_path); + managed_dir = Some(manifest.managed_dir); + } + } + if managed_dir.is_none() { + match find_managed_dir(root) { + Ok(path) => managed_dir = Some(path_to_string(&path)), + Err(error) => problems.push(error.to_string()), + } + } + let detected_process = detect_running_game_process(root); + game_process_running = Some(detected_process.is_some()); + if let Some(process) = detected_process { + game_process_id = Some(process.pid); + game_process_name = Some(process.image_name); + } + } + + let (pipe_reachable, runtime_status) = probe_runtime_status(&mut problems); + if game_root.is_some() && !pipe_reachable { + match game_process_running { + Some(false) => problems.push( + "game process does not appear to be running yet, so the bridge pipe cannot connect" + .to_string(), + ), + Some(true) if installed => problems.push( + "game process is running but the bridge pipe is not reachable yet; confirm the Mercury Unity Probe plugin loaded in BepInEx" + .to_string(), + ), + _ => {} + } + } + + Ok(StatusReport { + windows_supported: true, + game_root: game_root.map(path_to_string), + plugin_dir, + dll_path, + manifest_path, + installed, + game_process_running, + game_process_id, + game_process_name, + pipe_name: BRIDGE_PIPE_NAME.to_string(), + pipe_reachable, + runtime_status, + compiler_path, + managed_dir, + problems, + }) +} + +fn detect_running_game_process(game_root: &Path) -> Option { + let candidates = infer_game_executable_candidates(game_root); + if candidates.is_empty() { + return None; + } + select_running_game_process(&snapshot_processes(), &candidates).cloned() +} + +fn infer_game_executable_candidates(game_root: &Path) -> Vec { + let mut candidates = Vec::new(); + let mut seen = BTreeSet::::new(); + if let Ok(entries) = fs::read_dir(game_root) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if !name.ends_with("_Data") { + continue; + } + let stem = &name[..name.len() - "_Data".len()]; + let exe_path = game_root.join(format!("{stem}.exe")); + if exe_path.exists() { + let key = lowercase_display_path(&exe_path); + if seen.insert(key) { + candidates.push(exe_path); + } + } + } + } + if let Ok(entries) = fs::read_dir(game_root) { + for entry in entries.flatten() { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if path.extension().and_then(|value| value.to_str()) != Some("exe") + || is_unity_helper_executable(file_name) + { + continue; + } + let key = lowercase_display_path(&path); + if seen.insert(key) { + candidates.push(path); + } + } + } + candidates +} + +fn select_running_game_process<'a>( + processes: &'a [ProcessDescriptor], + candidates: &[PathBuf], +) -> Option<&'a ProcessDescriptor> { + let normalized_candidates = candidates + .iter() + .map(|path| lowercase_display_path(path)) + .collect::>(); + let candidate_names = candidates + .iter() + .filter_map(|path| path.file_name().and_then(|value| value.to_str())) + .map(str::to_ascii_lowercase) + .collect::>(); + + processes.iter().find(|process| { + let exe_matches = process + .exe + .as_ref() + .is_some_and(|exe| normalized_candidates.contains(&exe.to_ascii_lowercase())); + let name_matches = candidate_names.contains(&process.image_name.to_ascii_lowercase()); + exe_matches || name_matches + }) +} + +fn lowercase_display_path(path: &Path) -> String { + path_to_string(path).to_ascii_lowercase() +} + +fn is_unity_helper_executable(file_name: &str) -> bool { + file_name.eq_ignore_ascii_case("UnityCrashHandler64.exe") + || file_name.eq_ignore_ascii_case("UnityCrashHandler32.exe") +} + +/// Queries live bridge runtime status through the named-pipe JSON protocol. +/// +/// # Errors +/// +/// Returns an error when the bridge is unavailable, the pipe cannot be read, or the bridge +/// returns an error response. +pub fn query_runtime_status() -> Result, UnitySupportError> { + send_request(&ProbeRequest { + command: "status", + query: None, + instance_id: None, + type_name: None, + limit: None, + }) +} + +/// Lists loaded scenes from the running Unity bridge. +/// +/// # Errors +/// +/// Returns an error when the bridge is unavailable or the request fails. +pub fn query_scenes() -> Result, UnitySupportError> { + send_request(&ProbeRequest { + command: "scenes", + query: None, + instance_id: None, + type_name: None, + limit: None, + }) +} + +/// Finds runtime objects by type or name through the running bridge. +/// +/// # Errors +/// +/// Returns an error when the bridge is unavailable or the request fails. +pub fn find_objects( + query: &str, + limit: usize, +) -> Result, UnitySupportError> { + send_request(&ProbeRequest { + command: "find", + query: Some(query), + instance_id: None, + type_name: None, + limit: Some(limit), + }) +} + +/// Inspects one runtime object through the running bridge. +/// +/// # Errors +/// +/// Returns an error when the bridge is unavailable or the request fails. +pub fn inspect_object(instance_id: i32) -> Result, UnitySupportError> { + send_request(&ProbeRequest { + command: "inspect", + query: None, + instance_id: Some(instance_id), + type_name: None, + limit: None, + }) +} + +/// Inspects static fields and properties for one managed type through the bridge. +/// +/// # Errors +/// +/// Returns an error when the bridge is unavailable or the request fails. +pub fn inspect_static(type_name: &str) -> Result, UnitySupportError> { + send_request(&ProbeRequest { + command: "static", + query: None, + instance_id: None, + type_name: Some(type_name), + limit: None, + }) +} + +fn send_request(request: &ProbeRequest<'_>) -> Result, UnitySupportError> +where + T: DeserializeOwned, +{ + ensure_windows()?; + let pipe_path = pipe_path(); + let mut stream = open_pipe_stream(&pipe_path)?; + let mut request_body = serde_json::to_string(request).map_err(|error| { + UnitySupportError::message(format!("failed to encode request: {error}")) + })?; + request_body.push('\n'); + stream.write_all(request_body.as_bytes()).map_err(|error| { + UnitySupportError::message(format_pipe_io_error("write to", &pipe_path, &error)) + })?; + stream.flush().map_err(|error| { + UnitySupportError::message(format_pipe_io_error("flush", &pipe_path, &error)) + })?; + + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|error| { + UnitySupportError::message(format_pipe_io_error("read from", &pipe_path, &error)) + })?; + if response.trim().is_empty() { + return Err(UnitySupportError::message( + "bridge returned an empty response".to_string(), + )); + } + + let envelope = serde_json::from_str::>(response.trim()).map_err(|error| { + UnitySupportError::message(format!("failed to parse bridge response: {error}")) + })?; + if envelope.ok { + Ok(envelope) + } else { + Err(UnitySupportError::message( + envelope + .error + .unwrap_or_else(|| "bridge request failed".to_string()), + )) + } +} + +fn probe_runtime_status(problems: &mut Vec) -> (bool, Option) { + finalize_runtime_status(query_runtime_status(), pipe_exists(), problems) +} + +fn finalize_runtime_status( + result: Result, UnitySupportError>, + pipe_present: bool, + problems: &mut Vec, +) -> (bool, Option) { + match result { + Ok(status) => (true, status.data), + Err(error) if pipe_present => { + problems.push(format!( + "bridge pipe exists but did not answer cleanly: {error}" + )); + (false, None) + } + Err(_) => (false, None), + } +} + +fn open_pipe_stream(pipe_path: &Path) -> Result { + for attempt in 0..PIPE_CONNECT_ATTEMPTS { + match fs::OpenOptions::new() + .read(true) + .write(true) + .open(pipe_path) + { + Ok(stream) => return Ok(stream), + Err(error) => { + let retryable = is_retryable_pipe_connect_error(&error); + let exhausted = attempt + 1 == PIPE_CONNECT_ATTEMPTS; + if !retryable || exhausted { + return Err(UnitySupportError::message(format_pipe_connect_error( + pipe_path, &error, + ))); + } + thread::sleep(PIPE_CONNECT_RETRY_DELAY); + } + } + } + + Err(UnitySupportError::message(format!( + "failed to connect to {} after {} attempts", + pipe_path.display(), + PIPE_CONNECT_ATTEMPTS + ))) +} + +fn is_retryable_pipe_connect_error(error: &std::io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(ERROR_FILE_NOT_FOUND | ERROR_PIPE_BUSY | ERROR_SEM_TIMEOUT) + ) +} + +fn format_pipe_connect_error(pipe_path: &Path, error: &std::io::Error) -> String { + match error.raw_os_error() { + Some(ERROR_PIPE_BUSY) => format!( + "failed to connect to {}: {error}; the bridge pipe is busy, so retry in a moment", + pipe_path.display() + ), + Some(ERROR_FILE_NOT_FOUND) => format!( + "failed to connect to {}: {error}; the bridge pipe is not available yet, so retry in a moment or install the bridge into BepInEx\\plugins and start the game", + pipe_path.display() + ), + Some(ERROR_SEM_TIMEOUT) => format!( + "failed to connect to {}: {error}; the bridge did not answer in time, so retry in a moment", + pipe_path.display() + ), + _ => format!( + "failed to connect to {}: {error}; install the bridge into BepInEx\\plugins and start the game", + pipe_path.display() + ), + } +} + +fn format_pipe_io_error(action: &str, pipe_path: &Path, error: &std::io::Error) -> String { + match error.raw_os_error() { + Some(ERROR_BROKEN_PIPE | ERROR_NO_DATA) => format!( + "failed to {action} {}: {error}; the bridge disconnected mid-request, so retry in a moment", + pipe_path.display() + ), + Some(ERROR_SEM_TIMEOUT) => format!( + "failed to {action} {}: {error}; the bridge timed out, so retry in a moment", + pipe_path.display() + ), + _ => format!("failed to {action} {}: {error}", pipe_path.display()), + } +} + +fn ensure_windows() -> Result<(), UnitySupportError> { + if windows_supported() { + Ok(()) + } else { + Err(UnitySupportError::message( + "unityprobe currently only works on Windows with an explicitly installed BepInEx bridge" + .to_string(), + )) + } +} + +fn validate_install_layout(game_root: &Path) -> Result<(), UnitySupportError> { + let bepinex_dll = game_root.join("BepInEx").join("core").join("BepInEx.dll"); + let mut issues = Vec::new(); + if !bepinex_dll.exists() { + issues.push(format!("missing {}", bepinex_dll.display())); + } + if find_managed_dir(game_root).is_err() { + issues.push(format!( + "missing {}_Data\\Managed directory under {}", + "*", + game_root.display() + )); + } + if issues.is_empty() { + Ok(()) + } else { + Err(UnitySupportError::message(format!( + "cannot install bridge into {}: {}", + game_root.display(), + issues.join("; ") + ))) + } +} + +fn install_layout(game_root: &Path) -> InstallLayout { + let plugin_dir = game_root + .join("BepInEx") + .join("plugins") + .join(INSTALL_DIR_NAME); + InstallLayout { + game_root: game_root.to_path_buf(), + dll_path: plugin_dir.join(DLL_NAME), + manifest_path: plugin_dir.join(MANIFEST_NAME), + source_dir: plugin_dir.join(SOURCE_DIR_NAME), + plugin_dir, + } +} + +fn template_dir() -> Result { + let candidates = template_dir_candidates(); + for candidate in &candidates { + if candidate.exists() { + return Ok(candidate.clone()); + } + } + + let searched = candidates + .iter() + .map(|candidate| candidate.display().to_string()) + .collect::>() + .join(", "); + Err(UnitySupportError::message(format!( + "missing bridge template source directory; searched {searched}" + ))) +} + +fn template_dir_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Ok(current_exe) = std::env::current_exe() { + extend_template_dir_candidates(&mut candidates, ¤t_exe); + } + if let Ok(current_dir) = std::env::current_dir() { + for directory in current_dir.ancestors() { + push_unique_path( + &mut candidates, + directory.join("support").join("unityprobe-bridge"), + ); + } + } + candidates +} + +fn extend_template_dir_candidates(candidates: &mut Vec, current_exe: &Path) { + let Some(bin_dir) = current_exe.parent() else { + return; + }; + let Some(root_dir) = bin_dir.parent() else { + return; + }; + + push_unique_path( + candidates, + root_dir + .join("share") + .join("mercury-toolbox") + .join("support") + .join("unityprobe-bridge"), + ); + push_unique_path( + candidates, + root_dir.join("support").join("unityprobe-bridge"), + ); +} + +fn push_unique_path(candidates: &mut Vec, candidate: PathBuf) { + if candidates.iter().all(|path| path != &candidate) { + candidates.push(candidate); + } +} + +fn copy_template_sources(target_dir: &Path) -> Result, UnitySupportError> { + let root = template_dir()?; + let mut files = Vec::new(); + for name in TEMPLATE_FILE_NAMES { + let source = root.join(name); + if !source.exists() { + return Err(UnitySupportError::message(format!( + "missing bridge template source {}", + source.display() + ))); + } + let destination = target_dir.join(name); + fs::copy(&source, &destination).map_err(|error| { + UnitySupportError::message(format!( + "failed to copy {} to {}: {error}", + source.display(), + destination.display() + )) + })?; + files.push(destination); + } + Ok(files) +} + +fn render_build_source() -> String { + format!( + "namespace Mercury.UnityProbe\r\n{{\r\n internal static class MercuryUnityProbeBuild\r\n {{\r\n internal const string PluginGuid = \"{PLUGIN_GUID}\";\r\n internal const string PluginName = \"{PLUGIN_NAME}\";\r\n internal const string PluginVersion = \"{}\";\r\n internal const string PipeName = \"{BRIDGE_PIPE_NAME}\";\r\n }}\r\n}}\r\n", + env!("CARGO_PKG_VERSION") + ) +} + +fn find_managed_dir(game_root: &Path) -> Result { + let entries = fs::read_dir(game_root).map_err(|error| { + UnitySupportError::message(format!("failed to read {}: {error}", game_root.display())) + })?; + let mut matches = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + UnitySupportError::message(format!( + "failed to read child entry under {}: {error}", + game_root.display() + )) + })?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.ends_with("_Data") { + continue; + } + let managed = path.join("Managed"); + if managed.is_dir() { + matches.push(managed); + } + } + matches.sort(); + match matches.as_slice() { + [managed] => Ok(managed.clone()), + [] => Err(UnitySupportError::message(format!( + "missing {}_Data\\Managed directory under {}", + "*", + game_root.display() + ))), + _ => Err(UnitySupportError::message(format!( + "found multiple *_Data\\Managed directories under {}", + game_root.display() + ))), + } +} + +fn find_csharp_compiler() -> Result { + let windir = std::env::var_os("WINDIR").unwrap_or_else(|| OsString::from(r"C:\Windows")); + let windir = PathBuf::from(windir); + let candidates = [ + windir + .join("Microsoft.NET") + .join("Framework64") + .join("v4.0.30319") + .join("csc.exe"), + windir + .join("Microsoft.NET") + .join("Framework") + .join("v4.0.30319") + .join("csc.exe"), + ]; + if let Some(found) = candidates.iter().find(|path| path.exists()) { + return Ok(found.clone()); + } + + let output = Command::new("where") + .arg("csc.exe") + .output() + .map_err(|error| { + UnitySupportError::message(format!("failed to search for csc.exe: {error}")) + })?; + if !output.status.success() { + return Err(UnitySupportError::message( + "could not locate a local C# compiler; expected csc.exe under .NET Framework v4.0.30319 or on PATH".to_string(), + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| { + UnitySupportError::message( + "could not locate a local C# compiler; expected csc.exe under .NET Framework v4.0.30319 or on PATH".to_string(), + ) + }) +} + +fn collect_reference_paths( + game_root: &Path, + managed_dir: &Path, + compiler_path: &Path, +) -> Result, UnitySupportError> { + let framework_dir = compiler_path.parent().ok_or_else(|| { + UnitySupportError::message(format!( + "compiler path {} does not have a parent directory", + compiler_path.display() + )) + })?; + let bepinex = game_root.join("BepInEx").join("core").join("BepInEx.dll"); + let mut required = vec![ + bepinex, + framework_dir.join("System.dll"), + framework_dir.join("System.Core.dll"), + ]; + + let unity_candidates = [ + managed_dir.join("UnityEngine.dll"), + managed_dir.join("UnityEngine.CoreModule.dll"), + ]; + let mut unity_found = unity_candidates + .into_iter() + .filter(|path| path.exists()) + .collect::>(); + if unity_found.is_empty() { + return Err(UnitySupportError::message(format!( + "missing UnityEngine.dll or UnityEngine.CoreModule.dll under {}", + managed_dir.display() + ))); + } + required.append(&mut unity_found); + + let netstandard = managed_dir.join("netstandard.dll"); + if netstandard.exists() { + required.push(netstandard); + } + + let missing = required + .iter() + .filter(|path| !path.exists()) + .map(|path| path_to_string(path)) + .collect::>(); + if !missing.is_empty() { + return Err(UnitySupportError::message(format!( + "missing bridge compiler references: {}", + missing.join(", ") + ))); + } + + required.sort(); + required.dedup(); + Ok(required) +} + +fn compile_bridge( + compiler_path: &Path, + dll_path: &Path, + source_files: &[PathBuf], + reference_paths: &[PathBuf], +) -> Result<(), UnitySupportError> { + let mut command = Command::new(compiler_path); + command + .arg("/nologo") + .arg("/target:library") + .arg("/langversion:Default") + .arg(format!("/out:{}", dll_path.display())); + for source in source_files { + command.arg(source); + } + for reference in reference_paths { + command.arg(format!("/reference:{}", reference.display())); + } + + let output = command.output().map_err(|error| { + UnitySupportError::message(format!( + "failed to launch {}: {error}", + compiler_path.display() + )) + })?; + if output.status.success() { + Ok(()) + } else { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(UnitySupportError::message(format!( + "bridge compile failed with status {}{}{}", + output.status, + if stdout.is_empty() { + String::new() + } else { + format!("; stdout: {stdout}") + }, + if stderr.is_empty() { + String::new() + } else { + format!("; stderr: {stderr}") + } + ))) + } +} + +fn write_manifest(path: &Path, manifest: &InstallManifest) -> Result<(), UnitySupportError> { + let body = serde_json::to_vec_pretty(manifest).map_err(|error| { + UnitySupportError::message(format!("failed to encode manifest: {error}")) + })?; + fs::write(path, body).map_err(|error| { + UnitySupportError::message(format!("failed to write {}: {error}", path.display())) + }) +} + +fn read_manifest(path: &Path) -> Result { + let body = fs::read(path).map_err(|error| { + UnitySupportError::message(format!("failed to read {}: {error}", path.display())) + })?; + serde_json::from_slice(&body).map_err(|error| { + UnitySupportError::message(format!("failed to parse {}: {error}", path.display())) + }) +} + +fn is_directory_empty(path: &Path) -> Result { + let mut entries = fs::read_dir(path).map_err(|error| { + UnitySupportError::message(format!("failed to read {}: {error}", path.display())) + })?; + Ok(entries.next().is_none()) +} + +fn remove_file_if_exists(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + fs::remove_file(path).map_err(|error| { + UnitySupportError::message(format!("failed to remove {}: {error}", path.display())) + })?; + Ok(true) +} + +fn remove_dir_all_if_exists(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + fs::remove_dir_all(path).map_err(|error| { + UnitySupportError::message(format!("failed to remove {}: {error}", path.display())) + })?; + Ok(true) +} + +fn display_paths(paths: &[PathBuf]) -> Vec { + paths.iter().map(|path| path_to_string(path)).collect() +} + +fn path_to_string(path: &Path) -> String { + path.display().to_string() +} + +fn pipe_path() -> PathBuf { + PathBuf::from(format!(r"\\.\pipe\{BRIDGE_PIPE_NAME}")) +} + +fn pipe_exists() -> bool { + pipe_path().exists() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{suffix}")); + fs::create_dir_all(&path).expect("temp dir"); + path + } + + #[test] + fn install_layout_places_bridge_under_explicit_plugin_directory() { + let root = PathBuf::from(r"C:\Games\Example"); + let layout = install_layout(&root); + assert_eq!( + layout.plugin_dir, + root.join("BepInEx").join("plugins").join(INSTALL_DIR_NAME) + ); + assert_eq!(layout.dll_path, layout.plugin_dir.join(DLL_NAME)); + assert_eq!(layout.source_dir, layout.plugin_dir.join(SOURCE_DIR_NAME)); + } + + #[test] + fn build_source_contains_stable_metadata() { + let source = render_build_source(); + assert!(source.contains(BRIDGE_PIPE_NAME)); + assert!(source.contains(PLUGIN_GUID)); + assert!(source.contains(PLUGIN_NAME)); + assert!(source.contains(env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn template_sources_exist_in_support_directory() { + let root = template_dir().expect("template dir"); + for name in TEMPLATE_FILE_NAMES { + assert!( + root.join(name).exists(), + "missing {}", + root.join(name).display() + ); + } + } + + #[test] + fn template_sources_copy_into_install_source_directory() { + let target = unique_temp_dir("unitysupport-template-copy"); + let copied = copy_template_sources(&target).expect("copy templates"); + + assert_eq!(copied.len(), TEMPLATE_FILE_NAMES.len()); + for name in TEMPLATE_FILE_NAMES { + let path = target.join(name); + assert!(path.exists(), "missing copied {}", path.display()); + assert!( + fs::read_to_string(&path) + .expect("template") + .contains("Mercury"), + "template {} should contain Mercury bridge source", + path.display() + ); + } + } + + #[test] + fn bridge_template_limits_named_pipe_to_current_windows_user() { + let root = template_dir().expect("template dir"); + let source = fs::read_to_string(root.join(TEMPLATE_PLUGIN_NAME)).expect("plugin template"); + + assert!(source.contains("PipeSecurity")); + assert!(source.contains("WindowsIdentity.GetCurrent()")); + assert!(source.contains("PipeAccessRule")); + assert!(source.contains("PipeAccessRights.ReadWrite")); + assert!(source.contains("CreateCurrentUserPipeSecurity()")); + assert!(source.contains("pipeSecurity")); + } + + #[test] + fn bridge_template_bounds_request_size_and_json_complexity() { + let root = template_dir().expect("template dir"); + let plugin = fs::read_to_string(root.join(TEMPLATE_PLUGIN_NAME)).expect("plugin template"); + let protocol = + fs::read_to_string(root.join(TEMPLATE_PROTOCOL_NAME)).expect("protocol template"); + + assert!(plugin.contains("ReadBoundedRequestLine")); + assert!(plugin.contains("MercuryUnityProbeProtocol.MaxRequestChars")); + assert!(protocol.contains("MaxRequestChars")); + assert!(protocol.contains("MaxJsonDepth")); + assert!(protocol.contains("MaxJsonNodes")); + } + + #[test] + fn template_dir_candidates_cover_install_and_portable_layouts() { + let portable_exe = PathBuf::from( + r"C:\Portable\MercuryToolbox-x86_64-pc-windows-msvc-ReleaseFast\bin\unityprobe.exe", + ); + let mut portable_candidates = Vec::new(); + extend_template_dir_candidates(&mut portable_candidates, &portable_exe); + assert_eq!( + portable_candidates, + vec![ + PathBuf::from( + r"C:\Portable\MercuryToolbox-x86_64-pc-windows-msvc-ReleaseFast\share\mercury-toolbox\support\unityprobe-bridge", + ), + PathBuf::from( + r"C:\Portable\MercuryToolbox-x86_64-pc-windows-msvc-ReleaseFast\support\unityprobe-bridge", + ), + ] + ); + + let installed_exe = + PathBuf::from(r"C:\Users\example\AppData\Local\MercuryToolbox\bin\unityprobe.exe"); + let mut installed_candidates = Vec::new(); + extend_template_dir_candidates(&mut installed_candidates, &installed_exe); + assert_eq!( + installed_candidates, + vec![ + PathBuf::from( + r"C:\Users\example\AppData\Local\MercuryToolbox\share\mercury-toolbox\support\unityprobe-bridge", + ), + PathBuf::from( + r"C:\Users\example\AppData\Local\MercuryToolbox\support\unityprobe-bridge", + ), + ] + ); + } + + #[test] + fn game_executable_candidates_prefer_data_directory_stem_and_skip_unity_helpers() { + let root = unique_temp_dir("unitysupport-game-candidates"); + fs::create_dir_all(root.join("Game_Data")).expect("data dir"); + fs::write(root.join("Game.exe"), b"").expect("game exe"); + fs::write(root.join("UnityCrashHandler64.exe"), b"").expect("crash handler"); + fs::write(root.join("Tool.exe"), b"").expect("extra exe"); + + let candidates = infer_game_executable_candidates(&root); + + assert_eq!(candidates.first(), Some(&root.join("Game.exe"))); + assert!(candidates.contains(&root.join("Tool.exe"))); + assert!(!candidates.contains(&root.join("UnityCrashHandler64.exe"))); + assert_eq!( + candidates + .iter() + .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some("Game.exe")) + .count(), + 1 + ); + } + + #[test] + fn running_game_selection_accepts_image_name_when_full_path_differs() { + let game = PathBuf::from(r"C:\Games\Demo\Demo.exe"); + let fallback = PathBuf::from(r"C:\Other\Demo.exe"); + let processes = vec![ + ProcessDescriptor { + pid: 10, + parent_pid: None, + image_name: "Demo.exe".to_string(), + exe: Some(fallback.display().to_string()), + command_line: Vec::new(), + start_time_unix: 1, + run_time_seconds: 2, + }, + ProcessDescriptor { + pid: 11, + parent_pid: None, + image_name: "demo.exe".to_string(), + exe: Some(game.display().to_string()), + command_line: Vec::new(), + start_time_unix: 3, + run_time_seconds: 4, + }, + ]; + + let selected = + select_running_game_process(&processes, std::slice::from_ref(&game)).expect("process"); + + assert_eq!(selected.pid, 10); + assert_eq!(selected.image_name, "Demo.exe"); + } + + #[test] + fn managed_dir_detection_finds_single_data_directory() { + let root = unique_temp_dir("unitysupport-managed"); + let managed = root.join("Example_Data").join("Managed"); + fs::create_dir_all(&managed).expect("managed dir"); + let discovered = find_managed_dir(&root).expect("managed dir"); + assert_eq!(discovered, managed); + } + + #[test] + fn managed_dir_detection_reports_missing_and_ambiguous_layouts() { + let missing_root = unique_temp_dir("unitysupport-managed-missing"); + let missing = find_managed_dir(&missing_root).expect_err("missing managed"); + assert!( + missing + .to_string() + .contains("missing *_Data\\Managed directory") + ); + + let ambiguous_root = unique_temp_dir("unitysupport-managed-ambiguous"); + fs::create_dir_all(ambiguous_root.join("A_Data").join("Managed")).expect("managed A"); + fs::create_dir_all(ambiguous_root.join("B_Data").join("Managed")).expect("managed B"); + let ambiguous = find_managed_dir(&ambiguous_root).expect_err("ambiguous managed"); + assert!( + ambiguous + .to_string() + .contains("found multiple *_Data\\Managed directories") + ); + } + + #[test] + fn manifest_round_trip_is_stable() { + let temp = unique_temp_dir("unitysupport-manifest"); + let path = temp.join("install.json"); + let manifest = InstallManifest { + plugin_guid: PLUGIN_GUID.to_string(), + plugin_name: PLUGIN_NAME.to_string(), + plugin_version: env!("CARGO_PKG_VERSION").to_string(), + pipe_name: BRIDGE_PIPE_NAME.to_string(), + game_root: "C:\\Game".to_string(), + plugin_dir: "C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe".to_string(), + dll_path: "C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\Mercury.UnityProbe.dll" + .to_string(), + manifest_path: path.display().to_string(), + source_dir: "C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\Source".to_string(), + compiler_path: "C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe" + .to_string(), + managed_dir: "C:\\Game\\Example_Data\\Managed".to_string(), + source_files: vec!["one.cs".to_string(), "two.cs".to_string()], + reference_paths: vec!["A.dll".to_string(), "B.dll".to_string()], + }; + write_manifest(&path, &manifest).expect("write"); + let loaded = read_manifest(&path).expect("read"); + assert_eq!(loaded, manifest); + } + + #[test] + fn read_manifest_reports_parse_errors() { + let temp = unique_temp_dir("unitysupport-manifest-invalid"); + let path = temp.join("broken.json"); + let mut file = fs::File::create(&path).expect("create"); + file.write_all(b"{not-json}").expect("write"); + let error = read_manifest(&path).expect_err("parse error"); + assert!(error.to_string().contains("failed to parse")); + } + + #[cfg(windows)] + #[test] + fn uninstall_bridge_removes_owned_files_and_empty_plugin_directory() { + let root = unique_temp_dir("unitysupport-uninstall"); + let layout = install_layout(&root); + fs::create_dir_all(&layout.source_dir).expect("source dir"); + fs::write(&layout.dll_path, b"dll").expect("dll"); + fs::write(&layout.manifest_path, b"{}").expect("manifest"); + + let report = uninstall_bridge(&root).expect("uninstall"); + + assert!(report.windows_supported); + assert!(report.removed); + assert!(!layout.dll_path.exists()); + assert!(!layout.manifest_path.exists()); + assert!(!layout.source_dir.exists()); + assert!(!layout.plugin_dir.exists()); + } + + #[cfg(windows)] + #[test] + fn status_bridge_reports_installed_layout_without_running_game_or_pipe() { + let root = unique_temp_dir("unitysupport-status"); + let layout = install_layout(&root); + let managed = root.join("Example_Data").join("Managed"); + fs::create_dir_all(&layout.source_dir).expect("source dir"); + fs::create_dir_all(&managed).expect("managed dir"); + fs::write(&layout.dll_path, b"dll").expect("dll"); + let manifest = InstallManifest { + plugin_guid: PLUGIN_GUID.to_string(), + plugin_name: PLUGIN_NAME.to_string(), + plugin_version: env!("CARGO_PKG_VERSION").to_string(), + pipe_name: BRIDGE_PIPE_NAME.to_string(), + game_root: root.display().to_string(), + plugin_dir: layout.plugin_dir.display().to_string(), + dll_path: layout.dll_path.display().to_string(), + manifest_path: layout.manifest_path.display().to_string(), + source_dir: layout.source_dir.display().to_string(), + compiler_path: r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe".to_string(), + managed_dir: managed.display().to_string(), + source_files: Vec::new(), + reference_paths: Vec::new(), + }; + write_manifest(&layout.manifest_path, &manifest).expect("manifest"); + + let status = status_bridge(Some(&root)).expect("status"); + + assert!(status.windows_supported); + assert!(status.installed); + assert_eq!( + status.managed_dir.as_deref(), + Some(manifest.managed_dir.as_str()) + ); + assert_eq!( + status.compiler_path.as_deref(), + Some(manifest.compiler_path.as_str()) + ); + assert_eq!(status.game_process_running, Some(false)); + assert!(!status.pipe_reachable); + assert!(status.runtime_status.is_none()); + assert!( + status + .problems + .iter() + .any(|problem| problem.contains("game process does not appear to be running")) + ); + } + + #[test] + fn file_and_directory_helpers_handle_present_and_missing_paths() { + let temp = unique_temp_dir("unitysupport-remove"); + let file_path = temp.join("item.txt"); + assert!(!remove_file_if_exists(&file_path).expect("missing file")); + fs::write(&file_path, b"x").expect("write file"); + assert!(remove_file_if_exists(&file_path).expect("remove file")); + assert!(!file_path.exists()); + + let dir_path = temp.join("nested"); + assert!(!remove_dir_all_if_exists(&dir_path).expect("missing dir")); + fs::create_dir_all(dir_path.join("child")).expect("create tree"); + assert!(remove_dir_all_if_exists(&dir_path).expect("remove dir")); + assert!(!dir_path.exists()); + } + + #[test] + fn directory_empty_detection_tracks_state_changes() { + let temp = unique_temp_dir("unitysupport-empty"); + let directory = temp.join("dir"); + fs::create_dir_all(&directory).expect("create dir"); + assert!(is_directory_empty(&directory).expect("empty")); + fs::write(directory.join("item.txt"), b"item").expect("write"); + assert!(!is_directory_empty(&directory).expect("not empty")); + } + + #[test] + fn validate_install_layout_reports_missing_requirements() { + let root = unique_temp_dir("unitysupport-layout"); + let error = validate_install_layout(&root).expect_err("missing requirements"); + let text = error.to_string(); + assert!(text.contains("cannot install bridge into")); + assert!(text.contains("missing")); + assert!(text.contains("BepInEx.dll")); + } + + #[test] + fn display_and_path_helpers_return_stable_strings() { + let paths = vec![PathBuf::from("one"), PathBuf::from("two")]; + let rendered = display_paths(&paths); + assert_eq!(rendered.len(), 2); + assert_eq!(rendered[0], path_to_string(Path::new("one"))); + assert_eq!(rendered[1], path_to_string(Path::new("two"))); + assert!(pipe_path().to_string_lossy().contains(BRIDGE_PIPE_NAME)); + } + + #[test] + fn collect_reference_paths_returns_sorted_unique_references() { + let root = unique_temp_dir("unitysupport-refs"); + let bepinex = root.join("BepInEx").join("core"); + fs::create_dir_all(&bepinex).expect("bepinex dir"); + fs::write(bepinex.join("BepInEx.dll"), b"").expect("bepinex dll"); + + let managed = root.join("Example_Data").join("Managed"); + fs::create_dir_all(&managed).expect("managed dir"); + fs::write(managed.join("UnityEngine.CoreModule.dll"), b"").expect("unity dll"); + fs::write(managed.join("netstandard.dll"), b"").expect("netstandard"); + + let framework = root.join("framework"); + fs::create_dir_all(&framework).expect("framework dir"); + fs::write(framework.join("System.dll"), b"").expect("System.dll"); + fs::write(framework.join("System.Core.dll"), b"").expect("System.Core.dll"); + let compiler = framework.join("csc.exe"); + fs::write(&compiler, b"").expect("csc"); + + let references = + collect_reference_paths(&root, &managed, &compiler).expect("reference paths"); + assert!(!references.is_empty()); + assert!(references.windows(2).all(|pair| pair[0] <= pair[1])); + assert!( + references + .iter() + .any(|path| path.ends_with("UnityEngine.CoreModule.dll")) + ); + assert!( + references + .iter() + .any(|path| path.ends_with("netstandard.dll")) + ); + } + + #[test] + fn collect_reference_paths_errors_without_unity_runtime_reference() { + let root = unique_temp_dir("unitysupport-refs-missing"); + let bepinex = root.join("BepInEx").join("core"); + fs::create_dir_all(&bepinex).expect("bepinex dir"); + fs::write(bepinex.join("BepInEx.dll"), b"").expect("bepinex dll"); + + let managed = root.join("Example_Data").join("Managed"); + fs::create_dir_all(&managed).expect("managed dir"); + + let framework = root.join("framework"); + fs::create_dir_all(&framework).expect("framework dir"); + fs::write(framework.join("System.dll"), b"").expect("System.dll"); + fs::write(framework.join("System.Core.dll"), b"").expect("System.Core.dll"); + let compiler = framework.join("csc.exe"); + fs::write(&compiler, b"").expect("csc"); + + let error = + collect_reference_paths(&root, &managed, &compiler).expect_err("missing unity ref"); + assert!( + error + .to_string() + .contains("missing UnityEngine.dll or UnityEngine.CoreModule.dll") + ); + } + + #[test] + fn pipe_connect_errors_are_classified_for_retryable_windows_races() { + let pipe = pipe_path(); + + let busy = format_pipe_connect_error(&pipe, &std::io::Error::from_raw_os_error(231)); + assert!(busy.contains("busy")); + assert!(busy.contains("retry")); + + let missing = format_pipe_connect_error(&pipe, &std::io::Error::from_raw_os_error(2)); + assert!(missing.contains("not available")); + assert!(missing.contains("retry")); + assert!(missing.contains("install the bridge")); + } + + #[test] + fn pipe_io_errors_explain_disconnect_timeout_and_generic_failures() { + let pipe = pipe_path(); + + let broken = + format_pipe_io_error("read from", &pipe, &std::io::Error::from_raw_os_error(109)); + assert!(broken.contains("disconnected mid-request")); + + let timeout = format_pipe_io_error("flush", &pipe, &std::io::Error::from_raw_os_error(121)); + assert!(timeout.contains("timed out")); + assert!(timeout.contains("retry")); + + let generic = format_pipe_io_error( + "write to", + &pipe, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + ); + assert!(generic.contains("failed to write to")); + } + + #[test] + fn pipe_probe_with_existing_pipe_but_failed_status_is_marked_unreachable() { + let mut problems = Vec::new(); + let (pipe_reachable, runtime) = finalize_runtime_status( + Err(UnitySupportError::message("bridge pipe handshake failed")), + true, + &mut problems, + ); + + assert!(!pipe_reachable); + assert!(runtime.is_none()); + assert_eq!( + problems, + vec!["bridge pipe exists but did not answer cleanly: bridge pipe handshake failed"] + ); + } + + #[test] + fn pipe_probe_marks_success_as_reachable_even_without_path_hint() { + let mut problems = Vec::new(); + let (pipe_reachable, runtime) = finalize_runtime_status( + Ok(ProbeEnvelope { + ok: true, + kind: "status".to_string(), + data: Some(RuntimeStatusData { + pipe_name: BRIDGE_PIPE_NAME.to_string(), + plugin_version: "0.1.0".to_string(), + unity_version: "2022.3.22".to_string(), + process_id: 1234, + scene_count: 1, + loaded_scene_names: vec!["Main".to_string()], + }), + error: None, + }), + false, + &mut problems, + ); + + assert!(pipe_reachable); + assert!(runtime.is_some()); + assert!(problems.is_empty()); + } + + #[cfg(windows)] + #[test] + fn live_queries_without_bridge_return_actionable_pipe_errors() { + for result in [ + query_scenes().map(|_| ()), + find_objects("Player", 5).map(|_| ()), + inspect_object(42).map(|_| ()), + inspect_static("Game.Manager").map(|_| ()), + ] { + let error = result.expect_err("bridge should be unavailable in unit test"); + let text = error.to_string(); + assert!(text.contains("failed to connect to")); + assert!(text.contains("install the bridge")); + } + } + + #[cfg(not(windows))] + #[test] + fn non_windows_status_is_explicit() { + let status = status_bridge(None).expect("status"); + assert!(!status.windows_supported); + assert!(!status.pipe_reachable); + assert_eq!(status.pipe_name, BRIDGE_PIPE_NAME); + } +} diff --git a/crates/unlock/Cargo.toml b/crates/unlock/Cargo.toml new file mode 100644 index 0000000..9a60ed7 --- /dev/null +++ b/crates/unlock/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "unlock" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Inspect file lockers and perform unlock-aware file actions on Windows." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +humantime.workspace = true +lexopt.workspace = true +serde.workspace = true +windowsupport = { path = "../windowsupport" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/unlock/src/lib.rs b/crates/unlock/src/lib.rs new file mode 100644 index 0000000..a44c532 --- /dev/null +++ b/crates/unlock/src/lib.rs @@ -0,0 +1,1348 @@ +//! The `unlock` command inspects file lockers and performs unlock-aware actions. +#![allow(clippy::multiple_crate_versions)] + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read}; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use common::{ + CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice, + parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured, + should_read_stdin, +}; +use humantime::parse_duration; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use windowsupport::{ + LockerProcess, LockerQueryMode, WindowsSupportError, force_release, graceful_release, + query_file_lockers, sleep_for, +}; + +const HELP: &str = "\ +Inspect file lockers and perform unlock-aware file actions on Windows. + +Windows only. + +Usage: + unlock [OPTIONS] who + unlock [OPTIONS] free + unlock [OPTIONS] move + unlock [OPTIONS] rename + unlock [OPTIONS] delete + unlock [OPTIONS] copy + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --color Control ANSI color output: auto, never + --quiet Suppress non-essential status output + --deep Use exhaustive locker inspection for `who`, including global handle scans + --force Escalate to terminating exact remaining blockers + --wait Wait between retries, for example 250ms or 2s + -h, --help Show this help text + -V, --version Show the command version + +Examples: + unlock who .\\target\\debug\\jsonlgrep.exe --json | ConvertFrom-Json | Select-Object -ExpandProperty results + unlock free --force .\\locked.txt + unlock delete --force .\\locked.txt +"; + +const MAX_RENDERED_COMMAND_LEN: usize = 120; + +#[derive(Debug, Clone)] +struct Cli { + common: CommonArgs, + who_query_mode: LockerQueryMode, + force: bool, + wait: Duration, + action: Action, +} + +#[derive(Debug, Clone)] +enum Action { + Who(Vec), + Free(Vec), + Move { + source: PathBuf, + destination: PathBuf, + }, + Rename { + source: PathBuf, + new_name: String, + }, + Delete(Vec), + Copy { + source: PathBuf, + destination: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct UnlockResult { + path: String, + action: String, + initial_blockers: Vec, + attempts: Vec, + final_blockers: Vec, + ok: bool, + blocked: bool, + escalated: bool, + result_path: Option, + error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct UnlockSummary { + results: usize, + ok: usize, + blocked: usize, + errors: usize, + escalated: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct UnlockReport { + results: Vec, + summary: UnlockSummary, +} + +#[derive(Debug, Clone)] +struct UnlockOutcome { + initial_blockers: Vec, + attempts: Vec, + final_blockers: Vec, + ok: bool, + escalated: bool, + result_path: Option, + error: Option, +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Version, _)) => { + println!("unlock {}", env!("CARGO_PKG_VERSION")); + ExitCode::Success.as_i32() + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(code) => code.as_i32(), + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + }, + Err(error) => { + print_quick_help_error(&error, HELP); + error.exit_code().as_i32() + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut common = CommonArgs::default(); + let mut who_query_mode = LockerQueryMode::Fast; + let mut force = false; + let mut wait = Duration::from_millis(250); + let mut subcommand = None::; + let mut action_args = Vec::::new(); + + while let Some(argument) = parser + .next() + .map_err(|error| CliError::usage(error.to_string()))? + { + match argument { + Long("help") | Short('h') => { + return Ok(( + ParseOutcome::Help, + Cli { + common, + who_query_mode, + force, + wait, + action: Action::Who(Vec::new()), + }, + )); + } + Long("version") | Short('V') => { + return Ok(( + ParseOutcome::Version, + Cli { + common, + who_query_mode, + force, + wait, + action: Action::Who(Vec::new()), + }, + )); + } + Long("json") => common.set_render_mode(RenderMode::Json), + Long("toon") => common.set_render_mode(RenderMode::Toon), + Long("format") => { + let value = parser_value_string(&mut parser, "--format")?; + common.set_render_mode(parse_format_choice(&value)?); + } + Long("input-format") => { + common.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("color") => { + common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; + } + Long("quiet") => common.quiet = true, + Long("deep") => who_query_mode = LockerQueryMode::Deep, + Long("force") => force = true, + Long("wait") => { + wait = parse_duration(&parser_value_string(&mut parser, "--wait")?) + .map_err(|error| CliError::usage(format!("invalid --wait value: {error}")))?; + } + ArgValue(value) => { + if subcommand.is_none() { + subcommand = Some(value.clone().into_string().map_err(|invalid| { + CliError::usage(format!( + "subcommand expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + })?); + } else { + action_args.push(value); + } + } + _ => { + return Err(CliError::usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + let action = parse_action( + subcommand, + action_args, + common.input_format, + common.stdin_is_terminal(), + )?; + Ok(( + ParseOutcome::Run, + Cli { + common, + who_query_mode, + force, + wait, + action, + }, + )) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser + .value() + .map_err(|error| CliError::usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + CliError::usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_action( + subcommand: Option, + positional: Vec, + input_format: InputFormat, + stdin_is_terminal: bool, +) -> Result { + let Some(subcommand) = subcommand else { + return Err(CliError::usage( + "provide a subcommand: who, free, move, rename, delete, or copy", + )); + }; + + match subcommand.as_str() { + "who" => Ok(Action::Who(collect_paths( + positional, + input_format, + stdin_is_terminal, + )?)), + "free" => Ok(Action::Free(collect_paths( + positional, + input_format, + stdin_is_terminal, + )?)), + "delete" => Ok(Action::Delete(collect_paths( + positional, + input_format, + stdin_is_terminal, + )?)), + "move" => { + if positional.len() != 2 { + return Err(CliError::usage("move requires ")); + } + let source = common::require_exactly_one_input_path( + &common::expand_input_patterns( + std::slice::from_ref(&PathBuf::from(&positional[0])), + "unlock", + )?, + "unlock move source", + )?; + Ok(Action::Move { + source, + destination: PathBuf::from(&positional[1]), + }) + } + "rename" => { + if positional.len() != 2 { + return Err(CliError::usage("rename requires ")); + } + let source = common::require_exactly_one_input_path( + &common::expand_input_patterns( + std::slice::from_ref(&PathBuf::from(&positional[0])), + "unlock", + )?, + "unlock rename source", + )?; + let new_name = validate_plain_file_name(positional[1].clone().into_string().map_err( + |invalid| { + CliError::usage(format!( + "rename target expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }, + )?)?; + Ok(Action::Rename { source, new_name }) + } + "copy" => { + if positional.len() != 2 { + return Err(CliError::usage("copy requires ")); + } + let source = common::require_exactly_one_input_path( + &common::expand_input_patterns( + std::slice::from_ref(&PathBuf::from(&positional[0])), + "unlock", + )?, + "unlock copy source", + )?; + Ok(Action::Copy { + source, + destination: PathBuf::from(&positional[1]), + }) + } + _ => Err(CliError::usage( + "unknown subcommand; expected who, free, move, rename, delete, or copy", + )), + } +} + +fn validate_plain_file_name(value: String) -> Result { + let mut components = Path::new(&value).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(value), + _ => Err(CliError::usage( + "rename target must be a plain file name without path separators", + )), + } +} + +fn collect_paths( + positional: Vec, + input_format: InputFormat, + stdin_is_terminal: bool, +) -> Result, CliError> { + if should_read_stdin(!positional.is_empty(), stdin_is_terminal) { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?; + let parsed = parse_paths_from_string(&buffer, input_format)?; + if !parsed.is_empty() { + return Ok(parsed); + } + } + let paths = positional + .into_iter() + .map(PathBuf::from) + .collect::>(); + if paths.is_empty() { + Err(CliError::usage( + "provide one or more paths or pipe them into stdin", + )) + } else { + common::expand_input_patterns(&paths, "unlock") + } +} + +fn parse_paths_from_string( + buffer: &str, + input_format: InputFormat, +) -> Result, CliError> { + common::read_existing_stdin_path_records(buffer, input_format, "unlock")? + .map_or_else(|| Ok(Vec::new()), Ok) +} + +fn run(cli: &Cli) -> Result { + let results = match &cli.action { + Action::Who(paths) => paths + .iter() + .map(|path| run_who(cli, path)) + .collect::>(), + Action::Free(paths) => paths + .iter() + .map(|path| run_free(cli, path)) + .collect::>(), + Action::Delete(paths) => paths + .iter() + .map(|path| { + run_action(cli, path, "delete", |path, _| { + delete_path(path).map(|()| None) + }) + }) + .collect::>(), + Action::Move { + source, + destination, + } => vec![run_action(cli, source, "move", |path, _| { + reject_reparse_point_or_symlink(path, "move")?; + fs::rename(path, destination) + .map(|()| Some(destination.display().to_string())) + .map_err(|error| error.to_string()) + })], + Action::Rename { source, new_name } => { + let destination = source + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(new_name); + vec![run_action(cli, source, "rename", |path, _| { + reject_reparse_point_or_symlink(path, "rename")?; + fs::rename(path, &destination) + .map(|()| Some(destination.display().to_string())) + .map_err(|error| error.to_string()) + })] + } + Action::Copy { + source, + destination, + } => vec![run_action(cli, source, "copy", |path, _| { + reject_reparse_point_or_symlink(path, "copy")?; + fs::copy(path, destination) + .map(|_| Some(destination.display().to_string())) + .map_err(|error| error.to_string()) + })], + }; + + let exit_code = match &cli.action { + Action::Who(_) => { + if results.iter().all(|result| result.ok) { + ExitCode::Success + } else { + ExitCode::RuntimeError + } + } + _ => { + if results.iter().all(|result| result.ok) { + ExitCode::Success + } else { + ExitCode::RuntimeError + } + } + }; + + let report = build_report(&results); + + match cli.common.render_mode() { + RenderMode::Json => { + print_json(&report)?; + } + RenderMode::Toon => { + print_structured(&report, RenderMode::Toon)?; + } + RenderMode::Text => { + for result in &results { + print!("{}", render_result(result)); + } + } + } + + Ok(exit_code) +} + +fn build_report(results: &[UnlockResult]) -> UnlockReport { + UnlockReport { + summary: UnlockSummary { + results: results.len(), + ok: results.iter().filter(|result| result.ok).count(), + blocked: results.iter().filter(|result| result.blocked).count(), + errors: results + .iter() + .filter(|result| result.error.is_some()) + .count(), + escalated: results.iter().filter(|result| result.escalated).count(), + }, + results: results.to_vec(), + } +} + +fn run_who(cli: &Cli, path: &Path) -> UnlockResult { + if !path.exists() { + return build_unlock_result( + path, + "who", + UnlockOutcome { + initial_blockers: Vec::new(), + attempts: Vec::new(), + final_blockers: Vec::new(), + ok: false, + escalated: false, + result_path: None, + error: Some("path not found".to_string()), + }, + ); + } + match query_file_lockers(path, cli.who_query_mode) { + Ok(blockers) => build_unlock_result( + path, + "who", + UnlockOutcome { + initial_blockers: blockers.clone(), + attempts: Vec::new(), + final_blockers: blockers, + ok: true, + escalated: false, + result_path: None, + error: None, + }, + ), + Err(error) => build_unlock_result( + path, + "who", + UnlockOutcome { + initial_blockers: Vec::new(), + attempts: Vec::new(), + final_blockers: Vec::new(), + ok: false, + escalated: false, + result_path: None, + error: Some(render_locker_query_error(&error)), + }, + ), + } +} + +fn run_free(cli: &Cli, path: &Path) -> UnlockResult { + let initial_blockers = query_file_lockers(path, cli.who_query_mode).unwrap_or_default(); + if initial_blockers.is_empty() { + return build_unlock_result( + path, + "free", + UnlockOutcome { + initial_blockers: Vec::new(), + attempts: Vec::new(), + final_blockers: Vec::new(), + ok: true, + escalated: false, + result_path: None, + error: None, + }, + ); + } + + let mut attempts = vec![format!("observed {} blocker(s)", initial_blockers.len())]; + let mut final_blockers = initial_blockers.clone(); + let mut escalated = false; + + if graceful_release(&[path.to_path_buf()]).is_ok() { + attempts.push("graceful_release".to_string()); + sleep_for(cli.wait); + final_blockers = query_file_lockers(path, cli.who_query_mode).unwrap_or_default(); + } + + if !final_blockers.is_empty() && cli.force && force_release(&[path.to_path_buf()]).is_ok() { + attempts.push("force_release".to_string()); + escalated = true; + sleep_for(cli.wait); + final_blockers = query_file_lockers(path, cli.who_query_mode).unwrap_or_default(); + } + + let blocked = !final_blockers.is_empty(); + let error = if blocked { + Some(if cli.force { + "blockers remain after forced release".to_string() + } else { + "blockers remain; rerun with --force to terminate them".to_string() + }) + } else { + None + }; + + build_unlock_result( + path, + "free", + UnlockOutcome { + initial_blockers, + attempts, + final_blockers, + ok: !blocked, + escalated, + result_path: None, + error, + }, + ) +} + +fn run_action(cli: &Cli, path: &Path, action: &str, operation: F) -> UnlockResult +where + F: Fn(&Path, &Cli) -> Result, String>, +{ + run_action_with_locker_query(cli, path, action, operation, query_file_lockers) +} + +fn run_action_with_locker_query( + cli: &Cli, + path: &Path, + action: &str, + operation: F, + locker_query: Q, +) -> UnlockResult +where + F: Fn(&Path, &Cli) -> Result, String>, + Q: Fn(&Path, LockerQueryMode) -> Result, WindowsSupportError>, +{ + match operation(path, cli) { + Ok(result_path) => build_unlock_result( + path, + action, + UnlockOutcome { + initial_blockers: Vec::new(), + attempts: Vec::new(), + final_blockers: Vec::new(), + ok: true, + escalated: false, + result_path, + error: None, + }, + ), + Err(initial_error) => { + let initial_blockers = locker_query(path, cli.who_query_mode).unwrap_or_default(); + if initial_blockers.is_empty() { + return build_unlock_result( + path, + action, + UnlockOutcome { + initial_blockers, + attempts: Vec::new(), + final_blockers: Vec::new(), + ok: false, + escalated: false, + result_path: None, + error: Some(initial_error), + }, + ); + } + + run_action_with_blockers( + cli, + path, + action, + operation, + initial_blockers, + &initial_error, + ) + } + } +} + +fn run_action_with_blockers( + cli: &Cli, + path: &Path, + action: &str, + operation: F, + initial_blockers: Vec, + initial_error: &str, +) -> UnlockResult +where + F: Fn(&Path, &Cli) -> Result, String>, +{ + let mut attempts = vec![format!("initial_operation_failed: {initial_error}")]; + let mut escalated = false; + if graceful_release(&[path.to_path_buf()]).is_ok() { + attempts.push("graceful_release".to_string()); + sleep_for(cli.wait); + } + + let mut final_error = match try_complete_operation( + path, + cli, + action, + &operation, + &initial_blockers, + &attempts, + escalated, + ) { + Ok(result) => return result, + Err(error) => error, + }; + + let mut final_blockers = query_file_lockers(path, cli.who_query_mode).unwrap_or_default(); + if cli.force && !final_blockers.is_empty() { + attempts.push("force_release".to_string()); + if force_release(&[path.to_path_buf()]).is_ok() { + escalated = true; + sleep_for(cli.wait); + match try_complete_operation( + path, + cli, + action, + &operation, + &initial_blockers, + &attempts, + escalated, + ) { + Ok(result) => return result, + Err(error) => final_error = error, + } + } + final_blockers = query_file_lockers(path, cli.who_query_mode).unwrap_or_default(); + } + + if !final_blockers.is_empty() { + final_error = if cli.force { + format!("{final_error}; blockers remain after forced release") + } else { + format!("{final_error}; blockers remain; rerun with --force to terminate them") + }; + } + + build_unlock_result( + path, + action, + UnlockOutcome { + initial_blockers, + attempts, + final_blockers, + ok: false, + escalated, + result_path: None, + error: Some(final_error), + }, + ) +} + +fn try_complete_operation( + path: &Path, + cli: &Cli, + action: &str, + operation: &F, + initial_blockers: &[LockerProcess], + attempts: &[String], + escalated: bool, +) -> Result +where + F: Fn(&Path, &Cli) -> Result, String>, +{ + match operation(path, cli) { + Ok(result_path) => { + let final_blockers = query_file_lockers(path, cli.who_query_mode).unwrap_or_default(); + let blocked = !final_blockers.is_empty(); + Ok(build_unlock_result( + path, + action, + UnlockOutcome { + initial_blockers: initial_blockers.to_vec(), + attempts: attempts.to_vec(), + final_blockers, + ok: !blocked, + escalated, + result_path, + error: blocked.then(|| { + "operation completed but blockers still appear active".to_string() + }), + }, + )) + } + Err(error) => Err(error), + } +} + +fn build_unlock_result(path: &Path, action: &str, outcome: UnlockOutcome) -> UnlockResult { + UnlockResult { + path: path.display().to_string(), + action: action.to_string(), + blocked: !outcome.final_blockers.is_empty(), + initial_blockers: outcome.initial_blockers, + attempts: outcome.attempts, + final_blockers: outcome.final_blockers, + ok: outcome.ok, + escalated: outcome.escalated, + result_path: outcome.result_path, + error: outcome.error, + } +} + +fn delete_path(path: &Path) -> Result<(), String> { + let metadata = reject_reparse_point_or_symlink(path, "delete")?; + if metadata.is_dir() { + fs::remove_dir_all(path).map_err(|error| error.to_string()) + } else { + fs::remove_file(path).map_err(|error| error.to_string()) + } +} + +fn reject_reparse_point_or_symlink(path: &Path, action: &str) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if is_reparse_point_or_symlink(&metadata) { + return Err(format!( + "refusing to {action} reparse point or symlink: {}", + path.display() + )); + } + Ok(metadata) +} + +#[cfg(windows)] +fn is_reparse_point_or_symlink(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point_or_symlink(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +fn render_result(result: &UnlockResult) -> String { + let mut rendered = String::new(); + writeln!( + rendered, + "path={} action={} ok={} blocked={} escalated={} blockers={} error={}", + result.path, + result.action, + result.ok, + result.blocked, + result.escalated, + result.final_blockers.len(), + result.error.as_deref().unwrap_or("-") + ) + .expect("writing to a String cannot fail"); + if !result.attempts.is_empty() { + writeln!(rendered, "attempts={}", result.attempts.join(" | ")) + .expect("writing to a String cannot fail"); + } + for blocker in &result.final_blockers { + writeln!( + rendered, + "blocker pid={} image={} app={} service={} cmd={}", + blocker.pid, + blocker.image_name.as_deref().unwrap_or("-"), + blocker.app_name, + blocker.service_name, + render_command_line(&blocker.command_line) + ) + .expect("writing to a String cannot fail"); + } + rendered +} + +fn render_command_line(arguments: &[String]) -> String { + if arguments.is_empty() { + return "-".to_string(); + } + let joined = arguments + .iter() + .map(|argument| { + if argument.contains(' ') { + format!("\"{argument}\"") + } else { + argument.clone() + } + }) + .collect::>() + .join(" "); + if joined.len() <= MAX_RENDERED_COMMAND_LEN { + joined + } else { + format!("{}...", &joined[..MAX_RENDERED_COMMAND_LEN - 3]) + } +} + +fn render_locker_query_error(error: &WindowsSupportError) -> String { + match error { + WindowsSupportError::RestartManager { + action: "query", + code: 234, + } => "locker snapshot changed while enumerating blockers (restart manager code 234); retry the query or run `unlock free` first".to_string(), + WindowsSupportError::RestartManager { action, code } => { + format!("restart manager {action} failed with code {code}") + } + WindowsSupportError::Io(message) + | WindowsSupportError::Process(message) + | WindowsSupportError::Unsupported(message) => message.clone(), + WindowsSupportError::WindowsApi { action, code } => { + format!("{action} failed with code {code}") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn parse_paths_supports_lines_jsonl_and_auto() { + let temp = tempdir().expect("tempdir"); + let first = temp.path().join("locked.txt"); + let second = temp.path().join("other.txt"); + fs::write(&first, "one").expect("first"); + fs::write(&second, "two").expect("second"); + + assert_eq!( + parse_paths_from_string(&format!("{}\n", first.display()), InputFormat::Lines) + .expect("lines"), + vec![first.clone()] + ); + assert_eq!( + parse_paths_from_string( + &format!( + "{}\n{{\"path\":{}}}\n", + serde_json::to_string(&first.display().to_string()).expect("first path"), + serde_json::to_string(&second.display().to_string()).expect("second path"), + ), + InputFormat::Jsonl, + ) + .expect("jsonl"), + vec![first.clone(), second] + ); + assert_eq!( + parse_paths_from_string(&format!("{}\n", first.display()), InputFormat::Auto) + .expect("auto"), + vec![first] + ); + } + + #[test] + fn render_result_is_compact() { + let text = render_result(&UnlockResult { + path: "locked.txt".to_string(), + action: "delete".to_string(), + initial_blockers: Vec::new(), + attempts: vec!["graceful_release".to_string()], + final_blockers: Vec::new(), + ok: true, + blocked: false, + escalated: false, + result_path: None, + error: None, + }); + assert!(text.contains("path=locked.txt")); + assert!(text.contains("action=delete")); + } + + #[test] + fn parse_action_and_path_collection_cover_common_cases() { + assert!(matches!( + parse_action(None, Vec::new(), InputFormat::Lines, true), + Err(CliError::Usage(message)) if message.contains("provide a subcommand") + )); + assert!(matches!( + parse_action( + Some("move".to_string()), + vec![OsString::from("only-one")], + InputFormat::Lines, + true + ), + Err(CliError::Usage(message)) if message.contains("move requires") + )); + assert!(matches!( + parse_action( + Some("rename".to_string()), + vec![OsString::from("a.txt"), OsString::from("b.txt")], + InputFormat::Lines, + true + ), + Ok(Action::Rename { .. }) + )); + assert!(matches!( + parse_action( + Some("rename".to_string()), + vec![OsString::from("a.txt"), OsString::from("..\\escaped.txt")], + InputFormat::Lines, + true + ), + Err(CliError::Usage(message)) if message.contains("plain file name") + )); + + let collected = + collect_paths(vec![OsString::from("a.txt")], InputFormat::Lines, true).expect("paths"); + assert_eq!(collected, vec![PathBuf::from("a.txt")]); + } + + #[test] + fn delete_path_and_run_action_cover_success_and_error_paths() { + let temp_dir = std::env::temp_dir().join(format!( + "unlock-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + + let file_path = temp_dir.join("delete-me.txt"); + fs::write(&file_path, "data").expect("file"); + delete_path(&file_path).expect("delete file"); + assert!(!file_path.exists()); + + let dir_path = temp_dir.join("dir"); + fs::create_dir_all(&dir_path).expect("dir"); + delete_path(&dir_path).expect("delete dir"); + assert!(!dir_path.exists()); + + let cli = Cli { + common: CommonArgs::default(), + who_query_mode: LockerQueryMode::Fast, + force: false, + wait: Duration::from_millis(0), + action: Action::Free(vec![temp_dir.clone()]), + }; + let success = run_action(&cli, &temp_dir, "free", |_, _| Ok(Some("done".to_string()))); + assert!(success.ok); + assert!(!success.blocked); + assert_eq!(success.result_path, Some("done".to_string())); + + let failure = run_action_with_locker_query( + &cli, + &temp_dir, + "free", + |_, _| Err("blocked".to_string()), + |_, _| Ok(Vec::new()), + ); + assert!(!failure.ok); + assert!(!failure.blocked); + assert_eq!(failure.error.as_deref(), Some("blocked")); + + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn parse_cli_supports_help_and_flags() { + let (outcome, _) = parse_cli_from(["unlock", "--help"]).expect("help"); + assert_eq!(outcome, ParseOutcome::Help); + + let (_, cli) = parse_cli_from([ + "unlock", + "--json", + "--deep", + "--force", + "--wait", + "1s", + "delete", + "locked.txt", + ]) + .expect("cli"); + assert!(cli.common.json); + assert_eq!(cli.who_query_mode, LockerQueryMode::Deep); + assert!(cli.force); + assert_eq!(cli.wait, Duration::from_secs(1)); + assert!(matches!(cli.action, Action::Delete(_))); + } + + #[test] + fn run_dispatch_covers_copy_move_rename_delete_and_who() { + let temp_dir = std::env::temp_dir().join(format!( + "unlock-run-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + + let source = temp_dir.join("source.txt"); + let copy_dest = temp_dir.join("copy.txt"); + let move_dest = temp_dir.join("moved.txt"); + fs::write(&source, "payload").expect("source"); + + let common = CommonArgs { + json: false, + format: None, + input_format: InputFormat::Lines, + color: common::ColorChoice::Never, + quiet: true, + }; + + let copy_cli = Cli { + common, + who_query_mode: LockerQueryMode::Fast, + force: false, + wait: Duration::from_millis(0), + action: Action::Copy { + source: source.clone(), + destination: copy_dest.clone(), + }, + }; + assert_eq!(run(©_cli).expect("copy"), ExitCode::Success); + assert!(copy_dest.exists()); + + let move_cli = Cli { + common, + who_query_mode: LockerQueryMode::Fast, + force: false, + wait: Duration::from_millis(0), + action: Action::Move { + source: copy_dest, + destination: move_dest.clone(), + }, + }; + assert_eq!(run(&move_cli).expect("move"), ExitCode::Success); + assert!(move_dest.exists()); + + let rename_cli = Cli { + common, + who_query_mode: LockerQueryMode::Fast, + force: false, + wait: Duration::from_millis(0), + action: Action::Rename { + source: move_dest, + new_name: "renamed.txt".to_string(), + }, + }; + assert_eq!(run(&rename_cli).expect("rename"), ExitCode::Success); + let renamed = temp_dir.join("renamed.txt"); + assert!(renamed.exists()); + + let (_, parsed_who) = parse_cli_from(["unlock", "who", "ready.txt"]).expect("who"); + assert!(matches!(parsed_who.action, Action::Who(paths) if paths.len() == 1)); + + let delete_cli = Cli { + common, + who_query_mode: LockerQueryMode::Fast, + force: false, + wait: Duration::from_millis(0), + action: Action::Delete(vec![renamed.clone()]), + }; + assert_eq!(run(&delete_cli).expect("delete"), ExitCode::Success); + assert!(!renamed.exists()); + + let _ = fs::remove_file(source); + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn render_with_blockers_covers_remaining_text_paths() { + let temp_dir = std::env::temp_dir().join(format!( + "unlock-free-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + let source = temp_dir.join("free.txt"); + fs::write(&source, "payload").expect("source"); + + let text = render_result(&UnlockResult { + path: source.display().to_string(), + action: "who".to_string(), + initial_blockers: Vec::new(), + attempts: vec!["probe".to_string()], + final_blockers: vec![LockerProcess { + path: source.display().to_string(), + pid: 42, + start_time_unix: None, + app_name: "pwsh".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("pwsh.exe".to_string()), + command_line: vec![ + "pwsh".to_string(), + "-File".to_string(), + "locker.ps1".to_string(), + ], + }], + ok: false, + blocked: true, + escalated: false, + result_path: None, + error: Some("busy".to_string()), + }); + assert!(text.contains("blocker pid=42")); + + let _ = fs::remove_file(source); + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn parse_and_run_cover_more_error_and_json_paths() { + assert!(matches!( + parse_action( + Some("unknown".to_string()), + vec![OsString::from("a.txt")], + InputFormat::Lines, + true + ), + Err(CliError::Usage(message)) if message.contains("unknown subcommand") + )); + assert!(matches!( + collect_paths(Vec::new(), InputFormat::Lines, true), + Err(CliError::Usage(message)) if message.contains("provide one or more paths") + )); + + let temp_dir = std::env::temp_dir().join(format!( + "unlock-json-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("tempdir"); + let source = temp_dir.join("present.txt"); + let missing = temp_dir.join("missing.txt"); + fs::write(&source, "payload").expect("source"); + + assert!(matches!( + parse_cli_from(["unlock", "--json", "who", source.to_str().expect("source")]), + Ok((ParseOutcome::Run, Cli { action: Action::Who(paths), .. })) if paths == vec![source.clone()] + )); + assert!(delete_path(&missing).is_err()); + + let _ = fs::remove_file(source); + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn build_report_keeps_json_top_level_stable() { + let report = build_report(&[ + UnlockResult { + path: "a.txt".to_string(), + action: "who".to_string(), + initial_blockers: Vec::new(), + attempts: Vec::new(), + final_blockers: Vec::new(), + ok: true, + blocked: false, + escalated: false, + result_path: None, + error: None, + }, + UnlockResult { + path: "b.txt".to_string(), + action: "delete".to_string(), + initial_blockers: Vec::new(), + attempts: vec!["force_release".to_string()], + final_blockers: vec![LockerProcess { + path: "b.txt".to_string(), + pid: 42, + start_time_unix: None, + app_name: "pwsh".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("pwsh.exe".to_string()), + command_line: vec![ + "pwsh".to_string(), + "-File".to_string(), + "holder.ps1".to_string(), + ], + }], + ok: false, + blocked: true, + escalated: true, + result_path: None, + error: Some("busy".to_string()), + }, + ]); + + assert_eq!(report.summary.results, 2); + assert_eq!(report.summary.ok, 1); + assert_eq!(report.summary.blocked, 1); + assert_eq!(report.summary.errors, 1); + assert_eq!(report.summary.escalated, 1); + assert_eq!(report.results.len(), 2); + } + + #[test] + fn who_reports_success_even_when_blockers_exist() { + let result = UnlockResult { + path: "locked.txt".to_string(), + action: "who".to_string(), + initial_blockers: vec![LockerProcess { + path: "locked.txt".to_string(), + pid: 7, + start_time_unix: Some(100), + app_name: "tool".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("tool.exe".to_string()), + command_line: vec!["tool.exe".to_string(), "--flag".to_string()], + }], + attempts: Vec::new(), + final_blockers: vec![LockerProcess { + path: "locked.txt".to_string(), + pid: 7, + start_time_unix: Some(100), + app_name: "tool".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("tool.exe".to_string()), + command_line: vec!["tool.exe".to_string(), "--flag".to_string()], + }], + ok: true, + blocked: true, + escalated: false, + result_path: None, + error: None, + }; + let text = render_result(&result); + assert!(text.contains("ok=true")); + assert!(!text.contains("attempts=")); + assert!(text.contains("blocker pid=7")); + assert!(text.contains("cmd=tool.exe --flag")); + } + + #[test] + fn who_reports_missing_paths_as_errors() { + let missing = std::env::temp_dir().join(format!( + "unlock-missing-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + let result = run_who( + &Cli { + common: CommonArgs::default(), + who_query_mode: LockerQueryMode::Fast, + force: false, + wait: Duration::from_millis(0), + action: Action::Who(vec![missing.clone()]), + }, + &missing, + ); + assert!(!result.ok); + assert!(!result.blocked); + assert_eq!(result.error.as_deref(), Some("path not found")); + } +} diff --git a/crates/unlock/src/main.rs b/crates/unlock/src/main.rs new file mode 100644 index 0000000..5dbbdc9 --- /dev/null +++ b/crates/unlock/src/main.rs @@ -0,0 +1,6 @@ +//! Binary entry point for `unlock`. +#![allow(clippy::multiple_crate_versions)] + +fn main() { + std::process::exit(unlock::main_entry()); +} diff --git a/crates/unlock/tests/unlock_cli.rs b/crates/unlock/tests/unlock_cli.rs new file mode 100644 index 0000000..2b41ce5 --- /dev/null +++ b/crates/unlock/tests/unlock_cli.rs @@ -0,0 +1,185 @@ +//! Integration tests for the `unlock` command. + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("unlock").expect("binary") +} + +#[test] +#[ignore = "slow Windows file-lock force-delete integration; covered by just stable-test"] +fn reports_file_lockers_and_can_force_delete() { + let binary = assert_cmd::cargo::cargo_bin("unlock"); + let script = format!( + "$temp = Join-Path $env:TEMP ('mercury-unlock-test-' + [guid]::NewGuid()); \ + New-Item -ItemType Directory -Path $temp | Out-Null; \ + $path = Join-Path $temp 'locked.txt'; \ + $locker = Join-Path $temp 'locker.ps1'; \ + Set-Content -Path $path -Value 'locked'; \ + Set-Content -Path $locker -Value \"param([string]`$Path)`n`$stream = [System.IO.File]::Open(`$Path, 'Open', 'ReadWrite', 'None')`nStart-Sleep -Seconds 10\"; \ + $child = Start-Process pwsh -ArgumentList '-NoProfile','-File',$locker,$path -PassThru; \ + Start-Sleep -Milliseconds 500; \ + $delete = & '{}' delete --json --force --wait 0ms $path | ConvertFrom-Json; \ + $exists = Test-Path $path; \ + Stop-Process -Id $child.Id -Force -ErrorAction SilentlyContinue; \ + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue; \ + [pscustomobject]@{{ delete = $delete; exists = $exists }} | ConvertTo-Json -Depth 8 -Compress", + binary.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"results\":[{")) + .stdout(predicate::str::contains("\"summary\":{\"results\":1")) + .stdout(predicate::str::contains("\"initial_blockers\"")) + .stdout(predicate::str::contains("\"command_line\"")) + .stdout(predicate::str::contains("\"action\":\"delete\"")) + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"exists\":false")); +} + +#[test] +fn help_includes_unlock_examples() { + let mut command = cargo_command(); + command + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("who")) + .stdout(predicate::str::contains("--deep")) + .stdout(predicate::str::contains("delete")) + .stdout(predicate::str::contains("ConvertFrom-Json")); +} + +#[test] +fn who_returns_success_for_unlocked_files() { + let temp = std::env::temp_dir().join(format!( + "unlock-who-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&temp).expect("tempdir"); + let path = temp.join("plain.txt"); + std::fs::write(&path, "ready").expect("fixture"); + + let mut command = cargo_command(); + command + .arg("who") + .arg("--json") + .arg(&path) + .assert() + .success() + .stdout(predicate::str::contains("\"summary\":{\"results\":1")) + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"final_blockers\":[]")); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir_all(temp); +} + +#[cfg(windows)] +#[test] +#[ignore = "slow Windows junction integration; covered by just stable-test"] +fn delete_refuses_junction_directory_without_touching_target() { + let binary = assert_cmd::cargo::cargo_bin("unlock"); + let script = format!( + "$temp = Join-Path $env:TEMP ('mercury-unlock-junction-' + [guid]::NewGuid()); \ + $target = Join-Path $temp 'target'; \ + $junction = Join-Path $temp 'junction'; \ + $sentinel = Join-Path $target 'sentinel.txt'; \ + try {{ \ + New-Item -ItemType Directory -Path $target | Out-Null; \ + Set-Content -LiteralPath $sentinel -Value 'keep'; \ + New-Item -ItemType Junction -Path $junction -Target $target | Out-Null; \ + $json = & '{}' delete --json $junction; \ + $exit = $LASTEXITCODE; \ + $report = $json | ConvertFrom-Json; \ + if ($exit -eq 0) {{ throw 'unlock delete unexpectedly succeeded for a junction directory' }}; \ + if (-not (Test-Path -LiteralPath $sentinel)) {{ throw 'junction target sentinel was deleted' }}; \ + if (-not (Test-Path -LiteralPath $junction)) {{ throw 'junction itself was removed instead of refused' }}; \ + if ($report.results[0].ok -ne $false) {{ throw 'unlock report should mark the junction delete as not ok' }}; \ + if ($report.results[0].error -notmatch 'reparse') {{ throw ('expected reparse error, got: ' + $report.results[0].error) }}; \ + [pscustomobject]@{{ exit = $exit; sentinel = (Test-Path -LiteralPath $sentinel); junction = (Test-Path -LiteralPath $junction); error = $report.results[0].error }} | ConvertTo-Json -Compress \ + }} finally {{ \ + if (Test-Path -LiteralPath $junction) {{ Remove-Item -LiteralPath $junction -Force -ErrorAction SilentlyContinue }}; \ + if (Test-Path -LiteralPath $temp) {{ Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue }} \ + }}", + binary.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"sentinel\":true")) + .stdout(predicate::str::contains("\"junction\":true")) + .stdout(predicate::str::contains("reparse")); +} + +#[cfg(windows)] +#[test] +#[ignore = "slow Windows junction integration; covered by just stable-test"] +fn copy_move_and_rename_refuse_junction_sources_with_reparse_errors() { + let binary = assert_cmd::cargo::cargo_bin("unlock"); + let script = format!( + "$temp = Join-Path $env:TEMP ('mercury-unlock-junction-actions-' + [guid]::NewGuid()); \ + $target = Join-Path $temp 'target'; \ + $copyJunction = Join-Path $temp 'copy-junction'; \ + $moveJunction = Join-Path $temp 'move-junction'; \ + $renameJunction = Join-Path $temp 'rename-junction'; \ + $copyDest = Join-Path $temp 'copy-dest'; \ + $moveDest = Join-Path $temp 'move-dest'; \ + $sentinel = Join-Path $target 'sentinel.txt'; \ + function Invoke-Refused([string[]]$ArgumentList) {{ \ + $json = & '{}' @ArgumentList; \ + $exit = $LASTEXITCODE; \ + $report = $json | ConvertFrom-Json; \ + if ($exit -eq 0) {{ throw ('expected nonzero exit for ' + ($ArgumentList -join ' ')) }}; \ + if ($report.results[0].ok -ne $false) {{ throw ('expected ok=false for ' + ($ArgumentList -join ' ')) }}; \ + if ($report.results[0].error -notmatch 'reparse') {{ throw ('expected reparse error for ' + ($ArgumentList -join ' ') + ', got: ' + $report.results[0].error) }}; \ + return $report.results[0].error \ + }}; \ + try {{ \ + New-Item -ItemType Directory -Path $target | Out-Null; \ + Set-Content -LiteralPath $sentinel -Value 'keep'; \ + New-Item -ItemType Junction -Path $copyJunction -Target $target | Out-Null; \ + New-Item -ItemType Junction -Path $moveJunction -Target $target | Out-Null; \ + New-Item -ItemType Junction -Path $renameJunction -Target $target | Out-Null; \ + $copyError = Invoke-Refused @('copy','--json',$copyJunction,$copyDest); \ + $moveError = Invoke-Refused @('move','--json',$moveJunction,$moveDest); \ + $renameError = Invoke-Refused @('rename','--json',$renameJunction,'renamed-junction'); \ + if (-not (Test-Path -LiteralPath $sentinel)) {{ throw 'junction target sentinel was deleted' }}; \ + if (-not (Test-Path -LiteralPath $copyJunction)) {{ throw 'copy junction was removed' }}; \ + if (-not (Test-Path -LiteralPath $moveJunction)) {{ throw 'move junction was removed' }}; \ + if (-not (Test-Path -LiteralPath $renameJunction)) {{ throw 'rename junction was removed' }}; \ + if (Test-Path -LiteralPath $copyDest) {{ throw 'copy destination was created' }}; \ + if (Test-Path -LiteralPath $moveDest) {{ throw 'move destination was created' }}; \ + [pscustomobject]@{{ sentinel = (Test-Path -LiteralPath $sentinel); copy = $copyError; move = $moveError; rename = $renameError }} | ConvertTo-Json -Compress \ + }} finally {{ \ + foreach ($path in @($copyJunction,$moveJunction,$renameJunction)) {{ if (Test-Path -LiteralPath $path) {{ Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }} }}; \ + if (Test-Path -LiteralPath $temp) {{ Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue }} \ + }}", + binary.display() + ); + + let mut command = Command::new("pwsh"); + command + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .assert() + .success() + .stdout(predicate::str::contains("\"sentinel\":true")) + .stdout(predicate::str::contains("reparse")); +} diff --git a/crates/windowsupport/Cargo.toml b/crates/windowsupport/Cargo.toml new file mode 100644 index 0000000..2462943 --- /dev/null +++ b/crates/windowsupport/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "windowsupport" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Shared Windows-native helpers for Mercury Toolbox commands." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +netstat2.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo.workspace = true +thiserror.workspace = true +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Environment", + "Win32_System_LibraryLoader", + "Win32_System_Registry", + "Win32_System_RemoteDesktop", + "Win32_System_Services", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_UI_Shell", + "Wdk_System_SystemInformation", +] } diff --git a/crates/windowsupport/src/lib.rs b/crates/windowsupport/src/lib.rs new file mode 100644 index 0000000..6dd7909 --- /dev/null +++ b/crates/windowsupport/src/lib.rs @@ -0,0 +1,1700 @@ +#![allow(clippy::multiple_crate_versions)] +//! Shared Windows-native helpers for Mercury Toolbox commands. + +mod sudo; + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::env; +#[cfg(windows)] +use std::ffi::c_void; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +#[cfg(windows)] +use std::ptr::null_mut; +use std::thread; +use std::time::Duration; + +use common::CliError; +use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, get_sockets_info}; +use serde::Serialize; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; +use thiserror::Error; +#[cfg(windows)] +use windows_sys::Wdk::System::SystemInformation::NtQuerySystemInformation; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{ + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, FALSE, HANDLE, INVALID_HANDLE_VALUE, +}; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_TYPE_DISK, GetFileType, GetFinalPathNameByHandleW, VOLUME_NAME_DOS, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE}; + +pub use sudo::{ + LaunchIdentity, LaunchRequest, LaunchResult, PrivilegeMode, ProcessPriority, ShowWindowMode, + TokenIntegrity, TokenStatus, attach_parent_console, current_token_status, + elevate_current_process, launch_request, +}; + +/// Represents a captured environment snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EnvironmentSnapshot { + /// Variable values keyed by name. + pub values: BTreeMap, +} + +/// Describes an added environment variable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EnvAdded { + /// Variable name. + pub name: String, + /// Variable value after the change. + pub value: String, +} + +/// Describes a removed environment variable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EnvRemoved { + /// Variable name. + pub name: String, + /// Variable value before removal. + pub value: String, +} + +/// Describes a changed environment variable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EnvChanged { + /// Variable name. + pub name: String, + /// Value before the change. + pub before: String, + /// Value after the change. + pub after: String, +} + +/// Describes path-like segment changes for a variable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PathLikeChange { + /// Variable name. + pub name: String, + /// Segment count before the change. + pub before_segment_count: usize, + /// Segment count after the change. + pub after_segment_count: usize, + /// Segments added to the path-like variable. + pub added_segments: Vec, + /// Segments removed from the path-like variable. + pub removed_segments: Vec, +} + +/// Stable environment diff shape used by `envdiff`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EnvironmentDiff { + /// Variables only present after execution. + pub added: Vec, + /// Variables removed by execution. + pub removed: Vec, + /// Variables present in both snapshots with changed values. + pub changed: Vec, + /// Path-like segment additions/removals for common variables. + pub path_like_changes: Vec, +} + +/// Process metadata used by `proctree` and `unlock`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProcessDescriptor { + /// Process identifier. + pub pid: u32, + /// Parent process identifier when known. + pub parent_pid: Option, + /// Executable image name. + pub image_name: String, + /// Full executable path when known. + pub exe: Option, + /// Full command line. + pub command_line: Vec, + /// Process start time as seconds since UNIX epoch when known. + pub start_time_unix: u64, + /// Process runtime in seconds. + pub run_time_seconds: u64, +} + +/// Process identity used to re-check a PID before forceful termination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct ProcessKillTarget { + /// Process identifier to terminate. + pub pid: u32, + /// Process start time captured with the PID, when known. + pub start_time_unix: Option, +} + +/// Locker metadata returned by Restart Manager. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LockerProcess { + /// Locked path that triggered this process entry. + pub path: String, + /// Process identifier. + pub pid: u32, + /// Process start time captured with the PID, when known. + pub start_time_unix: Option, + /// Application name reported by Restart Manager. + pub app_name: String, + /// Service short name when applicable. + pub service_name: String, + /// Whether Restart Manager considers this app restartable. + pub restartable: bool, + /// Process image name from a live process snapshot when available. + pub image_name: Option, + /// Full process command line when known. + pub command_line: Vec, +} + +/// Query depth used by file-locker inspection helpers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockerQueryMode { + /// Prefer low-latency sources that are good enough for interactive inspection. + Fast, + /// Use every available source, including global handle scans, for maximum coverage. + Deep, +} + +/// Supported local port ownership protocols. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PortProtocol { + /// TCP socket ownership. + Tcp, + /// UDP socket ownership. + Udp, +} + +/// Port ownership enriched with local process metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortOwner { + /// Local port number. + pub port: u16, + /// Socket protocol. + pub protocol: PortProtocol, + /// Local bind address. + pub local_address: String, + /// TCP state when available. + pub state: Option, + /// Owning process identifier. + pub pid: u32, + /// Process start time captured with the PID, when known. + pub start_time_unix: Option, + /// Executable image name when known. + pub image_name: Option, + /// Service label when known. + pub service_name: Option, + /// Full process command line when known. + pub command_line: Vec, +} + +/// Errors produced by the shared Windows helpers. +#[derive(Debug, Error)] +pub enum WindowsSupportError { + /// Restart Manager failed for the given action. + #[error("restart manager {action} failed with code {code}")] + RestartManager { + /// Operation label. + action: &'static str, + /// Raw Windows error code. + code: u32, + }, + /// A filesystem action failed. + #[error("{0}")] + Io(String), + /// A process action failed. + #[error("{0}")] + Process(String), + /// A direct Windows API call failed. + #[error("{action} failed with code {code}")] + WindowsApi { + /// Operation label. + action: &'static str, + /// Raw Windows error code. + code: u32, + }, + /// The requested operation is unsupported in the current context. + #[error("{0}")] + Unsupported(String), +} + +/// Captures the current process environment. +#[must_use] +pub fn capture_environment() -> EnvironmentSnapshot { + let values = std::env::vars().collect::>(); + EnvironmentSnapshot { values } +} + +/// Reads a line-based environment snapshot file in `NAME=VALUE` format. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read. +pub fn read_environment_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|error| { + WindowsSupportError::Io(format!("failed to read {}: {error}", path.display())) + })?; + let values = content + .lines() + .filter_map(|line| line.split_once('=')) + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect::>(); + Ok(EnvironmentSnapshot { values }) +} + +/// Computes a stable environment diff between two snapshots. +#[must_use] +pub fn diff_environments( + before: &EnvironmentSnapshot, + after: &EnvironmentSnapshot, +) -> EnvironmentDiff { + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut changed = Vec::new(); + let mut path_like_changes = Vec::new(); + + let all_names = before + .values + .keys() + .chain(after.values.keys()) + .cloned() + .collect::>(); + + for name in all_names { + match (before.values.get(&name), after.values.get(&name)) { + (None, Some(value)) => added.push(EnvAdded { + name: name.clone(), + value: value.clone(), + }), + (Some(value), None) => removed.push(EnvRemoved { + name: name.clone(), + value: value.clone(), + }), + (Some(before_value), Some(after_value)) if before_value != after_value => { + changed.push(EnvChanged { + name: name.clone(), + before: before_value.clone(), + after: after_value.clone(), + }); + if is_path_like_name(&name) { + let before_segments = split_path_like(before_value); + let after_segments = split_path_like(after_value); + path_like_changes.push(PathLikeChange { + name, + before_segment_count: before_segments.len(), + after_segment_count: after_segments.len(), + added_segments: after_segments + .iter() + .filter(|segment| !before_segments.contains(*segment)) + .cloned() + .collect(), + removed_segments: before_segments + .iter() + .filter(|segment| !after_segments.contains(*segment)) + .cloned() + .collect(), + }); + } + } + (Some(_), Some(_)) | (None, None) => {} + } + } + + EnvironmentDiff { + added, + removed, + changed, + path_like_changes, + } +} + +/// Captures a fresh process snapshot. +#[must_use] +pub fn snapshot_processes() -> Vec { + let mut system = System::new(); + let _ = system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_exe(UpdateKind::Always), + ); + + let mut processes = system + .processes() + .values() + .map(|process| ProcessDescriptor { + pid: process.pid().as_u32(), + parent_pid: process.parent().map(Pid::as_u32), + image_name: process.name().to_string_lossy().to_string(), + exe: process.exe().map(|path| path.display().to_string()), + command_line: process + .cmd() + .iter() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(), + start_time_unix: process.start_time(), + run_time_seconds: process.run_time(), + }) + .collect::>(); + processes.sort_by_key(|process| process.pid); + processes +} + +/// Captures local TCP and UDP port owners using native socket tables. +/// +/// # Errors +/// +/// Returns an error when the socket table snapshot cannot be read. +pub fn snapshot_port_owners() -> Result, WindowsSupportError> { + let process_map = snapshot_processes() + .into_iter() + .map(|process| (process.pid, process)) + .collect::>(); + let sockets = get_sockets_info( + AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, + ProtocolFlags::TCP | ProtocolFlags::UDP, + ) + .map_err(|error| { + WindowsSupportError::Process(format!("failed to read socket tables: {error}")) + })?; + + let mut owners = Vec::new(); + for socket in sockets { + let pids = if socket.associated_pids.is_empty() { + vec![0_u32] + } else { + socket.associated_pids + }; + for pid in pids { + let process = process_map.get(&pid); + match socket.protocol_socket_info { + ProtocolSocketInfo::Tcp(ref tcp) => owners.push(PortOwner { + port: tcp.local_port, + protocol: PortProtocol::Tcp, + local_address: tcp.local_addr.to_string(), + state: Some(tcp.state.to_string()), + pid, + start_time_unix: process.map(|process| process.start_time_unix), + image_name: process.map(|process| process.image_name.clone()), + service_name: None, + command_line: process + .map(|process| process.command_line.clone()) + .unwrap_or_default(), + }), + ProtocolSocketInfo::Udp(ref udp) => owners.push(PortOwner { + port: udp.local_port, + protocol: PortProtocol::Udp, + local_address: udp.local_addr.to_string(), + state: None, + pid, + start_time_unix: process.map(|process| process.start_time_unix), + image_name: process.map(|process| process.image_name.clone()), + service_name: None, + command_line: process + .map(|process| process.command_line.clone()) + .unwrap_or_default(), + }), + } + } + } + + owners.sort_by(|left, right| { + ( + left.port, + port_protocol_order(left.protocol), + left.pid, + left.local_address.as_str(), + ) + .cmp(&( + right.port, + port_protocol_order(right.protocol), + right.pid, + right.local_address.as_str(), + )) + }); + owners.dedup_by(|left, right| { + left.port == right.port + && left.protocol == right.protocol + && left.pid == right.pid + && left.local_address == right.local_address + }); + Ok(owners) +} + +/// Resolves locker processes for the given path using the requested query depth. +/// +/// # Errors +/// +/// Returns an error when Restart Manager cannot inspect the path. +pub fn query_file_lockers( + path: &Path, + mode: LockerQueryMode, +) -> Result, WindowsSupportError> { + let process_map = snapshot_processes() + .into_iter() + .map(|process| (process.pid, process)) + .collect::>(); + let script = format!( + "{}\n[MercuryRestartManager]::Who('{}') | ConvertTo-Json -Compress", + RESTART_MANAGER_SCRIPT, + escape_pwsh_single_quoted(&path.display().to_string()) + ); + let restart_manager_lockers = invoke_pwsh_json::(&script).map(|value| { + let infos = match value { + serde_json::Value::Array(items) => items + .into_iter() + .filter_map(|item| serde_json::from_value::(item).ok()) + .collect::>(), + serde_json::Value::Object(_) => serde_json::from_value::(value) + .map(|item| vec![item]) + .unwrap_or_default(), + _ => Vec::new(), + }; + infos + .into_iter() + .map(|info| { + let pid = info.pid; + let process = process_map.get(&pid); + LockerProcess { + path: path.display().to_string(), + pid, + start_time_unix: process.map(|process| process.start_time_unix), + app_name: info.app_name, + service_name: info.service_name, + restartable: info.restartable, + image_name: process.map(|process| process.image_name.clone()), + command_line: process + .map(|process| process.command_line.clone()) + .unwrap_or_default(), + } + }) + .collect::>() + }); + let fallback = fallback_lockers(path, &process_map); + match mode { + LockerQueryMode::Fast => resolve_fast_locker_query(restart_manager_lockers, fallback), + LockerQueryMode::Deep => { + let handle_lockers = scan_handle_lockers(path, &process_map); + resolve_deep_locker_query(restart_manager_lockers, handle_lockers, fallback) + } + } +} + +/// Requests a graceful release for any lockers on the given paths. +/// +/// # Errors +/// +/// Returns an error when Restart Manager cannot complete the shutdown request. +pub fn graceful_release(paths: &[PathBuf]) -> Result<(), WindowsSupportError> { + if paths.is_empty() { + return Ok(()); + } + let list = format_pwsh_array(paths); + let script = format!( + "{RESTART_MANAGER_SCRIPT}\n[void][MercuryRestartManager]::Shutdown({list}, $false)" + ); + invoke_pwsh_status(&script) +} + +/// Forces remaining lockers for the given paths to exit. +/// +/// # Errors +/// +/// Returns an error when a blocker process cannot be terminated. +pub fn force_release(paths: &[PathBuf]) -> Result<(), WindowsSupportError> { + force_release_with_locker_query(paths, query_file_lockers) +} + +fn force_release_with_locker_query( + paths: &[PathBuf], + locker_query: Q, +) -> Result<(), WindowsSupportError> +where + Q: Fn(&Path, LockerQueryMode) -> Result, WindowsSupportError>, +{ + let blockers = paths + .iter() + .map(|path| locker_query(path, LockerQueryMode::Fast)) + .collect::, _>>()? + .into_iter() + .flatten() + .map(|locker| ProcessKillTarget { + pid: locker.pid, + start_time_unix: locker.start_time_unix, + }) + .collect::>(); + let blocker_list = blockers.into_iter().collect::>(); + terminate_processes_checked(&blocker_list) +} + +/// Terminates the exact process identifiers provided. +/// +/// # Errors +/// +/// Returns an error when a process cannot be terminated. +pub fn terminate_processes(pids: &[u32]) -> Result<(), WindowsSupportError> { + let targets = pids + .iter() + .copied() + .map(|pid| ProcessKillTarget { + pid, + start_time_unix: None, + }) + .collect::>(); + terminate_processes_checked(&targets) +} + +/// Terminates processes after re-checking PID identity metadata when available. +/// +/// # Errors +/// +/// Returns an error when a process start time no longer matches or termination fails. +pub fn terminate_processes_checked( + targets: &[ProcessKillTarget], +) -> Result<(), WindowsSupportError> { + let snapshot = snapshot_processes(); + let pids = validated_process_kill_pids(targets, &snapshot)?; + terminate_process_ids(&pids) +} + +fn validated_process_kill_pids( + targets: &[ProcessKillTarget], + processes: &[ProcessDescriptor], +) -> Result, WindowsSupportError> { + let process_map = processes + .iter() + .map(|process| (process.pid, process)) + .collect::>(); + let mut unique = targets + .iter() + .copied() + .filter(|target| target.pid != 0) + .collect::>(); + unique.sort_unstable(); + unique.dedup(); + + let mut pids = Vec::with_capacity(unique.len()); + for target in unique { + if let Some(expected_start) = target.start_time_unix { + let Some(process) = process_map.get(&target.pid) else { + continue; + }; + if process.start_time_unix != expected_start { + return Err(WindowsSupportError::Process(format!( + "refusing to terminate pid {} because process start time changed from {} to {}", + target.pid, expected_start, process.start_time_unix + ))); + } + } + pids.push(target.pid); + } + pids.sort_unstable(); + pids.dedup(); + Ok(pids) +} + +fn terminate_process_ids(pids: &[u32]) -> Result<(), WindowsSupportError> { + for pid in pids { + let taskkill = system32_executable_from_root(&windows_root(), "taskkill.exe"); + let output = Command::new(&taskkill) + .args(["/PID", &pid.to_string(), "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| { + WindowsSupportError::Process(format!( + "failed to invoke taskkill for pid {pid}: {error}" + )) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(WindowsSupportError::Process(format!( + "taskkill failed for pid {pid} with status {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ))); + } + } + + Ok(()) +} + +/// Sleeps for the requested duration. +pub fn sleep_for(duration: Duration) { + thread::sleep(duration); +} + +fn is_path_like_name(name: &str) -> bool { + matches!( + name.to_ascii_uppercase().as_str(), + "PATH" | "PSMODULEPATH" | "LIB" | "INCLUDE" + ) +} + +fn split_path_like(value: &str) -> Vec { + value + .split(';') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect() +} + +const fn port_protocol_order(protocol: PortProtocol) -> u8 { + match protocol { + PortProtocol::Tcp => 0, + PortProtocol::Udp => 1, + } +} + +fn fallback_lockers( + path: &Path, + process_map: &HashMap, +) -> Vec { + let needle = path.display().to_string().to_ascii_lowercase(); + let mut excluded = HashSet::new(); + let mut current = Some(std::process::id()); + while let Some(pid) = current { + if !excluded.insert(pid) { + break; + } + current = process_map.get(&pid).and_then(|process| process.parent_pid); + } + process_map + .iter() + .filter(|(pid, _)| !excluded.contains(pid)) + .filter_map(|(pid, process)| { + let command_line = process.command_line.join(" ").to_ascii_lowercase(); + command_line.contains(&needle).then(|| LockerProcess { + path: path.display().to_string(), + pid: *pid, + start_time_unix: Some(process.start_time_unix), + app_name: process.image_name.clone(), + service_name: String::new(), + restartable: false, + image_name: Some(process.image_name.clone()), + command_line: process.command_line.clone(), + }) + }) + .collect() +} + +fn merge_locker_sources( + mut restart_manager: Vec, + handle_scan: Vec, +) -> Vec { + let mut seen = restart_manager + .iter() + .map(|locker| locker.pid) + .collect::>(); + for locker in handle_scan { + if let Some(existing) = restart_manager + .iter_mut() + .find(|existing| existing.pid == locker.pid) + { + if existing.start_time_unix.is_none() { + existing.start_time_unix = locker.start_time_unix; + } + if existing.image_name.is_none() { + existing.image_name.clone_from(&locker.image_name); + } + if existing.command_line.is_empty() { + existing.command_line.clone_from(&locker.command_line); + } + } else if seen.insert(locker.pid) { + restart_manager.push(locker); + } + } + restart_manager.sort_by_key(|locker| locker.pid); + restart_manager +} + +fn resolve_fast_locker_query( + restart_manager: Result, WindowsSupportError>, + fallback: Vec, +) -> Result, WindowsSupportError> { + match restart_manager { + Ok(lockers) => Ok(lockers), + Err(error) => { + if fallback.is_empty() { + Err(error) + } else { + Ok(fallback) + } + } + } +} + +fn resolve_deep_locker_query( + restart_manager: Result, WindowsSupportError>, + handle_scan: Vec, + fallback: Vec, +) -> Result, WindowsSupportError> { + match restart_manager { + Ok(lockers) => { + let lockers = merge_locker_sources(lockers, handle_scan); + if lockers.is_empty() { + Ok(fallback) + } else { + Ok(lockers) + } + } + Err(error) => { + if !handle_scan.is_empty() { + Ok(merge_locker_sources(Vec::new(), handle_scan)) + } else if !fallback.is_empty() { + Ok(fallback) + } else { + Err(error) + } + } + } +} + +fn path_matches_target(candidate: &str, target: &str) -> bool { + let candidate = normalize_windows_path(candidate); + let target = normalize_windows_path(target); + if candidate == target { + return true; + } + candidate + .strip_prefix(&target) + .is_some_and(|tail| tail.starts_with('\\')) +} + +fn normalize_windows_path(path: &str) -> String { + let trimmed = path + .trim() + .trim_start_matches(r"\\?\") + .strip_prefix(r"UNC\") + .map_or_else( + || path.trim().trim_start_matches(r"\\?\").to_string(), + |unc| format!(r"\\{unc}"), + ); + trimmed + .trim_end_matches(['\\', '/']) + .replace('/', "\\") + .to_ascii_lowercase() +} + +#[cfg(not(windows))] +fn scan_handle_lockers( + _path: &Path, + _process_map: &HashMap, +) -> Vec { + Vec::new() +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn scan_handle_lockers( + path: &Path, + process_map: &HashMap, +) -> Vec { + let target = path.display().to_string(); + let Some(handles) = system_handles() else { + return Vec::new(); + }; + let current_process = unsafe { GetCurrentProcess() }; + let mut process_handles = HashMap::::new(); + let mut lockers = Vec::::new(); + let mut seen = HashSet::::new(); + + for handle in &handles { + let Some(locker) = locker_from_handle( + handle, + current_process, + &target, + process_map, + &mut process_handles, + &seen, + ) else { + continue; + }; + seen.insert(locker.pid); + lockers.push(locker); + } + + lockers.sort_by_key(|locker| locker.pid); + lockers +} + +#[cfg(windows)] +const STATUS_INFO_LENGTH_MISMATCH: i32 = -1_073_741_820; +#[cfg(windows)] +const SYSTEM_EXTENDED_HANDLE_INFORMATION: i32 = 64; + +#[cfg(windows)] +#[repr(C)] +#[derive(Clone, Copy)] +struct SystemHandleTableEntryInfoEx { + object: *mut c_void, + unique_process_id: usize, + handle_value: usize, + granted_access: u32, + creator_back_trace_index: u16, + object_type_index: u16, + handle_attributes: u32, + reserved: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct SystemHandleInformationEx { + number_of_handles: usize, + reserved: usize, + handles: [SystemHandleTableEntryInfoEx; 1], +} + +#[cfg(windows)] +#[derive(Debug)] +struct OwnedHandle(HANDLE); + +#[cfg(windows)] +impl OwnedHandle { + fn new(raw: HANDLE) -> Option { + (!raw.is_null() && !std::ptr::eq(raw, INVALID_HANDLE_VALUE)).then_some(Self(raw)) + } + + const fn raw(&self) -> HANDLE { + self.0 + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.0); + } + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn system_handles() -> Option> { + let mut buffer = vec![0_usize; bytes_to_words(1024 * 1024)]; + loop { + let mut return_length = 0_u32; + let status = unsafe { + NtQuerySystemInformation( + SYSTEM_EXTENDED_HANDLE_INFORMATION, + buffer.as_mut_ptr().cast::(), + u32::try_from(buffer.len() * std::mem::size_of::()).ok()?, + &raw mut return_length, + ) + }; + if status == STATUS_INFO_LENGTH_MISMATCH { + resize_handle_buffer(&mut buffer, return_length); + continue; + } + if status < 0 { + return None; + } + break; + } + + let info = unsafe { &*(buffer.as_ptr().cast::()) }; + let first = std::ptr::addr_of!(info.handles).cast::(); + let handles = unsafe { std::slice::from_raw_parts(first, info.number_of_handles) }; + Some(handles.to_vec()) +} + +#[cfg(windows)] +const fn bytes_to_words(bytes: usize) -> usize { + bytes.div_ceil(std::mem::size_of::()) +} + +#[cfg(windows)] +fn resize_handle_buffer(buffer: &mut Vec, return_length: u32) { + let requested = usize::try_from(return_length) + .ok() + .map(bytes_to_words) + .filter(|words| *words > buffer.len()) + .unwrap_or(buffer.len() * 2); + buffer.resize(requested, 0); +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn locker_from_handle( + handle: &SystemHandleTableEntryInfoEx, + current_process: HANDLE, + target: &str, + process_map: &HashMap, + process_handles: &mut HashMap, + seen: &HashSet, +) -> Option { + let pid = u32::try_from(handle.unique_process_id).ok()?; + if pid == 0 || pid == std::process::id() || seen.contains(&pid) { + return None; + } + let process_handle = process_handle_for_pid(pid, process_handles)?; + let duplicate = duplicate_handle(process_handle, handle.handle_value, current_process)?; + if unsafe { GetFileType(duplicate.raw()) } != FILE_TYPE_DISK { + return None; + } + let candidate = final_path_from_handle(duplicate.raw())?; + if !path_matches_target(&candidate, target) { + return None; + } + Some(locker_from_process(pid, target, process_map)) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn process_handle_for_pid( + pid: u32, + process_handles: &mut HashMap, +) -> Option { + if let Some(process_handle) = process_handles.get(&pid) { + return Some(process_handle.raw()); + } + let raw = unsafe { OpenProcess(PROCESS_DUP_HANDLE, FALSE, pid) }; + let process_handle = OwnedHandle::new(raw)?; + let raw = process_handle.raw(); + process_handles.insert(pid, process_handle); + Some(raw) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn duplicate_handle( + process_handle: HANDLE, + handle_value: usize, + current_process: HANDLE, +) -> Option { + let mut duplicate: HANDLE = null_mut(); + let duplicated = unsafe { + DuplicateHandle( + process_handle, + handle_value as HANDLE, + current_process, + &raw mut duplicate, + 0, + FALSE, + DUPLICATE_SAME_ACCESS, + ) + }; + if duplicated == FALSE { + return None; + } + OwnedHandle::new(duplicate) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn final_path_from_handle(handle: HANDLE) -> Option { + let mut buffer = vec![0_u16; 32_768]; + let length = unsafe { + GetFinalPathNameByHandleW( + handle, + buffer.as_mut_ptr(), + u32::try_from(buffer.len()).ok()?, + VOLUME_NAME_DOS, + ) + }; + if length == 0 { + return None; + } + let length = usize::try_from(length).ok()?; + if length >= buffer.len() { + return None; + } + Some(String::from_utf16_lossy(&buffer[..length])) +} + +#[cfg(windows)] +fn locker_from_process( + pid: u32, + target: &str, + process_map: &HashMap, +) -> LockerProcess { + let process = process_map.get(&pid); + let image_name = process.map(|process| process.image_name.clone()); + LockerProcess { + path: target.to_string(), + pid, + start_time_unix: process.map(|process| process.start_time_unix), + app_name: image_name.clone().unwrap_or_else(|| format!("pid:{pid}")), + service_name: String::new(), + restartable: false, + image_name, + command_line: process + .map(|process| process.command_line.clone()) + .unwrap_or_default(), + } +} + +fn invoke_pwsh_status(script: &str) -> Result<(), WindowsSupportError> { + let pwsh = trusted_pwsh_executable()?; + let status = Command::new(&pwsh) + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .output() + .map_err(|error| { + WindowsSupportError::Process(format!("failed to launch {}: {error}", pwsh.display())) + })?; + if status.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&status.stderr); + if let Some((action, code)) = parse_restart_manager_failure(&stderr) { + Err(WindowsSupportError::RestartManager { action, code }) + } else { + Err(WindowsSupportError::Process(format!( + "pwsh script failed with status {}: {}", + status.status, + stderr.trim() + ))) + } + } +} + +fn invoke_pwsh_json(script: &str) -> Result +where + T: serde::de::DeserializeOwned, +{ + let pwsh = trusted_pwsh_executable()?; + let output = Command::new(&pwsh) + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .output() + .map_err(|error| { + WindowsSupportError::Process(format!("failed to launch {}: {error}", pwsh.display())) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if let Some((action, code)) = parse_restart_manager_failure(&stderr) { + return Err(WindowsSupportError::RestartManager { action, code }); + } + return Err(WindowsSupportError::Process(format!( + "pwsh script failed with status {}: {}", + output.status, + stderr.trim() + ))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let normalized = if stdout.trim().is_empty() { + "[]" + } else { + stdout.trim() + }; + serde_json::from_str(normalized).map_err(|error| { + WindowsSupportError::Process(format!("failed to parse pwsh JSON output: {error}")) + }) +} + +fn trusted_pwsh_executable() -> Result { + trusted_pwsh_candidates() + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + WindowsSupportError::Process( + "failed to find trusted PowerShell executable in system locations".to_string(), + ) + }) +} + +fn trusted_pwsh_candidates() -> Vec { + let root = windows_root(); + let program_files = env::var_os("ProgramW6432") + .or_else(|| env::var_os("ProgramFiles")) + .map(PathBuf::from); + let program_files_x86 = env::var_os("ProgramFiles(x86)").map(PathBuf::from); + trusted_pwsh_candidates_from_env( + &root, + program_files.as_deref(), + program_files_x86.as_deref(), + ) +} + +fn trusted_pwsh_candidates_from_env( + root: &Path, + program_files: Option<&Path>, + program_files_x86: Option<&Path>, +) -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = program_files { + candidates.push(path.join("PowerShell").join("7").join("pwsh.exe")); + } + if let Some(path) = program_files_x86 { + let candidate = path.join("PowerShell").join("7").join("pwsh.exe"); + if !candidates.iter().any(|existing| existing == &candidate) { + candidates.push(candidate); + } + } + candidates.push( + root.join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"), + ); + candidates +} + +fn windows_root() -> PathBuf { + env::var_os("SystemRoot") + .or_else(|| env::var_os("WINDIR")) + .map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from) +} + +fn system32_executable_from_root(root: &Path, executable: &str) -> PathBuf { + root.join("System32").join(executable) +} + +fn parse_restart_manager_failure(stderr: &str) -> Option<(&'static str, u32)> { + const PATTERNS: &[(&str, &str, &str)] = &[ + ("query", "RmGetList failed:", "RmGetList failed:"), + ( + "query", + "RmGetList preflight failed:", + "RmGetList preflight failed:", + ), + ( + "register", + "RmRegisterResources failed:", + "RmRegisterResources failed:", + ), + ( + "start_session", + "RmStartSession failed:", + "RmStartSession failed:", + ), + ("shutdown", "RmShutdown failed:", "RmShutdown failed:"), + ]; + for (action, marker, trim_marker) in PATTERNS { + if let Some(index) = stderr.find(marker) { + let tail = &stderr[index + trim_marker.len()..]; + let digits = tail + .chars() + .skip_while(|character| character.is_whitespace()) + .take_while(char::is_ascii_digit) + .collect::(); + if let Ok(code) = digits.parse::() { + return Some((action, code)); + } + } + } + None +} + +const RESTART_MANAGER_SCRIPT: &str = r#" +if (-not ('MercuryRestartManager' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public static class MercuryRestartManager +{ + [StructLayout(LayoutKind.Sequential)] + public struct RM_UNIQUE_PROCESS + { + public int dwProcessId; + public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO + { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public int ApplicationType; + public uint AppStatus; + public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] + public bool bRestartable; + } + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + private static extern int RmStartSession(out uint sessionHandle, int sessionFlags, string sessionKey); + + [DllImport("rstrtmgr.dll")] + private static extern int RmEndSession(uint sessionHandle); + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + private static extern int RmRegisterResources(uint sessionHandle, uint nFiles, string[] files, uint nApplications, IntPtr applications, uint nServices, string[] services); + + [DllImport("rstrtmgr.dll")] + private static extern int RmGetList(uint sessionHandle, out uint procInfoNeeded, ref uint procInfo, [In, Out] RM_PROCESS_INFO[] processInfo, ref uint rebootReasons); + + [DllImport("rstrtmgr.dll")] + private static extern int RmShutdown(uint sessionHandle, uint actionFlags, IntPtr callback); + + public static Dictionary[] Who(string path) + { + uint handle; + string key = Guid.NewGuid().ToString("N"); + int start = RmStartSession(out handle, 0, key); + if (start != 0) + { + throw new InvalidOperationException("RmStartSession failed: " + start); + } + + try + { + int register = RmRegisterResources(handle, 1, new[] { path }, 0, IntPtr.Zero, 0, null); + if (register != 0) + { + throw new InvalidOperationException("RmRegisterResources failed: " + register); + } + + uint needed = 0; + uint count = 0; + uint rebootReasons = 0; + int first = RmGetList(handle, out needed, ref count, null, ref rebootReasons); + if (first == 0) + { + return Array.Empty>(); + } + if (first != 234) + { + throw new InvalidOperationException("RmGetList preflight failed: " + first); + } + + RM_PROCESS_INFO[] infos = new RM_PROCESS_INFO[needed]; + count = needed; + int second = RmGetList(handle, out needed, ref count, infos, ref rebootReasons); + if (second != 0) + { + throw new InvalidOperationException("RmGetList failed: " + second); + } + + var results = new List>(); + for (int i = 0; i < count; i++) + { + results.Add(new Dictionary + { + ["pid"] = (uint)infos[i].Process.dwProcessId, + ["app_name"] = infos[i].strAppName ?? "", + ["service_name"] = infos[i].strServiceShortName ?? "", + ["restartable"] = infos[i].bRestartable, + }); + } + return results.ToArray(); + } + finally + { + RmEndSession(handle); + } + } + + public static void Shutdown(string[] paths, bool force) + { + uint handle; + string key = Guid.NewGuid().ToString("N"); + int start = RmStartSession(out handle, 0, key); + if (start != 0) + { + throw new InvalidOperationException("RmStartSession failed: " + start); + } + + try + { + int register = RmRegisterResources(handle, (uint)paths.Length, paths, 0, IntPtr.Zero, 0, null); + if (register != 0) + { + throw new InvalidOperationException("RmRegisterResources failed: " + register); + } + + int shutdown = RmShutdown(handle, force ? 1u : 0u, IntPtr.Zero); + if (shutdown != 0 && shutdown != 121) + { + throw new InvalidOperationException("RmShutdown failed: " + shutdown); + } + } + finally + { + RmEndSession(handle); + } + } +} +'@ +} +"#; + +fn format_pwsh_array(paths: &[PathBuf]) -> String { + let mut output = String::from("@("); + for (index, path) in paths.iter().enumerate() { + if index > 0 { + output.push_str(", "); + } + let _ = write!( + output, + "'{}'", + escape_pwsh_single_quoted(&path.display().to_string()) + ); + } + output.push(')'); + output +} + +fn escape_pwsh_single_quoted(value: &str) -> String { + value.replace('\'', "''") +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +struct PwshLockerInfo { + pid: u32, + app_name: String, + service_name: String, + restartable: bool, +} + +impl From for CliError { + fn from(value: WindowsSupportError) -> Self { + Self::runtime(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::net::{TcpListener, UdpSocket}; + + #[test] + fn environment_diff_tracks_changes_and_path_segments() { + let before = EnvironmentSnapshot { + values: BTreeMap::from([ + ("PATH".to_string(), r"C:\A;C:\B".to_string()), + ("KEEP".to_string(), "old".to_string()), + ]), + }; + let after = EnvironmentSnapshot { + values: BTreeMap::from([ + ("PATH".to_string(), r"C:\B;C:\C".to_string()), + ("KEEP".to_string(), "new".to_string()), + ("ADD".to_string(), "x".to_string()), + ]), + }; + + let diff = diff_environments(&before, &after); + assert_eq!(diff.added.len(), 1); + assert_eq!(diff.removed.len(), 0); + assert_eq!(diff.changed.len(), 2); + assert_eq!(diff.path_like_changes[0].added_segments, vec![r"C:\C"]); + assert_eq!(diff.path_like_changes[0].removed_segments, vec![r"C:\A"]); + } + + #[test] + fn split_path_like_discards_empty_segments() { + assert_eq!(split_path_like(r"C:\A;;C:\B;"), vec![r"C:\A", r"C:\B"]); + assert!(is_path_like_name("PATH")); + assert!(!is_path_like_name("HOME")); + } + + #[test] + fn capture_read_and_path_helpers_cover_snapshot_io() { + let captured = capture_environment(); + assert!(captured.values.contains_key("PATH") || !captured.values.is_empty()); + + let file = std::env::temp_dir().join(format!( + "windowsupport-env-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::write(&file, "ONE=1\nTWO=2\n").expect("env file"); + let loaded = read_environment_file(&file).expect("read env"); + assert_eq!(loaded.values.get("ONE"), Some(&"1".to_string())); + assert_eq!( + format_pwsh_array(&[PathBuf::from("C:\\Tool"), PathBuf::from("C:\\O'Hare")]), + "@('C:\\Tool', 'C:\\O''Hare')" + ); + assert_eq!(escape_pwsh_single_quoted("a'b"), "a''b"); + let _ = fs::remove_file(file); + } + + #[test] + fn trusted_helper_paths_do_not_use_path_search() { + let root = Path::new(r"C:\Windows"); + assert_eq!( + system32_executable_from_root(root, "taskkill.exe"), + root.join("System32").join("taskkill.exe") + ); + assert_eq!( + trusted_pwsh_candidates_from_env( + root, + Some(Path::new(r"C:\Program Files")), + Some(Path::new(r"C:\Program Files (x86)")), + ), + vec![ + PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"), + PathBuf::from(r"C:\Program Files (x86)\PowerShell\7\pwsh.exe"), + PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), + ] + ); + } + + #[test] + fn process_and_pwsh_helpers_cover_success_paths() { + let processes = snapshot_processes(); + assert!(!processes.is_empty()); + assert!( + processes + .iter() + .any(|process| process.pid == std::process::id()) + ); + + invoke_pwsh_status("$value = 1").expect("status"); + let value = + invoke_pwsh_json::("@{ ok = $true } | ConvertTo-Json -Compress") + .expect("json"); + assert_eq!( + value.get("ok").and_then(serde_json::Value::as_bool), + Some(true) + ); + } + + #[test] + fn locker_operations_cover_empty_and_unlocked_paths() { + graceful_release(&[]).expect("empty graceful release"); + + let file = std::env::temp_dir().join(format!( + "windowsupport-lock-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::write(&file, "free").expect("fixture"); + let lockers = query_file_lockers(&file, LockerQueryMode::Fast).expect("query lockers"); + assert!( + lockers.is_empty() + || lockers + .iter() + .all(|locker| locker.path == file.display().to_string()) + ); + force_release_with_locker_query(std::slice::from_ref(&file), |_, _| Ok(Vec::new())) + .expect("force release on unlocked file"); + let _ = fs::remove_file(file); + } + + #[test] + fn locker_source_merge_deduplicates_and_enriches_restart_manager_results() { + let restart_manager = vec![LockerProcess { + path: r"C:\Temp\locked.txt".to_string(), + pid: 42, + start_time_unix: Some(1_000), + app_name: "RestartManagerApp".to_string(), + service_name: String::new(), + restartable: true, + image_name: None, + command_line: Vec::new(), + }]; + let handle_scan = vec![ + LockerProcess { + path: r"C:\Temp\locked.txt".to_string(), + pid: 42, + start_time_unix: Some(1_000), + app_name: "locker.exe".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("locker.exe".to_string()), + command_line: vec!["locker.exe C:\\Temp\\locked.txt".to_string()], + }, + LockerProcess { + path: r"C:\Temp\locked.txt".to_string(), + pid: 7, + start_time_unix: Some(2_000), + app_name: "extra.exe".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("extra.exe".to_string()), + command_line: vec!["extra.exe".to_string()], + }, + ]; + + let merged = merge_locker_sources(restart_manager, handle_scan); + + assert_eq!(merged.len(), 2); + let restart_manager_entry = merged + .iter() + .find(|locker| locker.pid == 42) + .expect("restart manager entry remains"); + assert_eq!(restart_manager_entry.app_name, "RestartManagerApp"); + assert!(restart_manager_entry.restartable); + assert_eq!( + restart_manager_entry.image_name.as_deref(), + Some("locker.exe") + ); + assert_eq!( + restart_manager_entry.command_line, + vec!["locker.exe C:\\Temp\\locked.txt".to_string()] + ); + assert!(merged.iter().any(|locker| locker.pid == 7)); + } + + #[test] + fn process_kill_targets_reject_pid_reuse_by_start_time() { + let processes = vec![ProcessDescriptor { + pid: 42, + parent_pid: None, + image_name: "server.exe".to_string(), + exe: None, + command_line: vec!["server.exe".to_string()], + start_time_unix: 2_000, + run_time_seconds: 1, + }]; + + let error = validated_process_kill_pids( + &[ProcessKillTarget { + pid: 42, + start_time_unix: Some(1_000), + }], + &processes, + ) + .expect_err("changed start time should be rejected"); + assert!(error.to_string().contains("start time changed")); + + let stale_exit = validated_process_kill_pids( + &[ProcessKillTarget { + pid: 43, + start_time_unix: Some(1_000), + }], + &processes, + ) + .expect("already exited pid should be skipped"); + assert!(stale_exit.is_empty()); + + let unchecked = validated_process_kill_pids( + &[ProcessKillTarget { + pid: 44, + start_time_unix: None, + }], + &processes, + ) + .expect("bare pid remains supported"); + assert_eq!(unchecked, vec![44]); + } + + #[test] + fn fast_locker_query_uses_fallback_when_restart_manager_fails() { + let restart_manager = Err(WindowsSupportError::RestartManager { + action: "query", + code: 5, + }); + let fallback = vec![LockerProcess { + path: r"C:\Users\example\Documents\locked.txt".to_string(), + pid: 77, + start_time_unix: Some(3_000), + app_name: "pwsh.exe".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("pwsh.exe".to_string()), + command_line: vec!["pwsh.exe".to_string(), "locked.txt".to_string()], + }]; + + let lockers = resolve_fast_locker_query(restart_manager, fallback).expect("fast fallback"); + + assert_eq!(lockers.len(), 1); + assert_eq!(lockers[0].pid, 77); + } + + #[test] + fn deep_locker_query_uses_handle_scan_when_restart_manager_fails() { + let restart_manager = Err(WindowsSupportError::RestartManager { + action: "query", + code: 5, + }); + let handle_scan = vec![LockerProcess { + path: r"C:\Users\example\Documents\ExampleProject".to_string(), + pid: 4242, + start_time_unix: Some(4_000), + app_name: "node_repl.exe".to_string(), + service_name: String::new(), + restartable: false, + image_name: Some("node_repl.exe".to_string()), + command_line: vec!["node_repl.exe".to_string()], + }]; + + let lockers = resolve_deep_locker_query(restart_manager, handle_scan, Vec::new()) + .expect("handle fallback"); + + assert_eq!(lockers.len(), 1); + assert_eq!(lockers[0].app_name, "node_repl.exe"); + } + + #[test] + fn fallback_lockers_reads_existing_process_snapshot_command_lines() { + let path = PathBuf::from(r"C:\Temp\locked.txt"); + let mut process_map = HashMap::new(); + process_map.insert( + 42, + ProcessDescriptor { + pid: 42, + parent_pid: None, + image_name: "pwsh.exe".to_string(), + exe: None, + command_line: vec![ + "pwsh.exe".to_string(), + "-File".to_string(), + r"C:\Temp\locker.ps1".to_string(), + r"C:\Temp\locked.txt".to_string(), + ], + start_time_unix: 0, + run_time_seconds: 0, + }, + ); + + let lockers = fallback_lockers(&path, &process_map); + assert_eq!(lockers.len(), 1); + assert_eq!(lockers[0].pid, 42); + assert_eq!(lockers[0].command_line.len(), 4); + } + + #[test] + fn locker_path_matching_is_case_insensitive_and_directory_aware() { + assert!(path_matches_target( + r"C:\Temp\Locked.txt", + r"c:\temp\locked.txt" + )); + assert!(path_matches_target(r"C:\Temp\Child\locked.txt", r"c:\temp")); + assert!(!path_matches_target(r"C:\Template\locked.txt", r"c:\temp")); + } + + #[test] + fn port_snapshot_reports_tcp_and_udp_owners() { + let tcp = TcpListener::bind("127.0.0.1:0").expect("tcp"); + let udp = UdpSocket::bind("127.0.0.1:0").expect("udp"); + let tcp_port = tcp.local_addr().expect("tcp addr").port(); + let udp_port = udp.local_addr().expect("udp addr").port(); + + let owners = snapshot_port_owners().expect("port owners"); + let tcp_owner = owners + .iter() + .find(|owner| owner.port == tcp_port && owner.protocol == PortProtocol::Tcp) + .expect("tcp owner"); + let udp_owner = owners + .iter() + .find(|owner| owner.port == udp_port && owner.protocol == PortProtocol::Udp) + .expect("udp owner"); + + assert_eq!(tcp_owner.pid, std::process::id()); + assert_eq!(udp_owner.pid, std::process::id()); + assert_eq!(tcp_owner.state.as_deref(), Some("LISTEN")); + assert!(tcp_owner.local_address.contains("127.0.0.1")); + assert!(udp_owner.local_address.contains("127.0.0.1")); + } +} diff --git a/crates/windowsupport/src/sudo.rs b/crates/windowsupport/src/sudo.rs new file mode 100644 index 0000000..46ef1c2 --- /dev/null +++ b/crates/windowsupport/src/sudo.rs @@ -0,0 +1,2091 @@ +//! Windows-native helpers for privileged process launch and token inspection. + +use serde::Serialize; + +/// Target identity used for a privileged process launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LaunchIdentity { + /// Run as the current process token without UAC elevation. + CurrentProcess, + /// Run as the active interactive user. + CurrentUser, + /// Run as an elevated administrator. + Admin, + /// Run as `NT AUTHORITY\SYSTEM`. + System, + /// Run as `NT SERVICE\TrustedInstaller`. + TrustedInstaller, +} + +/// Privilege policy applied before launching a process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivilegeMode { + /// Keep the duplicated token privileges unchanged. + Default, + /// Attempt to enable all privileges present on the token. + EnableAll, + /// Attempt to disable all privileges present on the duplicated token. + DisableAll, +} + +/// Token integrity level used for status reports and optional launch shaping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenIntegrity { + /// Untrusted integrity. + Untrusted, + /// Low integrity. + Low, + /// Medium integrity. + Medium, + /// Medium-plus integrity. + MediumPlus, + /// High integrity. + High, + /// System integrity. + System, + /// Unknown or unmapped integrity. + Unknown, +} + +/// Process priority class used during launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProcessPriority { + /// Idle priority. + Idle, + /// Below normal priority. + BelowNormal, + /// Normal priority. + Normal, + /// Above normal priority. + AboveNormal, + /// High priority. + High, + /// Realtime priority. + Realtime, +} + +/// Window presentation mode used for a launch request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShowWindowMode { + /// Let Windows decide. + Default, + /// Hide the new window. + Hidden, + /// Show a normal window. + Normal, + /// Show a minimized window. + Minimized, + /// Show a maximized window. + Maximized, +} + +/// Stable status report for the current process token and privileged-launch capabilities. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TokenStatus { + /// Whether the current process is elevated. + pub is_elevated: bool, + /// Whether the token belongs to an administrators group member. + pub is_admin_member: bool, + /// Current integrity level. + pub integrity: TokenIntegrity, + /// Current Windows user name when available. + pub current_user: Option, + /// Current process session identifier when available. + pub session_id: Option, + /// Active console session identifier when available. + pub active_session_id: Option, + /// Whether the current process can directly launch as administrator. + pub can_admin: bool, + /// Whether an active interactive user token can be used. + pub can_current_user: bool, + /// Whether the current process can directly launch as `SYSTEM`. + pub can_system: bool, + /// Whether the current process can directly launch as `TrustedInstaller`. + pub can_trustedinstaller: bool, + /// Whether the `TrustedInstaller` service exists. + pub trustedinstaller_installed: bool, + /// Whether the `TrustedInstaller` service is already running. + pub trustedinstaller_running: bool, +} + +/// High-level launch request consumed by `msudo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchRequest { + /// Program path or command name. + pub program: String, + /// Command arguments excluding the program itself. + pub args: Vec, + /// Current working directory for the launched process. + pub current_directory: Option, + /// Optional stdout redirection path used by short-lived relays. + pub stdout_path: Option, + /// Optional stderr redirection path used by short-lived relays. + pub stderr_path: Option, + /// Target identity. + pub identity: LaunchIdentity, + /// Privilege policy. + pub privileges: PrivilegeMode, + /// Optional integrity shaping request. + pub integrity: Option, + /// Process priority class. + pub priority: ProcessPriority, + /// Window presentation mode. + pub show_window: ShowWindowMode, + /// Optional target session id. + pub session: Option, + /// Whether the caller explicitly wants a new window. + pub new_window: bool, + /// Whether the launched process should take over the caller's current console. + pub same_console: bool, + /// Whether to wait for completion and capture the exit code. + pub wait: bool, +} + +/// Result returned after a privileged launch attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct LaunchResult { + /// Spawned process identifier when available. + pub pid: Option, + /// Child exit code when `wait` was requested and the process exited normally. + pub exit_code: Option, + /// Identity used for the launched process. + pub identity: LaunchIdentity, +} + +#[cfg(not(windows))] +/// Reports the current process token status. +pub fn current_token_status() -> Result { + Err(WindowsSupportError::Unsupported( + "msudo is only supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +/// Relaunches the current executable with UAC elevation. +pub fn elevate_current_process( + _arguments: &[String], + _wait: bool, + _show_window: ShowWindowMode, +) -> Result { + Err(WindowsSupportError::Unsupported( + "msudo is only supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +/// Reattaches the current process to the parent's console so foreground launches can share it. +pub fn attach_parent_console(_parent_pid: u32) -> Result<(), WindowsSupportError> { + Err(WindowsSupportError::Unsupported( + "msudo is only supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +/// Launches a process with the requested identity. +pub fn launch_request(_request: &LaunchRequest) -> Result { + Err(WindowsSupportError::Unsupported( + "msudo is only supported on Windows".to_string(), + )) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod imp { + use std::ffi::{OsStr, c_void}; + use std::fs::{File, OpenOptions}; + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use std::ptr::{null, null_mut}; + use std::thread; + use std::time::{Duration, Instant}; + + use windows_sys::Win32::Foundation::{ + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_ACCESS_DENIED, + ERROR_NOT_ALL_ASSIGNED, FALSE, GetLastError, HANDLE, INVALID_HANDLE_VALUE, TRUE, + WAIT_FAILED, WAIT_OBJECT_0, + }; + use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, CheckTokenMembership, CreateWellKnownSid, DuplicateTokenEx, + GetTokenInformation, ImpersonateLoggedOnUser, LookupAccountSidW, LookupPrivilegeValueW, + RevertToSelf, SE_PRIVILEGE_ENABLED, SecurityImpersonation, SetTokenInformation, + TOKEN_ACCESS_MASK, TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_PRIVILEGES, TOKEN_ADJUST_SESSIONID, + TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, TOKEN_ELEVATION, TOKEN_IMPERSONATE, + TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TOKEN_QUERY, TokenElevation, TokenIntegrityLevel, + TokenSessionId, WinBuiltinAdministratorsSid, WinLocalSystemSid, + }; + use windows_sys::Win32::System::Console::{ + AttachConsole, FreeConsole, GetStdHandle, STD_ERROR_HANDLE, STD_HANDLE, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, SetConsoleCtrlHandler, + }; + use windows_sys::Win32::System::Environment::{ + CreateEnvironmentBlock, DestroyEnvironmentBlock, + }; + use windows_sys::Win32::System::RemoteDesktop::{ + ProcessIdToSessionId, WTSGetActiveConsoleSessionId, WTSQueryUserToken, + }; + use windows_sys::Win32::System::Services::{ + CloseServiceHandle, OpenSCManagerW, OpenServiceW, QueryServiceStatusEx, SC_HANDLE, + SC_MANAGER_CONNECT, SC_STATUS_PROCESS_INFO, SERVICE_QUERY_STATUS, SERVICE_RUNNING, + SERVICE_START, SERVICE_START_PENDING, SERVICE_STATUS_PROCESS, StartServiceW, + }; + use windows_sys::Win32::System::SystemServices::SE_GROUP_INTEGRITY; + use windows_sys::Win32::System::Threading::{ + ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CREATE_NEW_CONSOLE, + CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessAsUserW, CreateProcessW, + GetCurrentProcess, GetCurrentProcessId, GetExitCodeProcess, HIGH_PRIORITY_CLASS, + IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, OpenProcess, OpenProcessToken, + PROCESS_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, REALTIME_PRIORITY_CLASS, + STARTF_USESHOWWINDOW, STARTF_USESTDHANDLES, STARTUPINFOW, WaitForSingleObject, + }; + use windows_sys::Win32::UI::Shell::{ + SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW, + }; + + use crate::WindowsSupportError; + use crate::sudo::{ + LaunchIdentity, LaunchRequest, LaunchResult, PrivilegeMode, ProcessPriority, + ShowWindowMode, TokenIntegrity, TokenStatus, + }; + + const SECURITY_MAX_SID_SIZE: usize = 68; + const START_TIMEOUT: Duration = Duration::from_secs(10); + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + const SW_HIDE_VALUE: u16 = 0; + const SW_NORMAL_VALUE: u16 = 1; + const SW_SHOWMINIMIZED_VALUE: u16 = 2; + const SW_SHOWMAXIMIZED_VALUE: u16 = 3; + const SW_SHOWDEFAULT_VALUE: u16 = 10; + + const SECURITY_MANDATORY_UNTRUSTED_RID: u32 = 0x0000_0000; + const SECURITY_MANDATORY_LOW_RID: u32 = 0x0000_1000; + const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x0000_2000; + const SECURITY_MANDATORY_MEDIUM_PLUS_RID: u32 = SECURITY_MANDATORY_MEDIUM_RID + 0x100; + const SECURITY_MANDATORY_HIGH_RID: u32 = 0x0000_3000; + const SECURITY_MANDATORY_SYSTEM_RID: u32 = 0x0000_4000; + + #[derive(Debug)] + struct OwnedHandle(HANDLE); + + impl OwnedHandle { + fn new(raw: HANDLE) -> Result { + if raw.is_null() || std::ptr::eq(raw, INVALID_HANDLE_VALUE) { + Err(last_error("acquire handle")) + } else { + Ok(Self(raw)) + } + } + + const fn raw(&self) -> HANDLE { + self.0 + } + } + + impl Drop for OwnedHandle { + fn drop(&mut self) { + let _ = unsafe { CloseHandle(self.0) }; + } + } + + #[derive(Debug)] + struct ImpersonationGuard { + _token: OwnedHandle, + } + + impl ImpersonationGuard { + fn as_system() -> Result { + Self::as_system_with_privileges(&[]) + } + + fn as_system_with_privileges(privileges: &[&str]) -> Result { + let token = open_system_impersonation_token()?; + for privilege in privileges { + enable_named_privilege(token.raw(), privilege)?; + } + #[allow(unsafe_code)] + let impersonated = unsafe { ImpersonateLoggedOnUser(token.raw()) }; + if impersonated == FALSE { + return Err(last_error("ImpersonateLoggedOnUser")); + } + Ok(Self { _token: token }) + } + } + + impl Drop for ImpersonationGuard { + fn drop(&mut self) { + let _ = unsafe { RevertToSelf() }; + } + } + + #[derive(Debug)] + struct OwnedServiceHandle(SC_HANDLE); + + impl OwnedServiceHandle { + fn new(raw: SC_HANDLE, action: &'static str) -> Result { + if raw.is_null() { + Err(last_error(action)) + } else { + Ok(Self(raw)) + } + } + + const fn raw(&self) -> SC_HANDLE { + self.0 + } + } + + impl Drop for OwnedServiceHandle { + fn drop(&mut self) { + let _ = unsafe { CloseServiceHandle(self.0) }; + } + } + + #[derive(Debug)] + struct OwnedEnvironmentBlock(*mut c_void); + + impl OwnedEnvironmentBlock { + fn for_token(token: HANDLE, inherit: bool) -> Result { + let mut block = null_mut(); + #[allow(unsafe_code)] + let created = + unsafe { CreateEnvironmentBlock(&raw mut block, token, i32::from(inherit)) }; + if created == FALSE { + return Err(last_error("CreateEnvironmentBlock")); + } + Ok(Self(block)) + } + + const fn raw(&self) -> *const c_void { + self.0.cast::() + } + } + + impl Drop for OwnedEnvironmentBlock { + fn drop(&mut self) { + #[allow(unsafe_code)] + let _ = unsafe { DestroyEnvironmentBlock(self.0.cast::()) }; + } + } + + #[derive(Debug)] + struct InheritedStdHandles { + input: OwnedHandle, + output: OwnedHandle, + error: OwnedHandle, + _stdout_file: Option, + _stderr_file: Option, + } + + #[derive(Debug)] + struct ConsoleCtrlGuard { + ignore_ctrl_c: bool, + } + + impl ConsoleCtrlGuard { + fn ignore(ignore_ctrl_c: bool) -> Result { + if !ignore_ctrl_c { + return Ok(Self { ignore_ctrl_c }); + } + #[allow(unsafe_code)] + let installed = unsafe { SetConsoleCtrlHandler(None, 1) }; + if installed == FALSE { + return Err(last_error("SetConsoleCtrlHandler")); + } + Ok(Self { ignore_ctrl_c }) + } + } + + impl Drop for ConsoleCtrlGuard { + fn drop(&mut self) { + if self.ignore_ctrl_c { + #[allow(unsafe_code)] + let _ = unsafe { SetConsoleCtrlHandler(None, 0) }; + } + } + } + + /// Reports the current process token status and privileged-launch capabilities. + /// Query the current process token state and launch capabilities. + /// + /// # Errors + /// + /// Returns an error when the current process token cannot be inspected. + pub fn current_token_status() -> Result { + let token = open_current_token(TOKEN_QUERY)?; + let is_elevated = token_elevation(token.raw())?; + let is_admin_member = current_token_is_admin_member()?; + let integrity = token_integrity(token.raw())?; + let current_user = token_user_name(token.raw())?; + let session_id = current_process_session_id(); + let active_session_id = active_console_session_id(); + let (trustedinstaller_installed, trustedinstaller_running) = trustedinstaller_presence()?; + Ok(TokenStatus { + is_elevated, + is_admin_member, + integrity, + current_user, + session_id, + active_session_id, + can_admin: true, + can_current_user: active_session_id.is_some(), + can_system: is_elevated, + can_trustedinstaller: is_elevated && trustedinstaller_installed, + trustedinstaller_installed, + trustedinstaller_running, + }) + } + + /// Relaunches the current executable through the Windows `runas` verb. + /// Relaunch the current executable with `runas` so it can continue privileged work. + /// + /// # Errors + /// + /// Returns an error when the elevated helper cannot be started or waited on. + /// + /// # Panics + /// + /// Panics only if a Win32 structure size unexpectedly stops fitting in `u32`. + pub fn elevate_current_process( + arguments: &[String], + wait: bool, + show_window: ShowWindowMode, + ) -> Result { + let executable = std::env::current_exe().map_err(|error| { + WindowsSupportError::Process(format!("failed to locate current executable: {error}")) + })?; + let executable_wide = wide_from_os(executable.as_os_str()); + let verb = wide("runas"); + let parameters = quote_command_line(arguments.iter().map(String::as_str)); + let parameters_wide = wide(¶meters); + let mut execute = SHELLEXECUTEINFOW { + cbSize: u32::try_from(size_of::()) + .expect("shell execute info size always fits in u32"), + fMask: SEE_MASK_NOCLOSEPROCESS, + lpVerb: verb.as_ptr(), + lpFile: executable_wide.as_ptr(), + lpParameters: parameters_wide.as_ptr(), + nShow: i32::from(show_window_value(show_window)), + ..SHELLEXECUTEINFOW::default() + }; + #[allow(unsafe_code)] + if unsafe { ShellExecuteExW(&raw mut execute) } == FALSE { + return Err(last_error("ShellExecuteExW")); + } + let process = if execute.hProcess.is_null() { + None + } else { + Some(OwnedHandle::new(execute.hProcess)?) + }; + let (pid, exit_code) = if let Some(process) = process.as_ref() { + let pid = process_id_from_handle(process.raw())?; + let exit_code = if wait { + Some(wait_for_exit_code(process.raw())?) + } else { + None + }; + (Some(pid), exit_code) + } else { + (None, None) + }; + Ok(LaunchResult { + pid, + exit_code, + identity: LaunchIdentity::Admin, + }) + } + + /// Reattach the current process to a caller-owned console before starting a foreground child. + /// + /// # Errors + /// + /// Returns an error when the console cannot be rebound to the requested parent process. + pub fn attach_parent_console(parent_pid: u32) -> Result<(), WindowsSupportError> { + if parent_pid == 0 { + return Err(WindowsSupportError::Process( + "same-console relay requires a non-zero parent console pid".to_string(), + )); + } + #[allow(unsafe_code)] + let _ = unsafe { FreeConsole() }; + #[allow(unsafe_code)] + let attached = unsafe { AttachConsole(parent_pid) }; + if attached == FALSE { + return Err(last_error("AttachConsole")); + } + Ok(()) + } + + /// Launches a process with the requested identity in the current elevated context. + /// Launch a process using the requested identity and token shaping options. + /// + /// # Errors + /// + /// Returns an error when token acquisition, mutation, or process creation fails. + pub fn launch_request(request: &LaunchRequest) -> Result { + match request.identity { + LaunchIdentity::CurrentUser => launch_with_active_user(request), + LaunchIdentity::Admin if admin_launch_requires_duplicate_token(request) => { + launch_with_duplicate_identity(request, LaunchIdentity::Admin) + } + LaunchIdentity::CurrentProcess | LaunchIdentity::Admin => { + launch_with_current_identity(request) + } + LaunchIdentity::System => { + launch_with_duplicate_identity(request, LaunchIdentity::System) + } + LaunchIdentity::TrustedInstaller => { + launch_with_duplicate_identity(request, LaunchIdentity::TrustedInstaller) + } + } + } + + const fn admin_launch_requires_duplicate_token(request: &LaunchRequest) -> bool { + request.session.is_some() + || request.integrity.is_some() + || !matches!(request.privileges, PrivilegeMode::Default) + } + + fn launch_with_current_identity( + request: &LaunchRequest, + ) -> Result { + let desktop = interactive_desktop(request); + let mut startup = build_startup_info(request.show_window, desktop.as_deref()); + let std_handles = inherited_standard_handles(request)?; + apply_standard_handles(&mut startup, std_handles.as_ref()); + let mut process_info = PROCESS_INFORMATION::default(); + let application = application_name_wide(&request.program); + let command_line_text = build_process_command_line(&request.program, &request.args); + let mut command_line = wide(&command_line_text); + let mut environment = build_environment_block(); + let current_directory = request.current_directory.as_deref().map(wide); + let creation_flags = creation_flags(request); + #[allow(unsafe_code)] + let created = unsafe { + CreateProcessW( + application.as_ref().map_or(null(), Vec::as_ptr), + command_line.as_mut_ptr(), + null(), + null(), + inherit_handles_flag(std_handles.as_ref()), + creation_flags, + environment.as_mut_ptr().cast::(), + current_directory.as_ref().map_or(null(), Vec::as_ptr), + &raw mut startup, + &raw mut process_info, + ) + }; + if created == FALSE { + return Err(last_error("CreateProcessW")); + } + let thread = OwnedHandle::new(process_info.hThread)?; + drop(thread); + let process = OwnedHandle::new(process_info.hProcess)?; + let exit_code = if request.wait { + Some(wait_for_exit_code(process.raw())?) + } else { + None + }; + Ok(LaunchResult { + pid: Some(process_info.dwProcessId), + exit_code, + identity: request.identity, + }) + } + + fn launch_with_duplicate_identity( + request: &LaunchRequest, + identity: LaunchIdentity, + ) -> Result { + let status = current_token_status()?; + if !status.is_elevated { + return Err(WindowsSupportError::Unsupported(format!( + "{identity:?} launch requires an elevated administrator context" + ))); + } + enable_token_discovery_privileges()?; + let token = duplicate_primary_token(identity, None)?; + let _impersonation = ImpersonationGuard::as_system_with_privileges(&[ + "SeTcbPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeIncreaseQuotaPrivilege", + "SeImpersonatePrivilege", + "SeDebugPrivilege", + ])?; + if let Some(session_id) = resolve_target_session_id(request.session) { + set_token_session_id(token.raw(), session_id)?; + } + apply_token_options(&token, request.privileges, request.integrity)?; + launch_with_token_as_user(request, identity, &token) + } + + fn duplicate_primary_token( + identity: LaunchIdentity, + session: Option, + ) -> Result { + let source = match identity { + LaunchIdentity::Admin | LaunchIdentity::CurrentProcess => open_current_token( + TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, + )?, + LaunchIdentity::CurrentUser => open_active_user_process_token(session)?, + LaunchIdentity::System => open_system_process_token()?, + LaunchIdentity::TrustedInstaller => open_trustedinstaller_process_token()?, + }; + let desired_access: TOKEN_ACCESS_MASK = TOKEN_QUERY + | TOKEN_DUPLICATE + | TOKEN_ASSIGN_PRIMARY + | TOKEN_ADJUST_DEFAULT + | TOKEN_ADJUST_SESSIONID + | TOKEN_ADJUST_PRIVILEGES; + let mut duplicate: HANDLE = null_mut(); + #[allow(unsafe_code)] + let duplicated = unsafe { + DuplicateTokenEx( + source.raw(), + desired_access, + null(), + SecurityImpersonation, + windows_sys::Win32::Security::TokenPrimary, + &raw mut duplicate, + ) + }; + if duplicated == FALSE { + return Err(last_error("DuplicateTokenEx")); + } + OwnedHandle::new(duplicate) + } + + fn apply_token_options( + token: &OwnedHandle, + privileges: PrivilegeMode, + integrity: Option, + ) -> Result<(), WindowsSupportError> { + match privileges { + PrivilegeMode::Default => {} + PrivilegeMode::EnableAll => enable_token_privileges(token.raw())?, + PrivilegeMode::DisableAll => disable_token_privileges(token.raw())?, + } + if let Some(integrity) = integrity { + set_token_integrity(token.raw(), integrity)?; + } + Ok(()) + } + + fn launch_with_token_as_user( + request: &LaunchRequest, + identity: LaunchIdentity, + token: &OwnedHandle, + ) -> Result { + let desktop = interactive_desktop(request); + let mut startup = build_startup_info(request.show_window, desktop.as_deref()); + let std_handles = inherited_standard_handles(request)?; + apply_standard_handles(&mut startup, std_handles.as_ref()); + let mut process_info = PROCESS_INFORMATION::default(); + let application = application_name_wide(&request.program); + let command_line_text = build_process_command_line(&request.program, &request.args); + let mut command_line = wide(&command_line_text); + let environment = OwnedEnvironmentBlock::for_token(token.raw(), false)?; + let current_directory = request.current_directory.as_deref().map(wide); + let creation_flags = creation_flags(request); + #[allow(unsafe_code)] + let created = unsafe { + CreateProcessAsUserW( + token.raw(), + application.as_ref().map_or(null(), Vec::as_ptr), + command_line.as_mut_ptr(), + null(), + null(), + inherit_handles_flag(std_handles.as_ref()), + creation_flags, + environment.raw().cast_mut(), + current_directory.as_ref().map_or(null(), Vec::as_ptr), + &raw mut startup, + &raw mut process_info, + ) + }; + if created == FALSE { + return Err(last_error("CreateProcessAsUserW")); + } + finalize_launch(request, identity, process_info) + } + + fn launch_with_active_user( + request: &LaunchRequest, + ) -> Result { + let status = current_token_status()?; + if !request_requires_token_shaping(request) { + return launch_with_current_identity(request); + } + if !status.is_elevated { + return Err(WindowsSupportError::Unsupported( + "current-user launch requires an elevated administrator context".to_string(), + )); + } + enable_token_discovery_privileges()?; + let _impersonation = ImpersonationGuard::as_system_with_privileges(&[ + "SeTcbPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeIncreaseQuotaPrivilege", + "SeImpersonatePrivilege", + ])?; + let token = duplicate_primary_token(LaunchIdentity::CurrentUser, request.session)?; + apply_token_options(&token, request.privileges, request.integrity)?; + launch_with_token_as_user(request, LaunchIdentity::CurrentUser, &token) + } + + const fn request_requires_token_shaping(request: &LaunchRequest) -> bool { + request.session.is_some() + || request.integrity.is_some() + || !matches!(request.privileges, PrivilegeMode::Default) + } + + fn finalize_launch( + request: &LaunchRequest, + identity: LaunchIdentity, + process_info: PROCESS_INFORMATION, + ) -> Result { + let thread = OwnedHandle::new(process_info.hThread)?; + drop(thread); + let process = OwnedHandle::new(process_info.hProcess)?; + let exit_code = if request.wait { + let _console_ctrl = ConsoleCtrlGuard::ignore(request.same_console)?; + Some(wait_for_exit_code(process.raw())?) + } else { + None + }; + Ok(LaunchResult { + pid: Some(process_info.dwProcessId), + exit_code, + identity, + }) + } + + fn open_system_process_token() -> Result { + open_preferred_system_process_token(TOKEN_QUERY | TOKEN_DUPLICATE) + } + + fn open_system_impersonation_token() -> Result { + let source = open_system_process_token()?; + duplicate_token( + source.raw(), + TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_IMPERSONATE | TOKEN_ADJUST_PRIVILEGES, + windows_sys::Win32::Security::TokenImpersonation, + ) + } + + fn open_active_user_process_token( + explicit_session: Option, + ) -> Result { + let session = explicit_session + .or_else(active_console_session_id) + .or_else(current_process_session_id) + .ok_or_else(|| { + WindowsSupportError::Process("no active user session found".to_string()) + })?; + let mut token: HANDLE = null_mut(); + #[allow(unsafe_code)] + let queried = unsafe { WTSQueryUserToken(session, &raw mut token) }; + if queried == FALSE { + return Err(last_error("WTSQueryUserToken")); + } + OwnedHandle::new(token) + } + + fn open_preferred_system_process_token( + access: TOKEN_ACCESS_MASK, + ) -> Result { + let expected = well_known_sid(WinLocalSystemSid)?; + let processes = crate::snapshot_processes(); + let current_session = current_process_session_id(); + + for process in processes + .iter() + .filter(|process| { + process.image_name.eq_ignore_ascii_case("winlogon.exe") + && current_session == process_session_id(process.pid) + }) + .chain( + processes + .iter() + .filter(|process| process.image_name.eq_ignore_ascii_case("lsass.exe")), + ) + .chain(processes.iter()) + { + let opened = open_process_token_for_pid(process.pid, access); + let Ok(token) = opened else { + continue; + }; + if token_user_matches_sid(token.raw(), &expected)? { + return Ok(token); + } + } + Err(WindowsSupportError::Process( + "failed to locate a usable SYSTEM process token".to_string(), + )) + } + + fn open_trustedinstaller_process_token() -> Result { + let started = Instant::now(); + let _impersonation = ImpersonationGuard::as_system()?; + loop { + let pid = ensure_trustedinstaller_running()?; + match open_process_token_for_pid(pid, TOKEN_QUERY | TOKEN_DUPLICATE) { + Ok(token) => return Ok(token), + Err(error) => { + if !should_retry_trustedinstaller_token_open(&error) + || started.elapsed() >= START_TIMEOUT + { + return Err(error); + } + thread::sleep(Duration::from_millis(100)); + } + } + } + } + + fn duplicate_token( + source: HANDLE, + desired_access: TOKEN_ACCESS_MASK, + token_type: windows_sys::Win32::Security::TOKEN_TYPE, + ) -> Result { + let mut duplicate: HANDLE = null_mut(); + #[allow(unsafe_code)] + let duplicated = unsafe { + DuplicateTokenEx( + source, + desired_access, + null(), + SecurityImpersonation, + token_type, + &raw mut duplicate, + ) + }; + if duplicated == FALSE { + return Err(last_error("DuplicateTokenEx")); + } + OwnedHandle::new(duplicate) + } + + fn open_process_token_for_pid( + pid: u32, + access: TOKEN_ACCESS_MASK, + ) -> Result { + #[allow(unsafe_code)] + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) }; + let process = OwnedHandle::new(process)?; + let mut token: HANDLE = null_mut(); + #[allow(unsafe_code)] + let opened = unsafe { OpenProcessToken(process.raw(), access, &raw mut token) }; + if opened == FALSE { + return Err(last_error("OpenProcessToken")); + } + OwnedHandle::new(token) + } + + fn process_session_id(pid: u32) -> Option { + let mut session = 0_u32; + #[allow(unsafe_code)] + let ok = unsafe { ProcessIdToSessionId(pid, &raw mut session) }; + (ok != FALSE).then_some(session) + } + + fn should_retry_trustedinstaller_token_open(error: &WindowsSupportError) -> bool { + matches!( + error, + WindowsSupportError::WindowsApi { + action: "OpenProcessToken" | "OpenProcess", + code: ERROR_ACCESS_DENIED, + } + ) + } + + fn trustedinstaller_presence() -> Result<(bool, bool), WindowsSupportError> { + let manager = unsafe { OpenSCManagerW(null(), null(), SC_MANAGER_CONNECT) }; + let manager = match OwnedServiceHandle::new(manager, "OpenSCManagerW") { + Ok(value) => value, + Err(error) => { + return match error { + WindowsSupportError::WindowsApi { .. } => Ok((false, false)), + other => Err(other), + }; + } + }; + let service_name = wide("TrustedInstaller"); + let service = + unsafe { OpenServiceW(manager.raw(), service_name.as_ptr(), SERVICE_QUERY_STATUS) }; + let service = match OwnedServiceHandle::new(service, "OpenServiceW") { + Ok(value) => value, + Err(error) => { + return match error { + WindowsSupportError::WindowsApi { .. } => Ok((false, false)), + other => Err(other), + }; + } + }; + let running = service_running(service.raw())?; + Ok((true, running)) + } + + fn ensure_trustedinstaller_running() -> Result { + let manager = OwnedServiceHandle::new( + unsafe { OpenSCManagerW(null(), null(), SC_MANAGER_CONNECT) }, + "OpenSCManagerW", + )?; + let service_name = wide("TrustedInstaller"); + let service = OwnedServiceHandle::new( + unsafe { + OpenServiceW( + manager.raw(), + service_name.as_ptr(), + SERVICE_QUERY_STATUS | SERVICE_START, + ) + }, + "OpenServiceW", + )?; + let mut deadline = Instant::now() + START_TIMEOUT; + let mut last_checkpoint = 0_u32; + let mut start_requested = false; + loop { + let status = query_service_status(service.raw())?; + if status.dwCurrentState == SERVICE_RUNNING { + return Ok(status.dwProcessId); + } + if status.dwCurrentState == SERVICE_START_PENDING { + if status.dwCheckPoint != 0 && status.dwCheckPoint != last_checkpoint { + last_checkpoint = status.dwCheckPoint; + deadline = Instant::now() + START_TIMEOUT; + } + } else if !start_requested { + #[allow(unsafe_code)] + let _ = unsafe { StartServiceW(service.raw(), 0, null()) }; + start_requested = true; + } + if Instant::now() >= deadline { + return Err(WindowsSupportError::Process( + "timed out waiting for TrustedInstaller service".to_string(), + )); + } + let wait_hint = if status.dwWaitHint == 0 { + 100 + } else { + status.dwWaitHint.saturating_div(10).clamp(100, 1_000) + }; + thread::sleep(Duration::from_millis(u64::from(wait_hint))); + } + } + + fn service_running(service: SC_HANDLE) -> Result { + let status = query_service_status(service)?; + Ok(status.dwCurrentState == SERVICE_RUNNING) + } + + fn query_service_status( + service: SC_HANDLE, + ) -> Result { + let mut status = SERVICE_STATUS_PROCESS::default(); + let mut needed = 0_u32; + #[allow(unsafe_code)] + let queried = unsafe { + QueryServiceStatusEx( + service, + SC_STATUS_PROCESS_INFO, + (&raw mut status).cast::(), + u32::try_from(size_of::()) + .expect("service status size always fits in u32"), + &raw mut needed, + ) + }; + if queried == FALSE { + return Err(last_error("QueryServiceStatusEx")); + } + Ok(status) + } + + fn enable_token_discovery_privileges() -> Result<(), WindowsSupportError> { + let token = open_current_token(TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY)?; + for privilege in ["SeDebugPrivilege", "SeImpersonatePrivilege"] { + enable_named_privilege(token.raw(), privilege)?; + } + Ok(()) + } + + fn enable_token_privileges(token: HANDLE) -> Result<(), WindowsSupportError> { + let bytes = get_token_info_bytes(token, windows_sys::Win32::Security::TokenPrivileges)?; + #[allow(unsafe_code)] + let privilege_count = unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }; + let privileges_offset = std::mem::offset_of!(TOKEN_PRIVILEGES, Privileges); + for index in 0..usize::try_from(privilege_count).expect("privilege count fits usize") { + #[allow(unsafe_code)] + let privilege = unsafe { + std::ptr::read_unaligned( + bytes + .as_ptr() + .add( + privileges_offset + + index + * size_of::( + ), + ) + .cast::(), + ) + }; + adjust_single_token_privilege(token, privilege.Luid, SE_PRIVILEGE_ENABLED)?; + } + Ok(()) + } + + fn disable_token_privileges(token: HANDLE) -> Result<(), WindowsSupportError> { + #[allow(unsafe_code)] + let adjusted = + unsafe { AdjustTokenPrivileges(token, 1, null_mut(), 0, null_mut(), null_mut()) }; + if adjusted == FALSE { + return Err(last_error("AdjustTokenPrivileges")); + } + Ok(()) + } + + fn enable_named_privilege(token: HANDLE, name: &str) -> Result<(), WindowsSupportError> { + let name_wide = wide(name); + let mut luid = windows_sys::Win32::Foundation::LUID::default(); + #[allow(unsafe_code)] + let looked_up = unsafe { LookupPrivilegeValueW(null(), name_wide.as_ptr(), &raw mut luid) }; + if looked_up == FALSE { + return Err(last_error("LookupPrivilegeValueW")); + } + adjust_single_token_privilege(token, luid, SE_PRIVILEGE_ENABLED) + } + + fn adjust_single_token_privilege( + token: HANDLE, + luid: windows_sys::Win32::Foundation::LUID, + attributes: u32, + ) -> Result<(), WindowsSupportError> { + let mut privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [windows_sys::Win32::Security::LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: attributes, + }], + }; + #[allow(unsafe_code)] + let adjusted = unsafe { + AdjustTokenPrivileges( + token, + FALSE, + &raw mut privileges, + u32::try_from(size_of::()) + .expect("token privileges size always fits in u32"), + null_mut(), + null_mut(), + ) + }; + if adjusted == FALSE { + return Err(last_error("AdjustTokenPrivileges")); + } + #[allow(unsafe_code)] + let status = unsafe { GetLastError() }; + if status == ERROR_NOT_ALL_ASSIGNED { + return Err(WindowsSupportError::Process( + "token did not contain every requested privilege".to_string(), + )); + } + Ok(()) + } + + fn open_current_token(access: TOKEN_ACCESS_MASK) -> Result { + let mut token: HANDLE = null_mut(); + #[allow(unsafe_code)] + let opened = unsafe { OpenProcessToken(GetCurrentProcess(), access, &raw mut token) }; + if opened == FALSE { + return Err(last_error("OpenProcessToken")); + } + OwnedHandle::new(token) + } + + fn token_elevation(token: HANDLE) -> Result { + let elevation: TOKEN_ELEVATION = get_token_info(token, TokenElevation)?; + Ok(elevation.TokenIsElevated != 0) + } + + fn token_integrity(token: HANDLE) -> Result { + let bytes = get_token_info_bytes(token, TokenIntegrityLevel)?; + let label: TOKEN_MANDATORY_LABEL = + unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }; + if label.Label.Attributes + & u32::try_from(SE_GROUP_INTEGRITY).expect("integrity flag fits u32") + == 0 + { + return Ok(TokenIntegrity::Unknown); + } + let sid = label.Label.Sid; + let authority = sub_authority_count(sid)?; + let rid = sub_authority(sid, authority.saturating_sub(1))?; + Ok(match rid { + SECURITY_MANDATORY_UNTRUSTED_RID => TokenIntegrity::Untrusted, + SECURITY_MANDATORY_LOW_RID => TokenIntegrity::Low, + SECURITY_MANDATORY_MEDIUM_RID => TokenIntegrity::Medium, + SECURITY_MANDATORY_MEDIUM_PLUS_RID => TokenIntegrity::MediumPlus, + SECURITY_MANDATORY_HIGH_RID => TokenIntegrity::High, + SECURITY_MANDATORY_SYSTEM_RID => TokenIntegrity::System, + _ => TokenIntegrity::Unknown, + }) + } + + fn set_token_integrity( + token: HANDLE, + integrity: TokenIntegrity, + ) -> Result<(), WindowsSupportError> { + let Some(rid) = integrity_rid(integrity) else { + return Ok(()); + }; + let mut sid = mandatory_label_sid(rid); + let mut label = TOKEN_MANDATORY_LABEL { + Label: windows_sys::Win32::Security::SID_AND_ATTRIBUTES { + Sid: sid.as_mut_ptr().cast::(), + Attributes: u32::try_from(SE_GROUP_INTEGRITY).expect("integrity flag fits u32"), + }, + }; + #[allow(unsafe_code)] + let updated = unsafe { + SetTokenInformation( + token, + TokenIntegrityLevel, + (&raw mut label).cast::(), + u32::try_from(size_of::() + sid.len()) + .expect("mandatory label buffer size fits in u32"), + ) + }; + if updated == FALSE { + return Err(last_error("SetTokenInformation")); + } + Ok(()) + } + + const fn integrity_rid(integrity: TokenIntegrity) -> Option { + match integrity { + TokenIntegrity::Unknown => None, + TokenIntegrity::Untrusted => Some(SECURITY_MANDATORY_UNTRUSTED_RID), + TokenIntegrity::Low => Some(SECURITY_MANDATORY_LOW_RID), + TokenIntegrity::Medium => Some(SECURITY_MANDATORY_MEDIUM_RID), + TokenIntegrity::MediumPlus => Some(SECURITY_MANDATORY_MEDIUM_PLUS_RID), + TokenIntegrity::High => Some(SECURITY_MANDATORY_HIGH_RID), + TokenIntegrity::System => Some(SECURITY_MANDATORY_SYSTEM_RID), + } + } + + fn mandatory_label_sid(rid: u32) -> [u8; 12] { + let mut sid = [0_u8; 12]; + sid[0] = 1; + sid[1] = 1; + sid[7] = 16; + sid[8..12].copy_from_slice(&rid.to_le_bytes()); + sid + } + + fn token_user_name(token: HANDLE) -> Result, WindowsSupportError> { + let bytes = get_token_info_bytes(token, windows_sys::Win32::Security::TokenUser)?; + let user: windows_sys::Win32::Security::TOKEN_USER = unsafe { + std::ptr::read_unaligned( + bytes + .as_ptr() + .cast::(), + ) + }; + let sid = user.User.Sid; + let mut name_len = 0_u32; + let mut domain_len = 0_u32; + let mut use_type = 0_i32; + #[allow(unsafe_code)] + let _ = unsafe { + LookupAccountSidW( + null(), + sid, + null_mut(), + &raw mut name_len, + null_mut(), + &raw mut domain_len, + &raw mut use_type, + ) + }; + if name_len == 0 { + return Ok(None); + } + let mut name = vec![0_u16; usize::try_from(name_len).expect("name length fits usize")]; + let mut domain = + vec![0_u16; usize::try_from(domain_len).expect("domain length fits usize")]; + #[allow(unsafe_code)] + let looked_up = unsafe { + LookupAccountSidW( + null(), + sid, + name.as_mut_ptr(), + &raw mut name_len, + domain.as_mut_ptr(), + &raw mut domain_len, + &raw mut use_type, + ) + }; + if looked_up == FALSE { + return Ok(None); + } + let name = + wide_to_string(&name[..usize::try_from(name_len).expect("name length fits usize")]); + let domain = wide_to_string( + &domain[..usize::try_from(domain_len).expect("domain length fits usize")], + ); + if domain.is_empty() { + Ok(Some(name)) + } else { + Ok(Some(format!("{domain}\\{name}"))) + } + } + + fn current_token_is_admin_member() -> Result { + let sid = well_known_sid(WinBuiltinAdministratorsSid)?; + let mut result = FALSE; + #[allow(unsafe_code)] + let checked = unsafe { + CheckTokenMembership( + null_mut(), + sid.as_ptr().cast_mut().cast::(), + &raw mut result, + ) + }; + if checked == FALSE { + return Err(last_error("CheckTokenMembership")); + } + Ok(result != FALSE) + } + + fn current_process_session_id() -> Option { + let mut session = 0_u32; + #[allow(unsafe_code)] + let ok = unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &raw mut session) }; + (ok != FALSE).then_some(session) + } + + fn active_console_session_id() -> Option { + #[allow(unsafe_code)] + let session = unsafe { WTSGetActiveConsoleSessionId() }; + (session != u32::MAX).then_some(session) + } + + fn resolve_target_session_id(explicit_session: Option) -> Option { + explicit_session + .or_else(|| current_process_session_id().filter(|session| *session != 0)) + .or_else(active_console_session_id) + .or_else(current_process_session_id) + } + + fn token_user_matches_sid(token: HANDLE, expected: &[u8]) -> Result { + let bytes = get_token_info_bytes(token, windows_sys::Win32::Security::TokenUser)?; + let user: windows_sys::Win32::Security::TOKEN_USER = unsafe { + std::ptr::read_unaligned( + bytes + .as_ptr() + .cast::(), + ) + }; + sid_equal(user.User.Sid, expected.as_ptr().cast::()) + } + + fn sid_equal(left: *mut c_void, right: *const c_void) -> Result { + #[allow(unsafe_code)] + let left_len = unsafe { windows_sys::Win32::Security::GetLengthSid(left) }; + #[allow(unsafe_code)] + let right_len = unsafe { windows_sys::Win32::Security::GetLengthSid(right.cast_mut()) }; + if left_len != right_len { + return Ok(false); + } + let left = sid_bytes(left, left_len)?; + let right = sid_bytes(right.cast_mut(), right_len)?; + Ok(left == right) + } + + fn sid_bytes(sid: *mut c_void, len: u32) -> Result, WindowsSupportError> { + let len = usize::try_from(len).expect("sid length fits usize"); + if sid.is_null() { + return Err(WindowsSupportError::Process("null SID pointer".to_string())); + } + #[allow(unsafe_code)] + let slice = unsafe { std::slice::from_raw_parts(sid.cast::(), len) }; + Ok(slice.to_vec()) + } + + fn well_known_sid(kind: i32) -> Result, WindowsSupportError> { + let mut bytes = vec![0_u8; SECURITY_MAX_SID_SIZE]; + let mut size = u32::try_from(bytes.len()).expect("sid buffer size fits u32"); + #[allow(unsafe_code)] + let created = unsafe { + CreateWellKnownSid( + kind, + null_mut(), + bytes.as_mut_ptr().cast::(), + &raw mut size, + ) + }; + if created == FALSE { + return Err(last_error("CreateWellKnownSid")); + } + bytes.truncate(usize::try_from(size).expect("sid size fits usize")); + Ok(bytes) + } + + fn sub_authority_count(sid: *mut c_void) -> Result { + if sid.is_null() { + return Err(WindowsSupportError::Process("null SID pointer".to_string())); + } + #[allow(unsafe_code)] + let count = unsafe { *(sid.cast::().add(1)) }; + Ok(u32::from(count)) + } + + fn sub_authority(sid: *mut c_void, index: u32) -> Result { + if sid.is_null() { + return Err(WindowsSupportError::Process("null SID pointer".to_string())); + } + let offset = 8_usize + usize::try_from(index).expect("sid index fits usize") * 4; + #[allow(unsafe_code)] + let value = unsafe { std::ptr::read_unaligned(sid.cast::().add(offset).cast::()) }; + Ok(value) + } + + fn set_token_session_id(token: HANDLE, session: u32) -> Result<(), WindowsSupportError> { + let mut session = session; + #[allow(unsafe_code)] + let updated = unsafe { + SetTokenInformation( + token, + TokenSessionId, + (&raw mut session).cast::(), + u32::try_from(size_of::()).expect("u32 size always fits in u32"), + ) + }; + if updated == FALSE { + return Err(last_error("SetTokenInformation")); + } + Ok(()) + } + + fn interactive_desktop(request: &LaunchRequest) -> Option> { + if request.new_window || request.same_console { + Some(wide("winsta0\\default")) + } else { + None + } + } + + fn build_startup_info(show_window: ShowWindowMode, desktop: Option<&[u16]>) -> STARTUPINFOW { + STARTUPINFOW { + cb: u32::try_from(size_of::()) + .expect("startup info size always fits in u32"), + dwFlags: STARTF_USESHOWWINDOW, + wShowWindow: show_window_value(show_window), + lpDesktop: desktop.map_or(null_mut(), |value| value.as_ptr().cast_mut()), + ..STARTUPINFOW::default() + } + } + + fn inherited_standard_handles( + request: &LaunchRequest, + ) -> Result, WindowsSupportError> { + if request.new_window { + return Ok(None); + } + let (output, stdout_file) = if let Some(path) = &request.stdout_path { + let file = create_relay_output_file(path)?; + ( + duplicate_inheritable_handle(file.as_raw_handle().cast::(), "stdout file")?, + Some(file), + ) + } else { + (duplicate_standard_handle(STD_OUTPUT_HANDLE)?, None) + }; + let (error, stderr_file) = if let Some(path) = &request.stderr_path { + let file = create_relay_output_file(path)?; + ( + duplicate_inheritable_handle(file.as_raw_handle().cast::(), "stderr file")?, + Some(file), + ) + } else { + (duplicate_standard_handle(STD_ERROR_HANDLE)?, None) + }; + Ok(Some(InheritedStdHandles { + input: duplicate_standard_handle(STD_INPUT_HANDLE)?, + output, + error, + _stdout_file: stdout_file, + _stderr_file: stderr_file, + })) + } + + fn duplicate_standard_handle( + handle_id: STD_HANDLE, + ) -> Result { + #[allow(unsafe_code)] + let source = unsafe { GetStdHandle(handle_id) }; + if source.is_null() || std::ptr::eq(source, INVALID_HANDLE_VALUE) { + return Err(last_error("GetStdHandle")); + } + duplicate_inheritable_handle(source, "standard handle") + } + + fn create_relay_output_file(path: &str) -> Result { + OpenOptions::new() + .create_new(true) + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| { + WindowsSupportError::Process(format!( + "failed to create relay output file {path}: {error}" + )) + }) + } + + fn duplicate_inheritable_handle( + source: HANDLE, + action: &'static str, + ) -> Result { + #[allow(unsafe_code)] + let current_process = unsafe { GetCurrentProcess() }; + let mut duplicate: HANDLE = null_mut(); + #[allow(unsafe_code)] + let duplicated = unsafe { + DuplicateHandle( + current_process, + source, + current_process, + &raw mut duplicate, + 0, + TRUE, + DUPLICATE_SAME_ACCESS, + ) + }; + if duplicated == FALSE { + return Err(last_error(action)); + } + OwnedHandle::new(duplicate) + } + + const fn apply_standard_handles( + startup: &mut STARTUPINFOW, + handles: Option<&InheritedStdHandles>, + ) { + if let Some(handles) = handles { + startup.dwFlags |= STARTF_USESTDHANDLES; + startup.hStdInput = handles.input.raw(); + startup.hStdOutput = handles.output.raw(); + startup.hStdError = handles.error.raw(); + } + } + + const fn inherit_handles_flag(handles: Option<&InheritedStdHandles>) -> i32 { + if handles.is_some() { TRUE } else { FALSE } + } + + const fn creation_flags(request: &LaunchRequest) -> u32 { + let mut flags = priority_flag(request.priority) | CREATE_UNICODE_ENVIRONMENT; + if request.new_window { + flags |= CREATE_NEW_CONSOLE; + } else if matches!(request.show_window, ShowWindowMode::Hidden) { + flags |= CREATE_NO_WINDOW; + } + flags + } + + const fn priority_flag(priority: ProcessPriority) -> u32 { + match priority { + ProcessPriority::Idle => IDLE_PRIORITY_CLASS, + ProcessPriority::BelowNormal => BELOW_NORMAL_PRIORITY_CLASS, + ProcessPriority::Normal => NORMAL_PRIORITY_CLASS, + ProcessPriority::AboveNormal => ABOVE_NORMAL_PRIORITY_CLASS, + ProcessPriority::High => HIGH_PRIORITY_CLASS, + ProcessPriority::Realtime => REALTIME_PRIORITY_CLASS, + } + } + + const fn show_window_value(mode: ShowWindowMode) -> u16 { + match mode { + ShowWindowMode::Default => SW_SHOWDEFAULT_VALUE, + ShowWindowMode::Hidden => SW_HIDE_VALUE, + ShowWindowMode::Normal => SW_NORMAL_VALUE, + ShowWindowMode::Minimized => SW_SHOWMINIMIZED_VALUE, + ShowWindowMode::Maximized => SW_SHOWMAXIMIZED_VALUE, + } + } + + fn build_process_command_line(program: &str, args: &[String]) -> String { + let mut values = Vec::with_capacity(args.len() + 1); + values.push(program); + values.extend(args.iter().map(String::as_str)); + quote_command_line(values) + } + + fn application_name_wide(program: &str) -> Option> { + if should_use_path_search(program) { + None + } else { + Some(wide(program)) + } + } + + fn should_use_path_search(program: &str) -> bool { + !program.contains(['\\', '/', ':']) + } + + fn quote_command_line<'a, I>(values: I) -> String + where + I: IntoIterator, + { + let mut rendered = String::new(); + for value in values { + if !rendered.is_empty() { + rendered.push(' '); + } + rendered.push_str("e_windows_arg(value)); + } + rendered + } + + fn quote_windows_arg(value: &str) -> String { + if value.is_empty() { + return "\"\"".to_string(); + } + if !value.contains([' ', '\t', '"']) && !value.ends_with('\\') { + return value.to_string(); + } + + let mut rendered = String::with_capacity(value.len() + 2); + rendered.push('"'); + let mut backslashes = 0; + for character in value.chars() { + if character == '\\' { + backslashes += 1; + continue; + } + if character == '"' { + rendered.push_str(&"\\".repeat(backslashes * 2 + 1)); + rendered.push('"'); + backslashes = 0; + continue; + } + if backslashes > 0 { + rendered.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + } + rendered.push(character); + } + if backslashes > 0 { + rendered.push_str(&"\\".repeat(backslashes * 2)); + } + rendered.push('"'); + rendered + } + + fn build_environment_block() -> Vec { + let mut pairs = std::env::vars_os() + .map(|(name, value)| { + let mut rendered = String::new(); + rendered.push_str(&name.to_string_lossy()); + rendered.push('='); + rendered.push_str(&value.to_string_lossy()); + rendered + }) + .collect::>(); + pairs.sort_unstable(); + let mut buffer = Vec::new(); + for pair in pairs { + buffer.extend(OsStr::new(&pair).encode_wide()); + buffer.push(0); + } + buffer.push(0); + buffer + } + + fn process_id_from_handle(process: HANDLE) -> Result { + #[allow(unsafe_code)] + let pid = unsafe { windows_sys::Win32::System::Threading::GetProcessId(process) }; + if pid == 0 { + Err(last_error("GetProcessId")) + } else { + Ok(pid) + } + } + + fn wait_for_exit_code(process: HANDLE) -> Result { + #[allow(unsafe_code)] + let waited = unsafe { WaitForSingleObject(process, u32::MAX) }; + if waited != WAIT_OBJECT_0 { + return if waited == WAIT_FAILED { + Err(last_error("WaitForSingleObject")) + } else { + Err(WindowsSupportError::Process(format!( + "unexpected wait result: {waited}" + ))) + }; + } + let mut code = 0_u32; + #[allow(unsafe_code)] + let ok = unsafe { GetExitCodeProcess(process, &raw mut code) }; + if ok == FALSE { + return Err(last_error("GetExitCodeProcess")); + } + Ok(i32::try_from(code).unwrap_or(i32::MAX)) + } + + fn get_token_info( + token: HANDLE, + class: windows_sys::Win32::Security::TOKEN_INFORMATION_CLASS, + ) -> Result { + let bytes = get_token_info_bytes(token, class)?; + #[allow(unsafe_code)] + let value = unsafe { *(bytes.as_ptr().cast::()) }; + Ok(value) + } + + fn get_token_info_bytes( + token: HANDLE, + class: windows_sys::Win32::Security::TOKEN_INFORMATION_CLASS, + ) -> Result, WindowsSupportError> { + let mut needed = 0_u32; + #[allow(unsafe_code)] + let _ = unsafe { GetTokenInformation(token, class, null_mut(), 0, &raw mut needed) }; + if needed == 0 { + return Err(last_error("GetTokenInformation")); + } + let mut bytes = vec![0_u8; usize::try_from(needed).expect("token info length fits usize")]; + #[allow(unsafe_code)] + let ok = unsafe { + GetTokenInformation( + token, + class, + bytes.as_mut_ptr().cast::(), + needed, + &raw mut needed, + ) + }; + if ok == FALSE { + return Err(last_error("GetTokenInformation")); + } + Ok(bytes) + } + + fn wide(value: &str) -> Vec { + let mut wide = OsStr::new(value).encode_wide().collect::>(); + wide.push(0); + wide + } + + fn wide_from_os(value: &OsStr) -> Vec { + let mut wide = value.encode_wide().collect::>(); + wide.push(0); + wide + } + + fn wide_to_string(value: &[u16]) -> String { + let terminator = value + .iter() + .position(|item| *item == 0) + .unwrap_or(value.len()); + String::from_utf16_lossy(&value[..terminator]) + } + + fn last_error(action: &'static str) -> WindowsSupportError { + #[allow(unsafe_code)] + let code = unsafe { GetLastError() }; + WindowsSupportError::WindowsApi { action, code } + } + + #[cfg(test)] + mod tests { + use super::{ + CREATE_NEW_CONSOLE, CREATE_NO_WINDOW, ERROR_ACCESS_DENIED, ProcessPriority, + ShowWindowMode, TokenIntegrity, admin_launch_requires_duplicate_token, + application_name_wide, build_environment_block, build_process_command_line, + build_startup_info, create_relay_output_file, creation_flags, integrity_rid, + interactive_desktop, launch_request, mandatory_label_sid, priority_flag, + quote_command_line, quote_windows_arg, resolve_target_session_id, + should_retry_trustedinstaller_token_open, should_use_path_search, show_window_value, + wide, wide_from_os, wide_to_string, + }; + use std::ffi::OsStr; + use std::fs; + + use crate::WindowsSupportError; + use crate::sudo::{LaunchIdentity, LaunchRequest, PrivilegeMode}; + + #[test] + fn windows_arg_quoting_handles_spaces_quotes_and_trailing_backslashes() { + assert_eq!(quote_windows_arg("plain"), "plain"); + assert_eq!(quote_windows_arg("two words"), "\"two words\""); + assert_eq!(quote_windows_arg("a\"b"), "\"a\\\"b\""); + assert_eq!(quote_windows_arg("c:\\temp\\"), "\"c:\\temp\\\\\""); + } + + #[test] + fn command_line_builder_includes_program_first() { + let command = build_process_command_line("tool.exe", &["two words".to_string()]); + assert_eq!(command, "tool.exe \"two words\""); + assert_eq!( + quote_command_line(["", "tab\tvalue", "simple"]), + "\"\" \"tab\tvalue\" simple" + ); + } + + #[test] + fn bare_commands_use_windows_path_search() { + assert!(should_use_path_search("cmd")); + assert!(should_use_path_search("pwsh.exe")); + assert!(application_name_wide("cmd").is_none()); + assert!(application_name_wide("pwsh.exe").is_none()); + assert!(application_name_wide("C:\\Windows\\System32\\cmd.exe").is_some()); + assert!(application_name_wide(".\\tool.exe").is_some()); + } + + #[test] + fn enum_mappings_cover_priority_and_show_window() { + assert!(priority_flag(ProcessPriority::Idle) > 0); + assert!(priority_flag(ProcessPriority::BelowNormal) > 0); + assert!(priority_flag(ProcessPriority::Normal) > 0); + assert!(priority_flag(ProcessPriority::AboveNormal) > 0); + assert!(priority_flag(ProcessPriority::High) > 0); + assert!(priority_flag(ProcessPriority::Realtime) > 0); + assert_eq!(show_window_value(ShowWindowMode::Default), 10); + assert_eq!(show_window_value(ShowWindowMode::Hidden), 0); + assert_eq!(show_window_value(ShowWindowMode::Normal), 1); + assert_eq!(show_window_value(ShowWindowMode::Minimized), 2); + assert_eq!(show_window_value(ShowWindowMode::Maximized), 3); + } + + #[test] + fn token_integrity_debug_shape_is_stable() { + assert_eq!(format!("{:?}", TokenIntegrity::MediumPlus), "MediumPlus"); + } + + #[test] + fn trustedinstaller_retry_only_handles_access_denied_token_open() { + let retryable = WindowsSupportError::WindowsApi { + action: "OpenProcessToken", + code: ERROR_ACCESS_DENIED, + }; + let retryable_process = WindowsSupportError::WindowsApi { + action: "OpenProcess", + code: ERROR_ACCESS_DENIED, + }; + assert!(should_retry_trustedinstaller_token_open(&retryable)); + assert!(should_retry_trustedinstaller_token_open(&retryable_process)); + } + + #[test] + fn interactive_launches_use_winsta0_default_desktop() { + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: Vec::new(), + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::TrustedInstaller, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Default, + session: None, + same_console: false, + new_window: true, + wait: false, + }; + let desktop = interactive_desktop(&request).expect("interactive desktop"); + let startup = build_startup_info(request.show_window, Some(&desktop)); + assert_eq!(wide_to_string(&desktop), "winsta0\\default"); + assert!(!startup.lpDesktop.is_null()); + } + + #[test] + fn same_console_launches_use_winsta0_default_desktop() { + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: Vec::new(), + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::System, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Default, + session: None, + same_console: true, + new_window: false, + wait: true, + }; + let desktop = interactive_desktop(&request).expect("interactive desktop"); + assert_eq!(wide_to_string(&desktop), "winsta0\\default"); + } + + #[test] + fn hidden_new_window_does_not_request_no_window_flag() { + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: Vec::new(), + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::Admin, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Hidden, + session: None, + same_console: false, + new_window: true, + wait: false, + }; + let flags = creation_flags(&request); + assert_ne!(flags & CREATE_NEW_CONSOLE, 0); + assert_eq!(flags & CREATE_NO_WINDOW, 0); + } + + #[test] + fn explicit_session_wins_session_resolution() { + assert_eq!(resolve_target_session_id(Some(42)), Some(42)); + } + + #[test] + fn creation_flags_cover_window_and_priority_combinations() { + let mut request = LaunchRequest { + program: "cmd.exe".to_string(), + args: Vec::new(), + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::CurrentProcess, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::High, + show_window: ShowWindowMode::Hidden, + session: None, + same_console: false, + new_window: false, + wait: false, + }; + let hidden_same_window = creation_flags(&request); + assert_ne!(hidden_same_window & CREATE_NO_WINDOW, 0); + assert_eq!(hidden_same_window & CREATE_NEW_CONSOLE, 0); + + request.new_window = true; + let hidden_new_window = creation_flags(&request); + assert_ne!(hidden_new_window & CREATE_NEW_CONSOLE, 0); + assert_eq!(hidden_new_window & CREATE_NO_WINDOW, 0); + } + + #[test] + fn wide_and_environment_helpers_emit_null_terminated_buffers() { + let wide = wide("Mercury"); + assert_eq!(wide_to_string(&wide), "Mercury"); + assert_eq!(wide.last(), Some(&0)); + + let from_os = wide_from_os(OsStr::new("Toolbox")); + assert_eq!(wide_to_string(&from_os), "Toolbox"); + assert_eq!(from_os.last(), Some(&0)); + + let environment = build_environment_block(); + assert!(environment.ends_with(&[0, 0])); + } + + #[test] + fn relay_output_file_refuses_existing_replacements() { + let path = std::env::temp_dir().join(format!( + "windowsupport-relay-output-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + fs::write(&path, "attacker").expect("replacement"); + + let error = create_relay_output_file(&path.display().to_string()) + .expect_err("existing replacement should be refused"); + + assert!( + error + .to_string() + .contains("failed to create relay output file") + ); + assert_eq!( + fs::read_to_string(&path).expect("replacement remains"), + "attacker" + ); + let _ = fs::remove_file(path); + } + + #[cfg(windows)] + #[test] + fn current_process_launch_waits_for_child_exit_code() { + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: vec![ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + "exit /b 7".to_string(), + ], + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::CurrentProcess, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Hidden, + session: None, + same_console: false, + new_window: true, + wait: true, + }; + + let result = launch_request(&request).expect("launch current process"); + + assert_eq!(result.identity, LaunchIdentity::CurrentProcess); + assert_eq!(result.exit_code, Some(7)); + assert!(result.pid.is_some()); + } + + #[cfg(windows)] + #[test] + fn current_process_launch_can_relay_stdout_to_exclusive_file() { + let path = std::env::temp_dir().join(format!( + "windowsupport-relay-capture-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("epoch") + .as_nanos() + )); + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: vec![ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + "echo MercuryStdout".to_string(), + ], + current_directory: None, + stdout_path: Some(path.display().to_string()), + stderr_path: None, + identity: LaunchIdentity::CurrentProcess, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Hidden, + session: None, + same_console: false, + new_window: false, + wait: true, + }; + + let result = launch_request(&request).expect("launch with stdout relay"); + + assert_eq!(result.exit_code, Some(0)); + assert!( + fs::read_to_string(&path) + .expect("relay output") + .contains("MercuryStdout") + ); + let _ = fs::remove_file(path); + } + + #[cfg(windows)] + #[test] + fn current_user_launch_without_token_shaping_uses_current_identity_path() { + let status = super::current_token_status().expect("token status"); + assert!( + status + .current_user + .as_deref() + .is_some_and(|user| !user.is_empty()) + ); + + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: vec![ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + "exit /b 0".to_string(), + ], + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::CurrentUser, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Hidden, + session: None, + same_console: false, + new_window: true, + wait: true, + }; + + let result = launch_request(&request).expect("launch current user"); + + assert_eq!(result.identity, LaunchIdentity::CurrentUser); + assert_eq!(result.exit_code, Some(0)); + } + + #[test] + fn admin_launch_requires_duplicate_token_for_token_mutations() { + let request = LaunchRequest { + program: "cmd.exe".to_string(), + args: Vec::new(), + current_directory: None, + stdout_path: None, + stderr_path: None, + identity: LaunchIdentity::Admin, + privileges: PrivilegeMode::Default, + integrity: None, + priority: ProcessPriority::Normal, + show_window: ShowWindowMode::Default, + session: None, + same_console: false, + new_window: false, + wait: false, + }; + assert!(!admin_launch_requires_duplicate_token(&request)); + + let mut integrity = request.clone(); + integrity.integrity = Some(TokenIntegrity::High); + assert!(admin_launch_requires_duplicate_token(&integrity)); + + let mut privileges = request.clone(); + privileges.privileges = PrivilegeMode::DisableAll; + assert!(admin_launch_requires_duplicate_token(&privileges)); + + let mut session = request; + session.session = Some(1); + assert!(admin_launch_requires_duplicate_token(&session)); + } + + #[test] + fn mandatory_label_sid_matches_integrity_authority_shape() { + let rid = integrity_rid(TokenIntegrity::MediumPlus).expect("rid"); + let sid = mandatory_label_sid(rid); + assert_eq!(sid[0], 1); + assert_eq!(sid[1], 1); + assert_eq!(sid[2..8], [0, 0, 0, 0, 0, 16]); + assert_eq!(u32::from_le_bytes([sid[8], sid[9], sid[10], sid[11]]), rid); + } + } +} + +#[cfg(windows)] +pub use imp::{ + attach_parent_console, current_token_status, elevate_current_process, launch_request, +}; diff --git a/crates/zon/Cargo.toml b/crates/zon/Cargo.toml new file mode 100644 index 0000000..8c6a4fc --- /dev/null +++ b/crates/zon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "zon" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +readme.workspace = true +publish.workspace = true +description = "Convert JSON and JSONL to Zero Overhead Notation and back." +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true + +[dependencies] +common = { path = "../common", default-features = false } +lexopt.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/zon/src/lib.rs b/crates/zon/src/lib.rs new file mode 100644 index 0000000..50d08c5 --- /dev/null +++ b/crates/zon/src/lib.rs @@ -0,0 +1,1165 @@ +//! The `zon` command converts JSON and Zero Overhead Notation. + +use std::ffi::OsString; +use std::fmt; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; + +use common::{RenderMode, print_structured}; +use lexopt::prelude::{Long, Short, Value as ArgValue}; +use serde::Serialize; +use serde_json::{Map, Value}; + +const MAX_ZON_TABLE_ROWS: usize = 1_000_000; + +const HELP: &str = "\ +Convert JSON and JSONL to Zero Overhead Notation (ZON) and back. + +This tool targets Zero Overhead Notation, not Zig Object Notation. + +Usage: + zon [OPTIONS] [PATH] + +Options: + --format Structured output format: text, json, toon + --json Shortcut for --format json + --toon Shortcut for --format toon + --input-format Override stdin parsing mode: auto, lines, jsonl + --from Force source syntax: auto, json, zon + --to Force target syntax: auto, json, zon + --color Accepted for toolbox CLI symmetry: auto, never + --quiet Suppress non-essential status output + -h, --help Show this help text + -V, --version Show the command version + +Examples: + zon .\\fixtures\\json-family\\zon\\person.json + zon --from zon --to json .\\fixtures\\json-family\\zon\\person.zon + bat --style=plain --paging=never .\\fixtures\\json-family\\zon\\records.jsonl | zon --input-format jsonl + '.\\fixtures\\json-family\\zon\\person.json' | zon --input-format lines + +V1 subset: + Scalars, flat key:value objects, root/keyed @(N):field tables, JSONL streams, + and inline JSON for nested arrays/objects. Indented nested ZON blocks produce + explicit diagnostics instead of partial parses. +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceFormat { + Auto, + Json, + Zon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetFormat { + Auto, + Json, + Zon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InputFormat { + Auto, + Lines, + Jsonl, +} + +#[derive(Debug, Clone)] +struct Cli { + render_mode: RenderMode, + input_format: InputFormat, + from: SourceFormat, + to: TargetFormat, + path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParseOutcome { + Help, + Version, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Format { + Json, + Zon, +} + +#[derive(Debug, Clone)] +struct LoadedInput { + content: String, + source_path: Option, +} + +#[derive(Debug, Clone)] +struct ParsedInput { + value: Value, + documents: usize, + source_format: Format, +} + +#[derive(Debug, Clone, Serialize)] +struct ZonJsonPayload { + format: &'static str, + documents: usize, + text: String, +} + +#[derive(Debug, Clone)] +struct TableHeader { + key: Option, + expected_rows: usize, + fields: Vec, +} + +#[derive(Debug, Clone)] +enum CliError { + Usage(String), + Runtime(String), +} + +/// Parses CLI arguments and returns a process exit code. +#[must_use] +pub fn main_entry() -> i32 { + match parse_cli_from(std::env::args_os()) { + Ok((ParseOutcome::Help, _)) => { + print!("{HELP}"); + 0 + } + Ok((ParseOutcome::Version, _)) => { + println!("zon {}", env!("CARGO_PKG_VERSION")); + 0 + } + Ok((ParseOutcome::Run, cli)) => match run(&cli) { + Ok(()) => 0, + Err(error) => { + let exit_code = error.exit_code(); + print_error(&error); + exit_code + } + }, + Err(error) => { + print_error(&error); + eprintln!(); + eprintln!("{HELP}"); + 2 + } + } +} + +fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> +where + I: IntoIterator, + T: Into, +{ + let mut parser = lexopt::Parser::from_iter(args); + let mut cli = Cli { + render_mode: default_render_mode(), + input_format: InputFormat::Auto, + from: SourceFormat::Auto, + to: TargetFormat::Auto, + path: None, + }; + + while let Some(argument) = parser.next().map_err(|error| usage(error.to_string()))? { + match argument { + Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), + Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), + Long("json") => cli.render_mode = RenderMode::Json, + Long("toon") => cli.render_mode = RenderMode::Toon, + Long("format") => { + cli.render_mode = + parse_output_format(&parser_value_string(&mut parser, "--format")?)?; + } + Long("input-format") => { + cli.input_format = + parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; + } + Long("from") => { + cli.from = parse_source_format(&parser_value_string(&mut parser, "--from")?)?; + } + Long("to") => { + cli.to = parse_target_format(&parser_value_string(&mut parser, "--to")?)?; + } + Long("color") => { + let value = parser_value_string(&mut parser, "--color")?; + if !matches!(value.as_str(), "auto" | "never") { + return Err(usage(format!( + "invalid --color value '{value}'; expected auto or never" + ))); + } + } + Long("quiet") => {} + ArgValue(path) => { + if cli.path.replace(PathBuf::from(path)).is_some() { + return Err(usage("zon accepts at most one explicit path")); + } + } + _ => { + return Err(usage( + "unsupported argument; use --help to see available options", + )); + } + } + } + + Ok((ParseOutcome::Run, cli)) +} + +fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { + let value = parser.value().map_err(|error| usage(error.to_string()))?; + value.into_string().map_err(|invalid| { + usage(format!( + "{flag} expects UTF-8 text, got '{}'", + invalid.to_string_lossy() + )) + }) +} + +fn parse_input_format(value: &str) -> Result { + match value { + "auto" => Ok(InputFormat::Auto), + "lines" => Ok(InputFormat::Lines), + "jsonl" => Ok(InputFormat::Jsonl), + other => Err(usage(format!( + "invalid --input-format value '{other}'; expected auto, lines, or jsonl" + ))), + } +} + +fn parse_source_format(value: &str) -> Result { + match value { + "auto" => Ok(SourceFormat::Auto), + "json" => Ok(SourceFormat::Json), + "zon" => Ok(SourceFormat::Zon), + other => Err(usage(format!( + "invalid --from value '{other}'; expected auto, json, or zon" + ))), + } +} + +fn parse_target_format(value: &str) -> Result { + match value { + "auto" => Ok(TargetFormat::Auto), + "json" => Ok(TargetFormat::Json), + "zon" => Ok(TargetFormat::Zon), + other => Err(usage(format!( + "invalid --to value '{other}'; expected auto, json, or zon" + ))), + } +} + +fn run(cli: &Cli) -> Result<(), CliError> { + let inputs = load_inputs(cli)?; + let mut parsed = Vec::with_capacity(inputs.len()); + for input in &inputs { + parsed.push(parse_input(input, cli)?); + } + + let source_format = parsed + .first() + .map(|input| input.source_format) + .ok_or_else(|| runtime("input is empty"))?; + let target_format = resolve_target_format(cli.to, source_format); + let documents = parsed.iter().map(|input| input.documents).sum::(); + let value = merge_values(parsed.into_iter().map(|input| input.value).collect()); + let text = render_value(&value, target_format)?; + + match cli.render_mode { + RenderMode::Text => write_with_trailing_newline(&text), + RenderMode::Json => { + let payload = ZonJsonPayload { + format: target_format.as_str(), + documents, + text, + }; + let rendered = serde_json::to_string(&payload) + .map_err(|error| runtime(format!("failed to render JSON wrapper: {error}")))?; + write_with_trailing_newline(&rendered) + } + RenderMode::Toon => { + let payload = ZonJsonPayload { + format: target_format.as_str(), + documents, + text, + }; + print_structured(&payload, RenderMode::Toon) + .map_err(|error| runtime(format!("failed to render TOON wrapper: {error}"))) + } + } +} + +fn default_render_mode() -> RenderMode { + match std::env::var("MERCURY_OUTPUT") { + Ok(value) if value.eq_ignore_ascii_case("json") => RenderMode::Json, + Ok(value) if value.eq_ignore_ascii_case("toon") => RenderMode::Toon, + _ => RenderMode::Text, + } +} + +fn parse_output_format(value: &str) -> Result { + match value { + "text" => Ok(RenderMode::Text), + "json" => Ok(RenderMode::Json), + "toon" => Ok(RenderMode::Toon), + other => Err(usage(format!( + "invalid --format value '{other}'; expected text, json, or toon" + ))), + } +} + +fn load_inputs(cli: &Cli) -> Result, CliError> { + if let Some(path) = &cli.path { + return Ok(vec![read_path(path)?]); + } + + if io::stdin().is_terminal() { + return Err(usage("provide a JSON/ZON path or pipe data into stdin")); + } + + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .map_err(|error| runtime(format!("failed to read stdin: {error}")))?; + + if buffer.trim().is_empty() { + return Err(usage("input is empty")); + } + + if cli.input_format == InputFormat::Lines { + let mut loaded = Vec::new(); + for (index, line) in buffer.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let path = PathBuf::from(trimmed); + if !path.exists() { + return Err(usage(format!( + "stdin path line {} does not exist: {trimmed}", + index + 1 + ))); + } + loaded.push(read_path(&path)?); + } + if loaded.is_empty() { + return Err(usage("input path stream is empty")); + } + return Ok(loaded); + } + + Ok(vec![LoadedInput { + content: buffer, + source_path: None, + }]) +} + +fn read_path(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| runtime(format!("failed to read {}: {error}", path.display())))?; + if content.trim().is_empty() { + return Err(runtime(format!("input is empty: {}", path.display()))); + } + Ok(LoadedInput { + content, + source_path: Some(path.to_path_buf()), + }) +} + +fn parse_input(input: &LoadedInput, cli: &Cli) -> Result { + match resolve_source_format(cli.from, input.source_path.as_deref(), &input.content) { + Format::Json => parse_json_input(input, cli.input_format), + Format::Zon => parse_zon_input(&input.content), + } +} + +fn resolve_source_format( + requested: SourceFormat, + source_path: Option<&Path>, + content: &str, +) -> Format { + match requested { + SourceFormat::Json => Format::Json, + SourceFormat::Zon => Format::Zon, + SourceFormat::Auto => { + if source_path.is_some_and(has_zon_extension) { + return Format::Zon; + } + if source_path.is_some_and(has_json_family_extension) { + return Format::Json; + } + if serde_json::from_str::(content.trim()).is_ok() || looks_like_jsonl(content) { + Format::Json + } else { + Format::Zon + } + } + } +} + +const fn resolve_target_format(requested: TargetFormat, source_format: Format) -> Format { + match requested { + TargetFormat::Json => Format::Json, + TargetFormat::Zon => Format::Zon, + TargetFormat::Auto => match source_format { + Format::Json => Format::Zon, + Format::Zon => Format::Json, + }, + } +} + +fn parse_json_input( + input: &LoadedInput, + input_format: InputFormat, +) -> Result { + let parse_jsonl = input_format == InputFormat::Jsonl + || input + .source_path + .as_deref() + .is_some_and(has_jsonl_extension); + + if parse_jsonl { + let documents = parse_jsonl_documents(&input.content)?; + let count = documents.len(); + return Ok(ParsedInput { + value: Value::Array(documents), + documents: count, + source_format: Format::Json, + }); + } + + let trimmed = input.content.trim(); + match serde_json::from_str::(trimmed) { + Ok(value) => Ok(ParsedInput { + value, + documents: 1, + source_format: Format::Json, + }), + Err(_) if input_format == InputFormat::Auto && looks_like_jsonl(&input.content) => { + let documents = parse_jsonl_documents(&input.content)?; + let count = documents.len(); + Ok(ParsedInput { + value: Value::Array(documents), + documents: count, + source_format: Format::Json, + }) + } + Err(error) => Err(runtime(format!("invalid JSON input: {error}"))), + } +} + +fn parse_jsonl_documents(input: &str) -> Result, CliError> { + let mut documents = Vec::new(); + for (index, line) in input.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let document = serde_json::from_str::(trimmed) + .map_err(|error| runtime(format!("invalid JSONL at line {}: {error}", index + 1)))?; + documents.push(document); + } + + if documents.is_empty() { + return Err(runtime("JSONL input is empty")); + } + Ok(documents) +} + +fn parse_zon_input(input: &str) -> Result { + let lines = input + .lines() + .enumerate() + .filter_map(|(index, line)| { + let trimmed = line.trim_end(); + (!trimmed.trim().is_empty()).then_some((index + 1, trimmed.to_string())) + }) + .collect::>(); + + let Some((_, first_line)) = lines.first() else { + return Err(runtime("ZON input is empty")); + }; + + let value = if first_line.trim_start().starts_with("@(") { + let (array, consumed) = parse_table(&lines, 0)?; + if consumed != lines.len() { + let (line_number, _) = &lines[consumed]; + return Err(runtime(format!( + "line {line_number}: unexpected content after root table" + ))); + } + Value::Array(array) + } else { + parse_zon_object(&lines)? + }; + + let documents = match &value { + Value::Array(items) => items.len(), + _ => 1, + }; + Ok(ParsedInput { + value, + documents, + source_format: Format::Zon, + }) +} + +fn parse_zon_object(lines: &[(usize, String)]) -> Result { + let mut object = Map::new(); + let mut index = 0; + + while index < lines.len() { + let (line_number, line) = &lines[index]; + if line.starts_with(char::is_whitespace) { + return Err(runtime(format!( + "line {line_number}: indented nested blocks are not supported in zon v1" + ))); + } + + if let Some(header) = parse_table_header(line, *line_number)? { + let Some(key) = header.key.clone() else { + return Err(runtime(format!( + "line {line_number}: root table cannot appear inside an object" + ))); + }; + let (rows, consumed) = parse_table_with_header(lines, index, &header)?; + object.insert(key, Value::Array(rows)); + index += consumed; + continue; + } + + let Some((key, raw_value)) = line.split_once(':') else { + return Err(runtime(format!( + "line {line_number}: expected key:value pair" + ))); + }; + let key = key.trim(); + if key.is_empty() { + return Err(runtime(format!("line {line_number}: object key is empty"))); + } + let raw_value = raw_value.trim(); + if raw_value.is_empty() { + return Err(runtime(format!( + "line {line_number}: nested block values are not supported in zon v1" + ))); + } + object.insert(key.to_string(), parse_zon_value(raw_value, *line_number)?); + index += 1; + } + + Ok(Value::Object(object)) +} + +fn parse_table(lines: &[(usize, String)], index: usize) -> Result<(Vec, usize), CliError> { + let (line_number, line) = &lines[index]; + let Some(header) = parse_table_header(line, *line_number)? else { + return Err(runtime(format!( + "line {line_number}: expected table header" + ))); + }; + if header.key.is_some() { + return Err(runtime(format!( + "line {line_number}: expected root table header without a key" + ))); + } + parse_table_with_header(lines, index, &header) +} + +fn parse_table_with_header( + lines: &[(usize, String)], + index: usize, + header: &TableHeader, +) -> Result<(Vec, usize), CliError> { + if header.expected_rows > MAX_ZON_TABLE_ROWS { + let (header_line, _) = &lines[index]; + return Err(runtime(format!( + "line {header_line}: table declares {} row(s), above the supported limit of {MAX_ZON_TABLE_ROWS}", + header.expected_rows + ))); + } + let mut rows = Vec::with_capacity(header.expected_rows); + for offset in 0..header.expected_rows { + let row_index = index + .checked_add(1) + .and_then(|value| value.checked_add(offset)) + .ok_or_else(|| runtime("table row index overflowed"))?; + let Some((line_number, line)) = lines.get(row_index) else { + let (header_line, _) = &lines[index]; + return Err(runtime(format!( + "line {header_line}: table expected {} row(s), found {offset}", + header.expected_rows + ))); + }; + let cells = split_top_level(line, ',', *line_number)?; + if cells.len() != header.fields.len() { + return Err(runtime(format!( + "line {line_number}: table row has {} cell(s), expected {}", + cells.len(), + header.fields.len() + ))); + } + let mut object = Map::new(); + for (field, cell) in header.fields.iter().zip(cells) { + object.insert(field.clone(), parse_zon_value(cell.trim(), *line_number)?); + } + rows.push(Value::Object(object)); + } + Ok((rows, header.expected_rows + 1)) +} + +fn parse_table_header(line: &str, line_number: usize) -> Result, CliError> { + let (key, rest) = if let Some(stripped) = line.strip_prefix("@(") { + (None, format!("@({stripped}")) + } else if let Some((key, rest)) = line.split_once(":@(") { + let key = key.trim(); + if key.is_empty() { + return Err(runtime(format!("line {line_number}: table key is empty"))); + } + (Some(key.to_string()), format!("@({rest}")) + } else { + return Ok(None); + }; + + let Some(after_prefix) = rest.strip_prefix("@(") else { + return Ok(None); + }; + let Some((count, fields)) = after_prefix.split_once("):") else { + return Err(runtime(format!( + "line {line_number}: invalid table header; expected @(N):field,field" + ))); + }; + let expected_rows = count.parse::().map_err(|error| { + runtime(format!( + "line {line_number}: invalid table row count '{count}': {error}" + )) + })?; + let fields = fields + .split(',') + .map(str::trim) + .map(str::to_string) + .collect::>(); + if fields.iter().any(String::is_empty) { + return Err(runtime(format!("line {line_number}: table field is empty"))); + } + + Ok(Some(TableHeader { + key, + expected_rows, + fields, + })) +} + +fn parse_zon_value(raw: &str, line_number: usize) -> Result { + if raw.is_empty() { + return Ok(Value::String(String::new())); + } + + if raw.starts_with(['"', '[', '{']) { + return serde_json::from_str::(raw).map_err(|error| { + runtime(format!( + "line {line_number}: invalid inline JSON value '{raw}': {error}" + )) + }); + } + + if let Some(value) = parse_keyword_value(raw) { + return Ok(value); + } + + if looks_like_number(raw) { + return serde_json::from_str::(raw).map_err(|error| { + runtime(format!( + "line {line_number}: invalid numeric value '{raw}': {error}" + )) + }); + } + + Ok(Value::String(raw.to_string())) +} + +fn parse_keyword_value(raw: &str) -> Option { + match raw { + "true" => Some(Value::Bool(true)), + "false" => Some(Value::Bool(false)), + "null" => Some(Value::Null), + _ => None, + } +} + +fn render_value(value: &Value, target_format: Format) -> Result { + match target_format { + Format::Json => { + let mut rendered = serde_json::to_string_pretty(value) + .map_err(|error| runtime(format!("failed to render JSON: {error}")))?; + rendered.push('\n'); + Ok(rendered) + } + Format::Zon => encode_zon(value), + } +} + +fn encode_zon(value: &Value) -> Result { + let mut output = String::with_capacity(estimate_zon_capacity(value)); + match value { + Value::Object(object) => encode_object(object, &mut output)?, + Value::Array(items) => { + if let Some(fields) = uniform_object_fields(items) { + encode_table(None, items, &fields, &mut output)?; + } else { + output.push_str(&render_zon_value(value)?); + output.push('\n'); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + output.push_str(&render_zon_value(value)?); + output.push('\n'); + } + } + Ok(output) +} + +fn encode_object(object: &Map, output: &mut String) -> Result<(), CliError> { + for (key, value) in object { + if let Value::Array(items) = value + && let Some(fields) = uniform_object_fields(items) + { + encode_table(Some(key), items, &fields, output)?; + continue; + } + + output.push_str(key); + output.push(':'); + output.push_str(&render_zon_value(value)?); + output.push('\n'); + } + Ok(()) +} + +fn encode_table( + key: Option<&str>, + rows: &[Value], + fields: &[String], + output: &mut String, +) -> Result<(), CliError> { + if let Some(key) = key { + output.push_str(key); + output.push(':'); + } + output.push_str("@("); + output.push_str(&rows.len().to_string()); + output.push_str("):"); + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(','); + } + output.push_str(field); + } + output.push('\n'); + + for row in rows { + let Value::Object(object) = row else { + return Err(runtime("internal error: table row is not an object")); + }; + for (index, field) in fields.iter().enumerate() { + if index > 0 { + output.push(','); + } + let value = object + .get(field) + .ok_or_else(|| runtime(format!("internal error: missing table field '{field}'")))?; + push_table_cell(output, value)?; + } + output.push('\n'); + } + Ok(()) +} + +fn render_zon_value(value: &Value) -> Result { + serde_json::to_string(value) + .map_err(|error| runtime(format!("failed to render ZON inline value: {error}"))) +} + +fn push_table_cell(output: &mut String, value: &Value) -> Result<(), CliError> { + if let Value::String(text) = value + && is_bare_table_string(text) + { + output.push_str(text); + return Ok(()); + } + output.push_str(&render_zon_value(value)?); + Ok(()) +} + +fn estimate_zon_capacity(value: &Value) -> usize { + match value { + Value::Array(items) => items.len().saturating_mul(64), + Value::Object(object) => object.len().saturating_mul(64), + Value::Null | Value::Bool(_) | Value::Number(_) => 16, + Value::String(text) => text.len() + 8, + } +} + +fn uniform_object_fields(rows: &[Value]) -> Option> { + let first = rows.first()?; + let Value::Object(first_object) = first else { + return None; + }; + let fields = first_object.keys().cloned().collect::>(); + for row in rows { + let Value::Object(object) = row else { + return None; + }; + if object.len() != fields.len() || fields.iter().any(|field| !object.contains_key(field)) { + return None; + } + } + Some(fields) +} + +fn split_top_level( + input: &str, + delimiter: char, + line_number: usize, +) -> Result, CliError> { + let mut fields = Vec::new(); + let mut start = 0; + let mut in_string = false; + let mut escaped = false; + let mut depth = 0_u32; + + for (index, character) in input.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + + match character { + '"' => in_string = true, + '[' | '{' => depth += 1, + ']' | '}' => { + depth = depth.checked_sub(1).ok_or_else(|| { + runtime(format!("line {line_number}: unmatched closing delimiter")) + })?; + } + item if item == delimiter && depth == 0 => { + fields.push(&input[start..index]); + start = index + item.len_utf8(); + } + _ => {} + } + } + + if in_string { + return Err(runtime(format!("line {line_number}: unterminated string"))); + } + if depth != 0 { + return Err(runtime(format!( + "line {line_number}: unclosed inline compound value" + ))); + } + + fields.push(&input[start..]); + Ok(fields) +} + +fn merge_values(values: Vec) -> Value { + let mut values = values.into_iter(); + let Some(first) = values.next() else { + return Value::Array(Vec::new()); + }; + if values.len() == 0 { + first + } else { + let mut merged = Vec::with_capacity(values.len() + 1); + merged.push(first); + merged.extend(values); + Value::Array(merged) + } +} + +fn looks_like_jsonl(input: &str) -> bool { + let mut count = 0_usize; + for line in input.lines().map(str::trim).filter(|line| !line.is_empty()) { + if serde_json::from_str::(line).is_err() { + return false; + } + count += 1; + } + count > 1 +} + +fn has_zon_extension(path: &Path) -> bool { + has_extension(path, "zon") +} + +fn has_jsonl_extension(path: &Path) -> bool { + has_extension(path, "jsonl") +} + +fn has_json_family_extension(path: &Path) -> bool { + has_extension(path, "json") || has_extension(path, "jsonl") +} + +fn has_extension(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn looks_like_number(value: &str) -> bool { + value + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b'-' | b'0'..=b'9')) +} + +fn is_bare_table_string(value: &str) -> bool { + !value.is_empty() + && !matches!(value, "true" | "false" | "null") + && !looks_like_number(value) + && value.chars().all(|character| { + !character.is_whitespace() && !matches!(character, ',' | '"' | '[' | ']' | '{' | '}') + }) +} + +fn write_with_trailing_newline(text: &str) -> Result<(), CliError> { + let mut stdout = io::stdout().lock(); + stdout + .write_all(text.as_bytes()) + .map_err(|error| runtime(format!("failed to write stdout: {error}")))?; + if !text.ends_with('\n') { + stdout + .write_all(b"\n") + .map_err(|error| runtime(format!("failed to write stdout: {error}")))?; + } + Ok(()) +} + +fn usage(message: impl Into) -> CliError { + CliError::Usage(message.into()) +} + +fn runtime(message: impl Into) -> CliError { + CliError::Runtime(message.into()) +} + +fn print_error(error: &CliError) { + eprintln!("error: {error}"); +} + +impl Format { + const fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Zon => "zon", + } + } +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) | Self::Runtime(message) => formatter.write_str(message), + } + } +} + +impl CliError { + const fn exit_code(&self) -> i32 { + match self { + Self::Usage(_) => 2, + Self::Runtime(_) => 3, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + #[test] + fn parse_cli_covers_flags_and_validation() { + let (outcome, cli) = parse_cli_from([ + "zon", + "--from", + "json", + "--to", + "zon", + "--format", + "json", + "--input-format", + "jsonl", + "records.jsonl", + ]) + .expect("valid cli"); + + assert_eq!(outcome, ParseOutcome::Run); + assert_eq!(cli.from, SourceFormat::Json); + assert_eq!(cli.to, TargetFormat::Zon); + assert_eq!(cli.render_mode, RenderMode::Json); + assert_eq!(cli.input_format, InputFormat::Jsonl); + assert_eq!(cli.path, Some(PathBuf::from("records.jsonl"))); + + assert!(parse_cli_from(["zon", "--format", "xml"]).is_err()); + assert!(parse_cli_from(["zon", "--color", "always"]).is_err()); + assert!(parse_cli_from(["zon", "a", "b"]).is_err()); + } + + #[test] + fn json_objects_and_tables_roundtrip_through_zon() { + let value = json!({ + "name": "Ada", + "active": true, + "users": [ + {"id": 1, "name": "Ada"}, + {"id": 2, "name": "Grace"} + ] + }); + + let zon = encode_zon(&value).expect("encode zon"); + assert!(zon.contains("users:@(2):id,name")); + let parsed = parse_zon_input(&zon).expect("parse zon"); + assert_eq!(parsed.source_format, Format::Zon); + assert_eq!(parsed.documents, 1); + assert_eq!(parsed.value, value); + + assert_eq!( + resolve_source_format(SourceFormat::Auto, Some(Path::new("sample.zon")), "{}"), + Format::Zon + ); + assert_eq!( + resolve_target_format(TargetFormat::Auto, Format::Zon), + Format::Json + ); + } + + #[test] + fn root_tables_and_jsonl_inputs_are_document_counted() { + let zon = "@(2):id,name\n1,Ada\n2,Grace\n"; + let parsed = parse_zon_input(zon).expect("root table"); + assert_eq!(parsed.documents, 2); + assert_eq!(parsed.value[0]["name"], "Ada"); + + let input = LoadedInput { + content: "{\"id\":1}\n{\"id\":2}\n".to_owned(), + source_path: Some(PathBuf::from("records.jsonl")), + }; + let cli = Cli { + render_mode: RenderMode::Text, + input_format: InputFormat::Auto, + from: SourceFormat::Auto, + to: TargetFormat::Auto, + path: None, + }; + let jsonl = parse_input(&input, &cli).expect("jsonl input"); + assert_eq!(jsonl.documents, 2); + assert_eq!(jsonl.source_format, Format::Json); + } + + #[test] + fn parser_reports_zon_shape_errors() { + assert!(parse_zon_input("").is_err()); + assert!(parse_zon_input(" child:1\n").is_err()); + assert!(parse_zon_input("users:@(1):id,\n1\n").is_err()); + assert!(parse_zon_input("@(2):id\n1\n").is_err()); + assert!(parse_zon_input("@(1):id\n1,2\n").is_err()); + assert!(parse_zon_input("@(1):id\n{\"unterminated\"\n").is_err()); + assert!(split_top_level("\"unterminated", ',', 3).is_err()); + assert!(split_top_level("]", ',', 3).is_err()); + } + + #[test] + fn parser_rejects_table_row_counts_before_large_allocation() { + let too_many = MAX_ZON_TABLE_ROWS + 1; + let error = parse_zon_input(&format!("@({too_many}):id\n")).expect_err("row cap"); + assert!(error.to_string().contains("above the supported limit")); + } + + #[test] + fn rendering_helpers_cover_scalar_arrays_and_path_extensions() { + assert!(looks_like_jsonl("{\"a\":1}\n{\"a\":2}\n")); + assert!(!looks_like_jsonl("{\"a\":1}\n")); + assert!(has_json_family_extension(Path::new("DATA.JSON"))); + assert!(!has_jsonl_extension(Path::new("events.NDJSON"))); + assert!(looks_like_number("-1")); + assert!(is_bare_table_string("Ada")); + assert!(!is_bare_table_string("two words")); + + let mixed = json!([1, {"id": 2}]); + let rendered = encode_zon(&mixed).expect("mixed array"); + assert!(rendered.starts_with('[')); + assert_eq!(merge_values(vec![json!(1)]), json!(1)); + assert_eq!(merge_values(vec![json!(1), json!(2)]), json!([1, 2])); + } + + #[test] + fn run_reads_paths_and_emits_wrapper_modes() { + let directory = tempdir().expect("tempdir"); + let json_path = directory.path().join("person.json"); + let zon_path = directory.path().join("person.zon"); + fs::write(&json_path, r#"{"name":"Ada","active":true}"#).expect("json fixture"); + fs::write(&zon_path, "name:Ada\nactive:true\n").expect("zon fixture"); + + let (_, encode_cli) = parse_cli_from([ + "zon", + "--from", + "json", + "--to", + "zon", + "--json", + json_path.to_str().expect("utf8 path"), + ]) + .expect("encode cli"); + run(&encode_cli).expect("encode run"); + + let (_, decode_cli) = parse_cli_from([ + "zon", + "--from", + "zon", + "--to", + "json", + "--toon", + zon_path.to_str().expect("utf8 path"), + ]) + .expect("decode cli"); + run(&decode_cli).expect("decode run"); + + let missing = Cli { + render_mode: RenderMode::Text, + input_format: InputFormat::Auto, + from: SourceFormat::Auto, + to: TargetFormat::Auto, + path: Some(directory.path().join("missing.zon")), + }; + assert!(run(&missing).is_err()); + } + + #[test] + fn load_inputs_supports_path_streams_and_reports_empty_files() { + let directory = tempdir().expect("tempdir"); + let empty = directory.path().join("empty.json"); + fs::write(&empty, "\n").expect("empty fixture"); + assert!(read_path(&empty).is_err()); + + let jsonl = LoadedInput { + content: "{\"a\":1}\n{\"a\":2}\n".to_owned(), + source_path: None, + }; + let parsed = parse_json_input(&jsonl, InputFormat::Auto).expect("auto jsonl"); + assert_eq!(parsed.documents, 2); + assert!(parse_json_input(&jsonl, InputFormat::Jsonl).is_ok()); + assert!(parse_json_input(&jsonl, InputFormat::Lines).is_err()); + assert!(parse_jsonl_documents("\n").is_err()); + } +} diff --git a/crates/zon/src/main.rs b/crates/zon/src/main.rs new file mode 100644 index 0000000..756fba0 --- /dev/null +++ b/crates/zon/src/main.rs @@ -0,0 +1,5 @@ +//! Binary entry point for `zon`. + +fn main() { + std::process::exit(zon::main_entry()); +} diff --git a/crates/zon/tests/zon_cli.rs b/crates/zon/tests/zon_cli.rs new file mode 100644 index 0000000..e4eff18 --- /dev/null +++ b/crates/zon/tests/zon_cli.rs @@ -0,0 +1,181 @@ +//! Integration tests for the `zon` command. + +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn cargo_command() -> Command { + Command::cargo_bin("zon").expect("binary") +} + +fn fixture(path: &str) -> PathBuf { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join(path); + assert!( + fixture.exists(), + "missing fixture `{path}` at {}", + fixture.display() + ); + fixture +} + +#[test] +fn json_file_encodes_to_v1_zon_object() { + cargo_command() + .arg(fixture("json-family/zon/person.json")) + .assert() + .success() + .stdout(predicate::str::contains("name:\"Ada\"\n")) + .stdout(predicate::str::contains("active:true\n")) + .stdout(predicate::str::contains("score:42\n")) + .stdout(predicate::str::contains("tags:[\"math\",\"logic\"]\n")) + .stdout(predicate::str::contains( + "profile:{\"city\":\"London\",\"year\":1843}\n", + )); +} + +#[test] +fn zon_file_decodes_to_pretty_json() { + cargo_command() + .args(["--from", "zon", "--to", "json"]) + .arg(fixture("json-family/zon/person.zon")) + .assert() + .success() + .stdout(predicate::str::contains("\"name\": \"Ada\"")) + .stdout(predicate::str::contains("\"active\": true")) + .stdout(predicate::str::contains("\"profile\": {")); +} + +#[test] +fn jsonl_stdin_encodes_to_root_table() { + cargo_command() + .args(["--from", "json", "--input-format", "jsonl"]) + .write_stdin("{\"name\":\"Ada\",\"score\":10,\"active\":true}\n{\"name\":\"Bob\",\"score\":11,\"active\":false}\n") + .assert() + .success() + .stdout(predicate::str::contains("@(2):name,score,active\n")) + .stdout(predicate::str::contains("Ada,10,true\n")) + .stdout(predicate::str::contains("Bob,11,false\n")); +} + +#[test] +fn root_table_decodes_to_json_array() { + cargo_command() + .args(["--from", "zon", "--to", "json"]) + .arg(fixture("json-family/zon/records.zon")) + .assert() + .success() + .stdout(predicate::str::contains("\"name\": \"Ada\"")) + .stdout(predicate::str::contains("\"score\": 11")) + .stdout(predicate::str::contains("\"active\": false")); +} + +#[test] +fn explicit_path_wins_over_piped_stdin_noise() { + cargo_command() + .arg(fixture("json-family/zon/person.json")) + .write_stdin("not zon or json from upstream pipeline") + .assert() + .success() + .stdout(predicate::str::contains("name:\"Ada\"")); +} + +#[test] +fn accepts_stdin_path_stream_in_lines_mode() { + cargo_command() + .args(["--input-format", "lines"]) + .write_stdin(format!( + "{}\n", + fixture("json-family/zon/person.json").display() + )) + .assert() + .success() + .stdout(predicate::str::contains("name:\"Ada\"")); +} + +#[test] +fn lines_mode_accepts_path_with_spaces() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("person with spaces.json"); + std::fs::write(&path, "{\"name\":\"Ada\",\"active\":true}\n").expect("spaced path fixture"); + + cargo_command() + .args(["--input-format", "lines"]) + .write_stdin(format!("{}\n", path.display())) + .assert() + .success() + .stdout(predicate::str::contains("name:\"Ada\"")); +} + +#[test] +fn json_wrapper_reports_target_format_and_document_count() { + cargo_command() + .args(["--json", "--from", "json", "--input-format", "jsonl"]) + .arg(fixture("json-family/zon/records.jsonl")) + .assert() + .success() + .stdout(predicate::str::contains("\"format\":\"zon\"")) + .stdout(predicate::str::contains("\"documents\":2")) + .stdout(predicate::str::contains("@(2):name,score,active")); +} + +#[test] +fn toon_wrapper_reports_target_format_and_document_count() { + cargo_command() + .args(["--toon", "--from", "json", "--input-format", "jsonl"]) + .arg(fixture("json-family/zon/records.jsonl")) + .assert() + .success() + .stdout(predicate::str::contains("format: zon")) + .stdout(predicate::str::contains("documents: 2")) + .stdout(predicate::str::contains("@(2):name,score,active")); +} + +#[test] +fn invalid_zon_reports_line_and_unsupported_nested_blocks() { + cargo_command() + .args(["--from", "zon", "--to", "json"]) + .arg(fixture("json-family/zon/invalid-nested.zon")) + .assert() + .failure() + .stderr(predicate::str::contains("line 2")) + .stderr(predicate::str::contains( + "nested block values are not supported in zon v1", + )); +} + +#[test] +fn empty_stdin_without_path_exits_with_usage_code_2() { + cargo_command() + .write_stdin("") + .assert() + .code(2) + .stderr(predicate::str::contains("input is empty")); +} + +#[test] +fn invalid_stdin_path_stream_keeps_usage_code_2() { + cargo_command() + .args(["--input-format", "lines"]) + .write_stdin(".\\definitely-missing-zon-input.json\n") + .assert() + .code(2) + .stderr(predicate::str::contains("stdin path line 1 does not exist")); +} + +#[test] +fn help_names_zero_overhead_notation_and_examples() { + cargo_command() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Zero Overhead Notation")) + .stdout(predicate::str::contains("not Zig Object Notation")) + .stdout(predicate::str::contains( + "bat --style=plain --paging=never .\\fixtures\\json-family\\zon\\records.jsonl | zon --input-format jsonl", + )); +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..be7f8d1 --- /dev/null +++ b/deny.toml @@ -0,0 +1,26 @@ +[advisories] +# Package: paste 1.0.15 (proc-macro), transitive via netstat2 -> netlink-packet-utils. +# Reachability: build-time proc macro only; Mercury does not expose paste as a runtime parser, command, or input boundary. +# Reviewed: 2026-05-23 with `cargo tree --target all -i paste`. +# Upgrade/follow-up: remove this ignore when netstat2's dependency chain no longer requires paste, or when a maintained upstream replacement is available. +ignore = ["RUSTSEC-2024-0436"] + +[bans] +multiple-versions = "allow" + +[licenses] +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "CC0-1.0", + "BSD-3-Clause", + "BSL-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "NCSA", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.93 diff --git a/docs/ai/mercury-toolbox-ai-prompt.md b/docs/ai/mercury-toolbox-ai-prompt.md new file mode 100644 index 0000000..5b67835 --- /dev/null +++ b/docs/ai/mercury-toolbox-ai-prompt.md @@ -0,0 +1,62 @@ +# Mercury Toolbox AI Prompt +Available tools: cjson, ison, isonl, zon, tonl, mhash, jsonlgrep, jsonshape, recent, pathshadow, portping, portunlock, msudo, asmtype, asmmember, asmref, asmapi, asmflow, llvmobjdump, llvmreadobj, llvmnm, peexports, peimports, pecalls, pesig, pestrrefs, drvshape, ioctlscan, binmeta, fileprobe, outline, codeshape, refs, snip, defsnip, ctxpack, chunkcat, hitsnip, diagpick, logshape, stringscan, toon, csvshape, sqliteshape, sqlshape, unityasset, unityprobe, unitydiag, envdiff, proctree, sysshape, runprobe, await, argv, config, gitshape, reposhape, dotnetshape, unlock +Rules: Prefer Mercury first when the task is local inspection, shaping, or triage. Prefer compact text. Use `--json` when the next step parses output, and use `--toon` or `--format toon` when structured output is meant for an AI/model to read. Pipe external JSON into `toon` only when the producer is not a Mercury tool; TOON auto-detects JSON and emits denser TOON by default. Set `$env:MERCURY_OUTPUT='json'` or `$env:MERCURY_OUTPUT='toon'` when most commands in the session should default to that structured format. Prefer stdin and pipelines over re-reading large files. Prefer `fileprobe`, `outline`, `snip`, `chunkcat`, `hitsnip`, `defsnip`, `codeshape`, `refs`, and `ctxpack` over raw `Get-Content`, `cat`, or whole-file dumps. Every tool has guided triage metadata in the generated prompt and skill catalog; PE deep-analysis tools also emit runtime `answer=`, `trust`, `report_quality`, and `next_actions` fields. For every command, read the generated `guided_triage` answer, trust basis, and next actions before inventing a new path. When you are unsure which command to run next, prefer the emitted `next_actions` before inventing an ad-hoc pipeline. Use these tools with modern CLI companions such as `rg`, `fd`, `jq`, `yq`, `bat`, `sd`, `xh`, `tokei`, `eza`, `procs`, `dust`, `hyperfine`, `hexyl`, `zoxide`, `lazygit`, `delta`, and PowerShell. Prefer `mhash` for local hashing, OpenHashTab-class algorithm discovery, JSONL manifest generation and verification, and quick hash throughput benches; pair its JSON/JSONL output with `jq` and compare external command timing with `hyperfine` when needed. Prefer `rg` over recursive `grep` or `Select-String`, `fd` over recursive `Get-ChildItem`, `bat` over raw `Get-Content`, `jq` or `yq` over manual JSON or YAML parsing, and `xh` over `curl` for quick HTTP checks. +cjson: Compact JSON or JSONL before piping, diffing, or model input. Better than: pretty multi-line JSON when you need compact stable output and optional key sorting. Usage: `cjson [OPTIONS] [PATH]` Example: `cjson ` Guided: answer=Answer whether JSON/JSONL can be compacted cleanly and deterministically. trust=Trust the JSON parser result and document count; invalid JSON is reported before compaction. next=Run jsonshape on the compacted payload when you need schema shape. | Pipe to jq or toon for projection/model handoff. +ison: Convert between JSON and ISON records for compact structured handoff. Better than: verbose JSON when a deterministic compact ISON representation is easier to scan or diff. Usage: `ison [OPTIONS] [PATH]` Example: `ison ` Guided: answer=Answer whether JSON can round-trip through compact ISON for handoff. trust=Trust successful parser/encoder output; use JSON mode when a tool must consume it next. next=Use isonl for line-oriented streams. | Use toon when the next consumer is an AI model. +isonl: Convert between JSONL and ISONL line records. Better than: manual JSONL splitting when line-oriented ISON records are better for streaming or model input. Usage: `isonl [OPTIONS] [PATH]` Example: `isonl --from jsonl --to isonl ` Guided: answer=Answer whether JSONL/ISONL records can be converted as a stream. trust=Trust per-line parser diagnostics and document counts; malformed lines stop the conversion. next=Pipe into jsonlgrep for filtering. | Use cjson or toon for downstream compact handoff. +zon: Convert JSON or JSONL to Zero Overhead Notation and back. Better than: hand-written compact table encodings when you need Zero Overhead Notation interop without Node tooling. Usage: `zon [OPTIONS] [PATH]` Example: `zon ` Guided: answer=Answer whether JSON/JSONL can convert to or from Zero Overhead Notation. trust=Trust parser diagnostics and wrapper metadata for document count and target format. next=Use jsonshape after decoding to validate shape. | Use toon for model-facing structured summaries. +tonl: Encode, decode, query, validate, index, stream-query, and ETL JSON-backed TONL data. Better than: one-off scripts for compact JSON ETL, validation, query, indexing, or streaming filters. Usage: `tonl [OPTIONS] [PATH] | tonl index [OPTIONS] | tonl stream query [OPTIONS] [PATH]` Example: `tonl query --where active=true ` Guided: answer=Answer filtered TONL records, validation status, indexes, or ETL conversion results. trust=Trust explicit validation/query diagnostics; streaming mode reports record-level failures. next=Use jsonlgrep for simple JSONL filtering before TONL conversion. | Use cjson or toon for compact handoff. +mhash: Compute local file hashes across the OpenHashTab-class matrix, list algorithms, hash with `--all` or explicit multi-algorithm sets, produce JSONL manifests, verify manifests, or benchmark hash throughput. Better than: PowerShell Get-FileHash loops, GUI hash tabs, ad-hoc checksum scripts, or manual benchmark harnesses when you need OpenHashTab-class algorithm coverage, manifests, verification, JSON/JSONL, and throughput telemetry in one local CLI. Usage: `mhash [OPTIONS] [PATH...] | mhash verify [OPTIONS] | mhash list-algorithms [--json] | mhash bench [OPTIONS] [PATH...]` Example: `mhash --algorithm sha256,blake3-256,xxh3-128 .\dist\MercuryToolbox.zip --json | ConvertFrom-Json` Guided: answer=Answer file identity, checksum, manifest verification, or hash throughput questions. trust=Trust algorithm labels, manifest verification status, and per-file error rows. next=Use fileprobe first when the file type is unknown. | Use jsonlgrep or jq to filter manifest rows. +jsonlgrep: Filter JSONL logs, single-line JSON arrays, or structured event streams by field. Better than: ad-hoc `jq` for simple field filters, projections, counts, and regex matches. Usage: `jsonlgrep [OPTIONS] [PATH...]` Example: `recent --root . --limit 20 --json | jsonlgrep 'path~=LogOutput' --pick modified_rfc3339,path --json | ConvertFrom-Json` Guided: answer=Answer which JSONL records match field, regex, or projection filters. trust=Trust parsed JSON records and explicit malformed-line diagnostics. next=Use jsonshape on matched records to inspect structure. | Use toon or cjson for compact AI handoff. +jsonshape: Summarize JSON or JSONL into path/type stats, or diff two shape reports. Better than: opening raw payloads when you first need the shape, key paths, or a schema diff. Usage: `jsonshape [OPTIONS] [PATH...] | jsonshape [OPTIONS] diff ` Example: `jsonshape diff before.json after.json --json | ConvertFrom-Json` Guided: answer=Answer the key paths, types, counts, and shape differences in JSON/JSONL. trust=Trust reported path/type counts when parsing succeeds; review diff diagnostics for drift. next=Use jsonlgrep to isolate records before reshaping. | Use cjson to normalize payloads before comparing. +recent: List recently changed files or directories. Better than: manual directory scans when you just need the freshest files and `.gitignore` awareness. Usage: `recent [OPTIONS]` Example: `recent --root . --since 2h --ext rs --name '^(lib|main)$'` Guided: answer=Answer which files or directories changed recently. trust=Trust timestamp filters and gitignore-aware discovery within the selected root. next=Pipe paths into fileprobe or outline. | Use hitsnip or ctxpack after narrowing files. +pathshadow: Show which executable wins and which ones are shadowed. Better than: guessing PATH order when the wrong executable is being picked. Usage: `pathshadow [OPTIONS] [COMMAND...]` Example: `pathshadow python npm --shell powershell --summary --json | ConvertFrom-Json` Guided: answer=Answer which executable wins on PATH and which candidates are shadowed. trust=Trust resolved filesystem candidates and shell-aware summary rows. next=Use sysshape --group shell for broader environment context. | Use argv quote when command invocation quoting is suspicious. +portping: Probe ports or health endpoints with timings. Better than: jumping between multiple network tools for quick TCP and HTTP reachability checks. Usage: `portping [OPTIONS] [TARGET...]` Example: `portping tcp://127.0.0.1:80` Guided: answer=Answer whether a TCP/HTTP/HTTPS endpoint is reachable and how long it took. trust=Trust direct connection/status timing; failures distinguish DNS, connect, and HTTP states. next=Use portunlock when a local port is unexpectedly occupied. | Use await port/http to wait for readiness. +portunlock: Show and free local TCP or UDP port owners on Windows. Better than: guessing which process owns a port or hand-writing `netstat` and kill loops when a local listener refuses to go away. Usage: `portunlock [OPTIONS] who | portunlock [OPTIONS] free ` Example: `portunlock who --json --protocol any 3000 8080 | ConvertFrom-Json` Guided: answer=Answer which process owns a local TCP/UDP port and optionally free it. trust=Trust OS port snapshots and post-action verification rows. next=Use proctree on the owning PID before freeing. | Use portping after freeing or restarting a listener. +msudo: Top-level high-risk command: inspect Windows elevation relay status, token state, active session state, and host availability before any privileged launch. Better than: guessing whether Windows elevation relay support is available, or hand-writing `Start-Process -Verb RunAs` wrappers when you first need the current privilege status. Usage: `msudo [OPTIONS] [--] | msudo run [OPTIONS] [--] | msudo status [OPTIONS] | msudo [OPTIONS] --shell ` Example: `msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated` Guided: answer=Answer Windows elevation, token, relay, and launch-shape questions. trust=Trust status discovery before launch; treat run actions as high risk and explicit. next=Start with msudo status --json. | Use argv quote before privileged shell wrapping. +asmtype: List managed assembly types and filter them by name, namespace, base type, interface, or matching members, with optional user-code-only screening. Better than: manual reflection or ad-hoc PowerShell when you first need the type map from a managed assembly. Usage: `asmtype [OPTIONS] [ASSEMBLY...]` Example: `asmtype --with-member-match 'Build|Launch' --show-matched-members --pick full_name,base_type,matched_members --json | ConvertFrom-Json` Guided: answer=Answer which managed types match name, inheritance, interface, or member intent. trust=Trust metadata enumeration from the target assembly; broaden filters when no matches return. next=Pipe JSONL into asmmember. | Use asmflow find/xref when behavior matters. +asmmember: Inspect managed type members with binding filters and compact signatures, including JSONL handoff from `asmtype` and user-code-only filtering. Better than: long reflection scripts when you need methods, fields, and properties from a known managed type. Usage: `asmmember [OPTIONS] ` Example: `asmtype --intent unity-spacecraft-workflow --json | asmmember --assembly --input-format jsonl --intent unity-spacecraft-workflow --pick type_name,name,visibility,signature --json | ConvertFrom-Json` Guided: answer=Answer which methods, fields, and properties exist on selected managed types. trust=Trust assembly metadata and binding filters; use non-public filters intentionally. next=Use asmflow body for IL details. | Use asmapi diff when comparing versions. +asmref: Inspect managed assembly references, check whether they resolve from local directories, or run `diagnose` to report closure risks such as missing references, same-name version/token conflicts, winning DLLs, test-only leaks, and obvious MissingMethod/TypeLoad hazards. Better than: guessing missing managed dependencies when you need a fast reference, resolution, or Unity/Rocket plugin closure diagnosis pass. Usage: `asmref [OPTIONS] [ASSEMBLY...] | asmref diagnose [OPTIONS] [ASSEMBLY...]` Example: `asmref diagnose --resolve-dir --format toon` Guided: answer=Answer managed references, resolution status, and dependency closure risks. trust=Trust resolved candidates and risk tiers; explicit resolve dirs define the runtime universe. next=Run asmref diagnose for closure risks. | Use asmapi diff on risky version changes. +asmapi: Compare two managed assemblies for added or removed public types, removed methods, signature changes, and MissingMethodException risks, with optional internal/all visibility. Better than: manual reflection diffs or brittle spreadsheet comparisons when you need to know what managed API changed between two DLL versions. Usage: `asmapi [OPTIONS] [ARGS...]` Example: `asmapi diff .\old\0Harmony.dll .\new\0Harmony.dll --json | ConvertFrom-Json` Guided: answer=Answer public API differences between managed assemblies. trust=Trust metadata comparison under the chosen visibility scope. next=Use asmref diagnose to connect API drift to dependency closure. | Use asmmember on removed or changed types. +asmflow: Inspect managed method bodies, IL summaries, and cross-references for Unity and general .NET reverse-engineering flows. Better than: ad-hoc IL dump scripts or manual dnSpy browsing when you need callers, callees, field access, or string literals from a managed method quickly. Usage: `asmflow [OPTIONS] [ARGS...]` Example: `asmflow xref --assembly 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' --json | ConvertFrom-Json` Guided: answer=Answer managed IL bodies, callers, callees, field access, and string references. trust=Trust method-body metadata when the target method resolves; unknown bodies are reported. next=Use asmtype/asmmember to find exact targets. | Use ctxpack to hand off IL evidence. +llvmobjdump: Inspect object sections, symbol labels, and disassembly through LLVM backend discovery. Better than: raw llvm-objdump text when you need compact section, symbol, and disassembly shape for COFF/PE or object files. Usage: `llvmobjdump [OPTIONS] [PATH...]` Example: `llvmobjdump .\target\release-fast\binmeta.exe --json | ConvertFrom-Json` Guided: answer=Answer object sections, symbols, and disassembly shape through LLVM. trust=Trust LLVM backend output plus parser warnings/raw blocks for unsupported regions. next=Use llvmreadobj for headers/imports/debug hints. | Use pecalls or pesig for PE-focused call/function triage. +llvmreadobj: Inspect COFF/PE headers, sections, imports, exports, and debug hints through LLVM backend discovery. Better than: heavy binary viewers or raw llvm-readobj output when you need a bounded COFF/PE header, import/export, and debug/PDB summary. Usage: `llvmreadobj [OPTIONS] [PATH...]` Example: `llvmreadobj .\target\release-fast\binmeta.exe --json | ConvertFrom-Json` Guided: answer=Answer COFF/PE headers, sections, imports, exports, and debug hints through LLVM. trust=Trust LLVM backend exit status and parsed blocks; raw blocks preserve unparsed details. next=Use peimports/peexports for PE-focused grouping. | Use llvmobjdump when code layout or disassembly is needed. +llvmnm: List and classify object symbols through LLVM nm output. Better than: raw symbol-table dumps when you need stable symbol rows with kind, address, size, object/archive prefix, and source-line hints. Usage: `llvmnm [OPTIONS] [PATH...]` Example: `llvmnm .\target\release-fast\binmeta.exe --defined-only --json | ConvertFrom-Json` Guided: answer=Answer defined/undefined symbols with stable LLVM nm rows. trust=Trust POSIX-format symbol parsing and backend stderr tails. next=Use llvmobjdump for disassembly around symbols. | Use llvmreadobj for object headers and debug hints. +peexports: Inspect export surfaces, spot forwarded symbols, filter by name or ordinal, and use `report_quality`/`next_actions` to decide the next PE pass. Better than: manual PE export-table browsing when you want names, ordinals, RVA/VA, forwarders, and guided follow-up commands without opening a heavy PE debugger. Usage: `peexports [OPTIONS] [PATH...]` Example: `peexports C:\Windows\System32\kernel32.dll --forwarders-only --json | ConvertFrom-Json` Guided: answer=Answer PE export names, ordinals, RVAs/VAs, forwarders, and entrypoint hints. trust=Trust PE export table parsing plus report_quality evidence and limitations. next=Use peimports to inspect dependencies. | Use llvmreadobj --exports to cross-check LLVM output. +peimports: Inspect imports by library, name, or category, quickly separate filesystem, registry, device I/O, NT, and network APIs, then follow emitted `next_actions`. Better than: ad-hoc import-table triage when you need API families grouped into human-friendly categories with immediate callsite and role follow-ups. Usage: `peimports [OPTIONS] [PATH...]` Example: `peimports .\target\release-fast\portping.exe --category device_io --json | ConvertFrom-Json` Guided: answer=Answer imported APIs grouped by library and API category. trust=Trust PE import table parsing plus report_quality evidence and limitations. next=Use pecalls on interesting categories. | Use drvshape for driver-like/native profiles. +pecalls: Extract callsites for one or more imported APIs or API categories from LLVM objdump text and inspect `report_quality.limitations` before treating it as exhaustive. Better than: guessing import callsites from raw disassembly when you want direct IAT matches plus trust metadata around best-effort disassembly parsing. Usage: `pecalls [OPTIONS] [PATH...]` Example: `pecalls .\target\release-fast\portping.exe --api WSAStartup --json | ConvertFrom-Json` Guided: answer=Answer direct imported API callsites found through LLVM disassembly. trust=Trust direct IAT-target matches; report_quality limitations mark disassembly truncation or gaps. next=Use peimports to choose categories. | Use pesig to place callsites inside functions. +pesig: Infer function boundaries, source hints, and calling conventions from PE text and metadata, then pivot to imports, callsites, or raw objdump via `next_actions`. Better than: manual function-boundary and calling-convention inference when you need a quick v1 signal with explicit confidence and next-step context. Usage: `pesig [OPTIONS] [PATH...]` Example: `pesig .\target\release-fast\portping.exe --min-confidence medium --json | ConvertFrom-Json` Guided: answer=Answer likely PE function boundaries and calling-convention hints. trust=Trust confidence/evidence per function and report_quality for parse completeness. next=Use pecalls to connect functions to APIs. | Use llvmobjdump for raw disassembly context. +pestrrefs: Scan ASCII or UTF-16LE strings, report direct references from LLVM disassembly, and use `next_actions` to connect strings to imports, callsites, or IOCTL constants. Better than: opening a disassembler and a string viewer separately when you need string hits with direct xrefs and a clear next triage command. Usage: `pestrrefs [OPTIONS] [PATH...]` Example: `pestrrefs .\target\release-fast\portping.exe --contains DeviceIoControl --json | ConvertFrom-Json` Guided: answer=Answer strings and direct code references in PE files. trust=Trust direct VA/range xrefs; unresolved counts are not guessed. next=Use peimports to connect strings to APIs. | Use ioctlscan for device-control constants. +drvshape: Summarize likely WDM, KMDF, NDIS, minifilter, or native driver traits, surface heuristic driver hints, and treat `report_quality` as the trust boundary. Better than: guessing the driver role from imports and exports when you want a compact Windows driver profile with IOCTL/import/callsite follow-ups already suggested. Usage: `drvshape [OPTIONS] [PATH...]` Example: `drvshape C:\Windows\System32\drivers\ndis.sys --json | ConvertFrom-Json` Guided: answer=Answer likely Windows driver role, subsystem, imports, exports, and heuristic hints. trust=Trust role hints as heuristics, not vulnerability conclusions; report_quality shows evidence. next=Use ioctlscan to decode CTL_CODE candidates. | Use pecalls --category device_io for dispatch/callsite evidence. +ioctlscan: Scan PE bytes and disassembly-adjacent data for likely IOCTL constants, decode their fields, and use `report_quality` to separate strong hits from heuristic candidates. Better than: searching for Windows IOCTL constants by hand when you want decoded CTL_CODE fields, code locations, and suggested role/callsite/string follow-ups. Usage: `ioctlscan [OPTIONS] [PATH...]` Example: `ioctlscan C:\Windows\System32\drivers\ndis.sys --json | ConvertFrom-Json` Guided: answer=Answer probable IOCTL constants and decoded CTL_CODE fields. trust=Trust confidence/evidence per candidate; raw byte hits are heuristic until tied to code context. next=Use drvshape to confirm driver role. | Use pestrrefs or pecalls for surrounding evidence. +binmeta: Inspect file metadata, Windows PE basics, version/company/product identity, embedded Authenticode certificate tables, WinTrust status, catalog signing, signer certificate subjects, chains, timestamps, and expiry hints. Better than: opening heavy PE tooling or shelling out to signature utilities when you first need binary identity, PE basics, version resources, and Windows signing status. Usage: `binmeta [OPTIONS] [PATH...]` Example: `binmeta .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json` Guided: answer=Answer file identity, PE basics, version resource fields, company/product names, signature status, embedded-vs-catalog signing, signer subject, certificate chain, timestamp, and expiry fields before deeper binary analysis. trust=Trust goblin PE/resource parsing for local structure and Windows WinTrust/catalog status on Windows; signature failures are reported as metadata, not command failures. On non-Windows, trust only embedded certificate table presence and treat WinTrust fields as unsupported. next=Use peimports or llvmreadobj for deeper PE detail after identity is known. | Use drvshape or ioctlscan for signed drivers and native/system binaries. | Use stringscan or pestrrefs when identity/version metadata is missing or suspicious. +fileprobe: Classify files as text, binary, generated, minified, test, vendor, and more, including encoding, BOM, and newline diagnostics. Better than: blindly opening files when you need a fast read-worthiness, encoding, or newline check first. Usage: `fileprobe [OPTIONS] [PATH...]` Example: `fileprobe .\src\main.rs` Guided: answer=Answer whether a path is text, binary, generated, minified, vendor, or worth opening. trust=Trust bounded sniffing, encoding, newline, and classification diagnostics. next=Use outline or chunkcat for readable text. | Use binmeta or stringscan for binary files. +outline: List top-level code or config structure. Better than: scrolling whole files when you only need the structure. Usage: `outline [OPTIONS] ` Example: `outline ` Guided: answer=Answer top-level structure of source or config files. trust=Trust parser-supported outlines and explicit unsupported-file diagnostics. next=Use defsnip for exact definitions. | Use refs or codeshape for broader symbol maps. +codeshape: Map a codebase into files plus AST-backed declarations and signatures from the shared `codeindex` engine. Better than: plain `tree` output when you need internal declarations and signatures without dumping full source files. Usage: `codeshape [OPTIONS] [PATH...]` Example: `codeshape --max-depth 1 --limit-per-file 8 . --json | ConvertFrom-Json` Guided: answer=Answer repository file/declaration shape with AST-backed summaries. trust=Trust codeindex parser results and skipped-file diagnostics. next=Use defsnip to extract definitions. | Use refs to find use sites. +refs: Find exact symbol references or caller definitions through the shared `codeindex` engine with text fallback. Better than: falling back to raw `rg` when you need exact use-sites or caller summaries for a known symbol. Usage: `refs [OPTIONS] [PATH...] | refs [OPTIONS] --at [PATH...]` Example: `refs helper ` Guided: answer=Answer symbol references or callers across source files. trust=Trust codeindex hits and caller grouping under the selected roots. next=Use hitsnip to expand hit context. | Use defsnip for definition bodies. +snip: Extract precise snippets from files or stdin. Better than: dumping whole files when you only need exact lines, matches, or symbols. Usage: `snip [OPTIONS] [PATH...]` Example: `snip --lines 16:27 ` Guided: answer=Answer exact bounded source/text snippets by line, match, or symbol. trust=Trust line numbers and bounded extraction; binary or missing paths are rejected. next=Use hitsnip after rg for multiple hits. | Use ctxpack to bundle snippets. +defsnip: Extract full AST-backed definitions by exact symbol name through the shared `codeindex` engine. Better than: line-number snippets when you need the full function, class, or method body even after nearby edits move the lines. Usage: `defsnip [OPTIONS] [PATH...]` Example: `defsnip build_report . --json | ConvertFrom-Json` Guided: answer=Answer full AST-backed definition blocks for symbols. trust=Trust codeindex definitions and language support; no match means broaden symbol/root. next=Use refs for callers/use sites. | Use ctxpack to package the definition with nearby context. +ctxpack: Pack files, snippets, hits, diagnostics, or definition JSON into compact context blocks. Better than: hand-assembling ad-hoc prompt context from files, hits, diagnostics, and definitions. Usage: `ctxpack [OPTIONS] [INPUT...]` Example: `ctxpack ` Guided: answer=Answer compact prompt-ready context from files, snippets, hits, and diagnostics. trust=Trust included path and byte/line caps; review omitted/truncated sections for scope. next=Use fileprobe/codeshape before packing broad roots. | Use diagpick or hitsnip to feed focused evidence. +chunkcat: Read large text files in bounded chunks, including quick tail-style log windows. Better than: huge file dumps when you need deterministic windows. Usage: `chunkcat [OPTIONS] [PATH...]` Example: `chunkcat .\BepInEx\LogOutput.log --max-lines 20 --tail` Guided: answer=Answer a bounded chunk or inventory of a large text file. trust=Trust chunk indices, line ranges, and binary detection. next=Use snip when you know the exact line range. | Use hitsnip to expand search hits into chunks. +hitsnip: Turn search hits into compact snippets or promote them to enclosing definitions. Better than: raw `rg` hit lists when you need merged readable context or the enclosing definition instead of a tiny window. Usage: `hitsnip [OPTIONS] [HIT...]` Example: `rg -nH "helper" | hitsnip --def` Guided: answer=Answer merged snippets around rg-style search hits. trust=Trust hit parsing and merge ranges; malformed hit lines are reported. next=Use rg -nH to feed hits. | Use defsnip when the hit is inside a known symbol. +diagpick: Extract compiler or runtime diagnostics from noisy logs, with optional snippets and enclosing definitions. Better than: reading full logs when you need the actionable errors first and want direct source or definition context. Usage: `diagpick [OPTIONS] [PATH...]` Example: `diagpick --def` Guided: answer=Answer actionable diagnostics extracted from compiler/build/log text. trust=Trust recognized diagnostic formats and preserved source context. next=Use snip on referenced files/lines. | Use runprobe to capture the failing command reproducibly. +logshape: Group recurring log lines into templates. Better than: reading repetitive logs line by line when you need the dominant patterns. Usage: `logshape [OPTIONS] [PATH...]` Example: `logshape ` Guided: answer=Answer repeated log templates, incidents, and message clusters. trust=Trust grouping counts and examples; rare lines may stay as individual templates. next=Use diagpick for actionable errors. | Use jsonlgrep when logs are structured JSONL. +stringscan: Extract and classify high-signal strings from binaries or blobs. Better than: a full reverse-engineering pass when you first need identity clues from strings. Usage: `stringscan [OPTIONS] [PATH...]` Example: `stringscan ` Guided: answer=Answer strings and classified hints from arbitrary blobs. trust=Trust extracted string offsets and classification labels; encoding limits are explicit. next=Use binmeta first for binary identity. | Use pestrrefs for PE strings with xrefs. +toon: Convert between JSON and TOON. Better than: verbose JSON when you want a denser structured text form. Usage: `toon [OPTIONS] [PATH]` Example: `'{"ok":true}' | toon` Guided: answer=Answer compact model-readable TOON from JSON/JSONL or decode TOON back. trust=Trust parser/encoder diagnostics and document wrapper metadata. next=Use native --toon on Mercury producers when available. | Use cjson/jsonshape before converting questionable JSON. +csvshape: Summarize large CSV or TSV files into compact schema/bounded stats, or diff two CSV shapes. Better than: opening huge CSV or TSV files when you first need the columns, rough types, blanks, example values, or a shape diff. Usage: `csvshape [OPTIONS] [PATH...] | csvshape [OPTIONS] diff ` Example: `csvshape diff before.csv after.csv --json | ConvertFrom-Json` Guided: answer=Answer CSV/TSV delimiter, columns, row counts, and schema differences. trust=Trust parsed rows under the detected or requested delimiter. next=Use jsonshape after converting CSV to JSON externally. | Use config or sqlshape when CSV describes configuration/data imports. +sqliteshape: Inspect SQLite databases read-only with compact schema/table summaries, or diff two SQLite shapes. Better than: dropping into an interactive SQLite shell when you only need schema, sample rows, light table stats, or a shape diff. Usage: `sqliteshape [OPTIONS] [PATH...] | sqliteshape [OPTIONS] diff ` Example: `'C:\data\events.db' | sqliteshape --input-format lines --json | ConvertFrom-Json` Guided: answer=Answer SQLite tables, columns, indexes, triggers, and schema diffs. trust=Trust SQLite catalog queries against the selected database file. next=Use sqlshape for normalized cross-engine reports. | Use jsonshape/csvshape on exported data samples. +sqlshape: Inspect PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, or DuckDB schemas read-only with redacted connection sources and normalized cross-engine diffs. Better than: hand-writing per-engine catalog queries or exposing raw connection strings when you need a normalized cross-database schema report or diff. Usage: `sqlshape [OPTIONS] --url | sqlshape [OPTIONS] diff --before-url --after-url ` Example: `sqlshape --engine sqlite --url --json | ConvertFrom-Json` Guided: answer=Answer normalized SQL schema across SQLite, PostgreSQL, MySQL/MariaDB, SQL Server, or DuckDB. trust=Trust engine-specific introspection and redacted connection reporting. next=Use sqliteshape for SQLite-specific detail. | Use config to inspect connection/config files safely. +unityasset: Inspect Unity serialized assets, bundles, and YAML objects, then trace refs, dump fields, or extract payloads such as TextAsset, MonoScript, AudioClip, and VideoClip. Better than: opening a heavy Unity GUI tool when you first need an index, object refs, field dump, or a quick extraction path for assets, scripts, audio, or video. Usage: `unityasset [OPTIONS] index [PATH...] | unityasset [OPTIONS] refs [PATH...] | unityasset [OPTIONS] dump [PATH...] | unityasset [OPTIONS] extract [PATH...] [--output-dir ]` Example: `unityasset index 'C:\game\Game_Data'` Guided: answer=Answer Unity asset indexes, object dumps, references, and extraction writes. trust=Trust parsed serialized files/bundles and explicit unresolved reference rows. next=Use unitydiag for runtime/log symptoms. | Use asmtype/asmflow for managed script assemblies. +unityprobe: Install and query a read-only BepInEx bridge for live scenes, objects, and static state in a running Mono Unity game on Windows. Better than: hand-writing BepInEx reflection plugins or one-off named-pipe helpers when you only need a read-only runtime snapshot from a Mono Unity game. Usage: `unityprobe [OPTIONS] [ARGS...]` Example: `unityprobe install 'C:\game'` Guided: answer=Answer read-only Unity runtime bridge status, object search, and inspection. trust=Trust explicit bridge install/status and Windows named-pipe reachability. next=Use unitydiag when runtime logs explain bridge issues. | Use unityasset for offline assets. +unitydiag: Summarize Unity Player.log and BepInEx logs into grouped incidents, normalized messages, and domain-aware failure summaries. Better than: reading raw Player.log and BepInEx logs end to end when you need the repeated incidents, top frames, and mod-vs-framework blame split first. Usage: `unitydiag [OPTIONS] [PATH...]` Example: `unitydiag 'C:\Users\example\AppData\LocalLow\Studio\Game\Player.log'` Guided: answer=Answer Unity/BepInEx log incidents, grouped frames, and mod/runtime warnings. trust=Trust discovered log paths, grouping counts, and preserved stack snippets. next=Use asmref diagnose for plugin dependency issues. | Use unityprobe when live object state is needed. +envdiff: Snapshot, diff, or run commands while reporting environment-variable changes. Better than: hand-comparing environment variables before and after a script, especially around PATH pollution and batch wrappers. Usage: `envdiff [OPTIONS] run --shell cmd|pwsh|raw -- ` Example: `envdiff run --shell cmd -- .\scripts\set-env.cmd` Guided: answer=Answer environment variable changes before/after a command or between snapshots. trust=Trust captured before/after maps and PATH segment deltas. next=Use pathshadow after PATH changes. | Use sysshape for broader shell/tool inventory. +proctree: Inspect whole-system, rooted, matched, or command-run Windows process trees in AI-friendly form; use --match to filter by image name or command line. Better than: flat process lists when you need parent-child context, short command lines, and lingering orphan hints. Usage: `proctree [OPTIONS] system|root |run -- ` Example: `proctree system --match 'pwsh|Mercury' --include-cmdline` Guided: answer=Answer process trees, rooted processes, and command ancestry. trust=Trust OS process snapshots at capture time; short-lived processes may disappear. next=Use portunlock for port owners. | Use runprobe to capture command execution shape. +sysshape: Capture a compact local system and tool inventory for AI-friendly triage, including shell hints, path-like duplication, and probe status. Better than: asking users ad-hoc setup questions or checking versions one tool at a time when you need a compact local machine inventory first. Usage: `sysshape [OPTIONS]` Example: `sysshape --json --env safe --group shell | ConvertFrom-Json` Guided: answer=Answer machine, shell, environment, and tool inventory shape. trust=Trust detected command paths/versions and grouped environment metadata. next=Use pathshadow for a specific executable winner. | Use envdiff around commands that mutate the environment. +runprobe: Run one command and capture exit code, duration, timeout status, and bounded output tails. Better than: reading fragile shell text when what you really need is the stable result object for one command run. Usage: `runprobe [OPTIONS] -- ` Example: `runprobe --shell raw -- cmd /d /s /c "exit 0"` Guided: answer=Answer command exit code, duration, stdout/stderr tails, and timeout behavior. trust=Trust captured process result and bounded output tails. next=Use diagpick on captured stderr/stdout. | Use envdiff run when environment mutation matters. +await: Wait for a path, TCP target, HTTP endpoint, or command condition with bounded polling. Better than: writing ad-hoc polling loops for files, ports, HTTP endpoints, or repeat-until-success commands. Usage: `await [OPTIONS] path [--state exists|missing] | await [OPTIONS] port | await [OPTIONS] http [--status ] | await [OPTIONS] run [--shell ] [--cwd ] [--exit-code ] [--tail-bytes ] -- ` Example: `await path .\target\ready.flag` Guided: answer=Answer whether a path, port, HTTP endpoint, or command became ready before timeout. trust=Trust polling attempts, final state, and timeout status. next=Use portping for one-shot endpoint detail. | Use runprobe for command readiness probes. +argv: Quote argv for `pwsh` or `cmd`, or inspect what a shell actually passed to a native process. Better than: guessing how PowerShell or cmd really passed arguments to a native process. Usage: `argv quote --shell [VALUE...] | argv inspect [--json] --shell [VALUE...]` Example: `argv quote --shell pwsh -- git commit -m "two words"` Guided: answer=Answer how arguments should be quoted or how a shell actually passes them. trust=Trust helper round-trips for the selected shell/platform. next=Use runprobe to execute the quoted command. | Use msudo status before privileged shell wrapping. +config: Read, inspect, list, set, or delete config values in JSON, YAML, TOML, env, and INI files. Better than: fighting `jq` or hand-editing config files when you need safe slash-pointer reads or edits across common formats. Usage: `config [OPTIONS] [PATH] | config [OPTIONS] [POINTER] [VALUE]` Example: `config get .\package.json /version` Guided: answer=Answer get/set/delete operations over JSON/YAML/TOML/env/INI config paths. trust=Trust parser-specific diagnostics and structured edit reports. next=Use fileprobe before editing unknown config files. | Use jsonshape after JSON config changes. +gitshape: Summarize Git status or diff shape into concise AI-friendly file, line, and touched-symbol reports. Better than: reading raw `git status` and diff noise when you first need compact status counters or changed-file summaries. Usage: `gitshape [OPTIONS] [PATH] | gitshape [OPTIONS] status [PATH] | gitshape [OPTIONS] diff [REVISION_A] [REVISION_B]` Example: `gitshape .` Guided: answer=Answer git status, branch, diff, and repository change shape. trust=Trust git command output and explicit revision/path filters. next=Use reposhape to connect git changes to ecosystems. | Use ctxpack or hitsnip for changed-file handoff. +reposhape: Detect repository ecosystems, manifests, command entrypoints, and CI hints in one pass. Better than: manually browsing an unfamiliar repo when you first need the ecosystems, manifests, commands, and CI entrypoints. Usage: `reposhape [OPTIONS] [PATH]` Example: `reposhape .` Guided: answer=Answer repository ecosystems, manifests, entrypoints, and project layout. trust=Trust manifest detection under depth/hidden filters and parse diagnostics. next=Use codeshape for source declarations. | Use dotnetshape/sqlshape/gitshape for ecosystem-specific detail. +dotnetshape: Inspect .csproj, Directory.Build.props/targets, Directory.Packages.props, project references, package versions, and .NET-specific project graph diagnostics. Better than: opening a .NET repo one file at a time when you first need project graph shape, shared MSBuild inheritance, package references, and build-shape warnings. Usage: `dotnetshape [OPTIONS] [PATH]` Example: `dotnetshape .` Guided: answer=Answer .NET project graph, inherited MSBuild properties, package references, and diagnostics. trust=Trust static XML parsing without invoking MSBuild; generated/effective values are labeled. next=Use asmref diagnose on built assemblies. | Use config to inspect Directory.Build or props files directly. +unlock: Identify file lockers and perform unlock-aware file actions on Windows. Better than: ad-hoc PowerShell probing when a file is busy, locked, or refuses delete, move, rename, or copy. Usage: `unlock [OPTIONS] who | unlock [OPTIONS] free | unlock [OPTIONS] move | unlock [OPTIONS] rename | unlock [OPTIONS] delete | unlock [OPTIONS] copy ` Example: `unlock who .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json | Select-Object -ExpandProperty results` Guided: answer=Answer which processes lock files and perform unlock-aware copy/move/delete actions. trust=Trust restart-manager/handle-scan evidence and post-action filesystem checks. next=Use proctree on blocker PIDs. | Use recent after cleanup to confirm generated artifacts. diff --git a/docs/ai/toolbox-ai-prompt-notes.json b/docs/ai/toolbox-ai-prompt-notes.json new file mode 100644 index 0000000..c268800 --- /dev/null +++ b/docs/ai/toolbox-ai-prompt-notes.json @@ -0,0 +1,777 @@ +{ + "title": "Mercury Toolbox AI Prompt", + "overview": [ + "Mercury Toolbox is a set of small, focused Windows and PowerShell-friendly CLI tools for fast local inspection, transformation, and triage.", + "The toolbox is optimized for AI-assisted terminal work: compact text by default, stable machine-readable output through `--json`, native model-readable TOON through `--toon`, and easy piping between commands.", + "Prefer these tools when you need a narrow binary that explains a local situation quickly without opening a heavy UI or dumping unnecessary text." + ], + "selection_rules": [ + "Prefer Mercury first when the task is local inspection, shaping, or triage.", + "Prefer compact text. Use `--json` when the next step parses output, and use `--toon` or `--format toon` when structured output is meant for an AI/model to read.", + "Pipe external JSON into `toon` only when the producer is not a Mercury tool; TOON auto-detects JSON and emits denser TOON by default.", + "Set `$env:MERCURY_OUTPUT='json'` or `$env:MERCURY_OUTPUT='toon'` when most commands in the session should default to that structured format.", + "Prefer stdin and pipelines over re-reading large files.", + "Prefer `fileprobe`, `outline`, `snip`, `chunkcat`, `hitsnip`, `defsnip`, `codeshape`, `refs`, and `ctxpack` over raw `Get-Content`, `cat`, or whole-file dumps.", + "Every tool has guided triage metadata in the generated prompt and skill catalog; PE deep-analysis tools also emit runtime `answer=`, `trust`, `report_quality`, and `next_actions` fields.", + "For every command, read the generated `guided_triage` answer, trust basis, and next actions before inventing a new path.", + "When you are unsure which command to run next, prefer the emitted `next_actions` before inventing an ad-hoc pipeline.", + "Use these tools with modern CLI companions such as `rg`, `fd`, `jq`, `yq`, `bat`, `sd`, `xh`, `tokei`, `eza`, `procs`, `dust`, `hyperfine`, `hexyl`, `zoxide`, `lazygit`, `delta`, and PowerShell.", + "Prefer `mhash` for local hashing, OpenHashTab-class algorithm discovery, JSONL manifest generation and verification, and quick hash throughput benches; pair its JSON/JSONL output with `jq` and compare external command timing with `hyperfine` when needed.", + "Prefer `rg` over recursive `grep` or `Select-String`, `fd` over recursive `Get-ChildItem`, `bat` over raw `Get-Content`, `jq` or `yq` over manual JSON or YAML parsing, and `xh` over `curl` for quick HTTP checks." + ], + "tools": { + "cjson": { + "why": "pretty multi-line JSON when you need compact stable output and optional key sorting.", + "use_when": "Compact JSON or JSONL before piping, diffing, or model input.", + "guided_triage": { + "answer": "Answer whether JSON/JSONL can be compacted cleanly and deterministically.", + "trust": "Trust the JSON parser result and document count; invalid JSON is reported before compaction.", + "next_actions": [ + "Run jsonshape on the compacted payload when you need schema shape.", + "Pipe to jq or toon for projection/model handoff." + ] + } + }, + "ison": { + "why": "verbose JSON when a deterministic compact ISON representation is easier to scan or diff.", + "use_when": "Convert between JSON and ISON records for compact structured handoff.", + "prompt_example": "ison .\\fixtures\\json-family\\ison\\users.json", + "guided_triage": { + "answer": "Answer whether JSON can round-trip through compact ISON for handoff.", + "trust": "Trust successful parser/encoder output; use JSON mode when a tool must consume it next.", + "next_actions": [ + "Use isonl for line-oriented streams.", + "Use toon when the next consumer is an AI model." + ] + } + }, + "isonl": { + "why": "manual JSONL splitting when line-oriented ISON records are better for streaming or model input.", + "use_when": "Convert between JSONL and ISONL line records.", + "prompt_example": "isonl --from jsonl --to isonl .\\fixtures\\json-family\\ison\\users.isonl", + "guided_triage": { + "answer": "Answer whether JSONL/ISONL records can be converted as a stream.", + "trust": "Trust per-line parser diagnostics and document counts; malformed lines stop the conversion.", + "next_actions": [ + "Pipe into jsonlgrep for filtering.", + "Use cjson or toon for downstream compact handoff." + ] + } + }, + "zon": { + "why": "hand-written compact table encodings when you need Zero Overhead Notation interop without Node tooling.", + "use_when": "Convert JSON or JSONL to Zero Overhead Notation and back.", + "prompt_example": "zon .\\fixtures\\json-family\\zon\\person.json", + "guided_triage": { + "answer": "Answer whether JSON/JSONL can convert to or from Zero Overhead Notation.", + "trust": "Trust parser diagnostics and wrapper metadata for document count and target format.", + "next_actions": [ + "Use jsonshape after decoding to validate shape.", + "Use toon for model-facing structured summaries." + ] + } + }, + "tonl": { + "why": "one-off scripts for compact JSON ETL, validation, query, indexing, or streaming filters.", + "use_when": "Encode, decode, query, validate, index, stream-query, and ETL JSON-backed TONL data.", + "prompt_example": "tonl query --where active=true .\\fixtures\\json-family\\tonl\\records.tonl", + "guided_triage": { + "answer": "Answer filtered TONL records, validation status, indexes, or ETL conversion results.", + "trust": "Trust explicit validation/query diagnostics; streaming mode reports record-level failures.", + "next_actions": [ + "Use jsonlgrep for simple JSONL filtering before TONL conversion.", + "Use cjson or toon for compact handoff." + ] + } + }, + "jsonlgrep": { + "why": "ad-hoc `jq` for simple field filters, projections, counts, and regex matches.", + "use_when": "Filter JSONL logs, single-line JSON arrays, or structured event streams by field.", + "prompt_example": "recent --root . --limit 20 --json | jsonlgrep 'path~=LogOutput' --pick modified_rfc3339,path --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer which JSONL records match field, regex, or projection filters.", + "trust": "Trust parsed JSON records and explicit malformed-line diagnostics.", + "next_actions": [ + "Use jsonshape on matched records to inspect structure.", + "Use toon or cjson for compact AI handoff." + ] + } + }, + "mhash": { + "why": "PowerShell Get-FileHash loops, GUI hash tabs, ad-hoc checksum scripts, or manual benchmark harnesses when you need OpenHashTab-class algorithm coverage, manifests, verification, JSON/JSONL, and throughput telemetry in one local CLI.", + "use_when": "Compute local file hashes across the OpenHashTab-class matrix, list algorithms, hash with `--all` or explicit multi-algorithm sets, produce JSONL manifests, verify manifests, or benchmark hash throughput.", + "prompt_example": "mhash --algorithm sha256,blake3-256,xxh3-128 .\\dist\\MercuryToolbox.zip --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer file identity, checksum, manifest verification, or hash throughput questions.", + "trust": "Trust algorithm labels, manifest verification status, and per-file error rows.", + "next_actions": [ + "Use fileprobe first when the file type is unknown.", + "Use jsonlgrep or jq to filter manifest rows." + ] + } + }, + "jsonshape": { + "why": "opening raw payloads when you first need the shape, key paths, or a schema diff.", + "use_when": "Summarize JSON or JSONL into path/type stats, or diff two shape reports.", + "guided_triage": { + "answer": "Answer the key paths, types, counts, and shape differences in JSON/JSONL.", + "trust": "Trust reported path/type counts when parsing succeeds; review diff diagnostics for drift.", + "next_actions": [ + "Use jsonlgrep to isolate records before reshaping.", + "Use cjson to normalize payloads before comparing." + ] + } + }, + "recent": { + "why": "manual directory scans when you just need the freshest files and `.gitignore` awareness.", + "use_when": "List recently changed files or directories.", + "guided_triage": { + "answer": "Answer which files or directories changed recently.", + "trust": "Trust timestamp filters and gitignore-aware discovery within the selected root.", + "next_actions": [ + "Pipe paths into fileprobe or outline.", + "Use hitsnip or ctxpack after narrowing files." + ] + } + }, + "pathshadow": { + "why": "guessing PATH order when the wrong executable is being picked.", + "use_when": "Show which executable wins and which ones are shadowed.", + "prompt_example": "pathshadow python npm --shell powershell --summary --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer which executable wins on PATH and which candidates are shadowed.", + "trust": "Trust resolved filesystem candidates and shell-aware summary rows.", + "next_actions": [ + "Use sysshape --group shell for broader environment context.", + "Use argv quote when command invocation quoting is suspicious." + ] + } + }, + "portping": { + "why": "jumping between multiple network tools for quick TCP and HTTP reachability checks.", + "use_when": "Probe ports or health endpoints with timings.", + "guided_triage": { + "answer": "Answer whether a TCP/HTTP/HTTPS endpoint is reachable and how long it took.", + "trust": "Trust direct connection/status timing; failures distinguish DNS, connect, and HTTP states.", + "next_actions": [ + "Use portunlock when a local port is unexpectedly occupied.", + "Use await port/http to wait for readiness." + ] + } + }, + "portunlock": { + "why": "guessing which process owns a port or hand-writing `netstat` and kill loops when a local listener refuses to go away.", + "use_when": "Show and free local TCP or UDP port owners on Windows.", + "guided_triage": { + "answer": "Answer which process owns a local TCP/UDP port and optionally free it.", + "trust": "Trust OS port snapshots and post-action verification rows.", + "next_actions": [ + "Use proctree on the owning PID before freeing.", + "Use portping after freeing or restarting a listener." + ] + } + }, + "msudo": { + "why": "guessing whether Windows elevation relay support is available, or hand-writing `Start-Process -Verb RunAs` wrappers when you first need the current privilege status.", + "use_when": "Top-level high-risk command: inspect Windows elevation relay status, token state, active session state, and host availability before any privileged launch.", + "prompt_example": "msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated", + "guided_triage": { + "answer": "Answer Windows elevation, token, relay, and launch-shape questions.", + "trust": "Trust status discovery before launch; treat run actions as high risk and explicit.", + "next_actions": [ + "Start with msudo status --json.", + "Use argv quote before privileged shell wrapping." + ] + } + }, + "asmtype": { + "why": "manual reflection or ad-hoc PowerShell when you first need the type map from a managed assembly.", + "use_when": "List managed assembly types and filter them by name, namespace, base type, interface, or matching members, with optional user-code-only screening.", + "prompt_example": "asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --with-member-match 'Build|Launch' --show-matched-members --pick full_name,base_type,matched_members --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer which managed types match name, inheritance, interface, or member intent.", + "trust": "Trust metadata enumeration from the target assembly; broaden filters when no matches return.", + "next_actions": [ + "Pipe JSONL into asmmember.", + "Use asmflow find/xref when behavior matters." + ] + } + }, + "asmmember": { + "why": "long reflection scripts when you need methods, fields, and properties from a known managed type.", + "use_when": "Inspect managed type members with binding filters and compact signatures, including JSONL handoff from `asmtype` and user-code-only filtering.", + "prompt_example": "asmtype .\\fixtures\\managed\\bin\\GameAssembly.dll --intent unity-spacecraft-workflow --json | asmmember --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll --input-format jsonl --intent unity-spacecraft-workflow --pick type_name,name,visibility,signature --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer which methods, fields, and properties exist on selected managed types.", + "trust": "Trust assembly metadata and binding filters; use non-public filters intentionally.", + "next_actions": [ + "Use asmflow body for IL details.", + "Use asmapi diff when comparing versions." + ] + } + }, + "asmref": { + "why": "guessing missing managed dependencies when you need a fast reference, resolution, or Unity/Rocket plugin closure diagnosis pass.", + "use_when": "Inspect managed assembly references, check whether they resolve from local directories, or run `diagnose` to report closure risks such as missing references, same-name version/token conflicts, winning DLLs, test-only leaks, and obvious MissingMethod/TypeLoad hazards.", + "prompt_example": "asmref diagnose .\\fixtures\\managed\\bin\\GameAssembly.dll --resolve-dir .\\fixtures\\managed\\bin --format toon", + "guided_triage": { + "answer": "Answer managed references, resolution status, and dependency closure risks.", + "trust": "Trust resolved candidates and risk tiers; explicit resolve dirs define the runtime universe.", + "next_actions": [ + "Run asmref diagnose for closure risks.", + "Use asmapi diff on risky version changes." + ] + } + }, + "asmapi": { + "why": "manual reflection diffs or brittle spreadsheet comparisons when you need to know what managed API changed between two DLL versions.", + "use_when": "Compare two managed assemblies for added or removed public types, removed methods, signature changes, and MissingMethodException risks, with optional internal/all visibility.", + "prompt_example": "asmapi diff .\\old\\0Harmony.dll .\\new\\0Harmony.dll --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer public API differences between managed assemblies.", + "trust": "Trust metadata comparison under the chosen visibility scope.", + "next_actions": [ + "Use asmref diagnose to connect API drift to dependency closure.", + "Use asmmember on removed or changed types." + ] + } + }, + "asmflow": { + "why": "ad-hoc IL dump scripts or manual dnSpy browsing when you need callers, callees, field access, or string literals from a managed method quickly.", + "use_when": "Inspect managed method bodies, IL summaries, and cross-references for Unity and general .NET reverse-engineering flows.", + "prompt_example": "asmflow xref --assembly .\\fixtures\\managed\\bin\\GameAssembly.dll 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer managed IL bodies, callers, callees, field access, and string references.", + "trust": "Trust method-body metadata when the target method resolves; unknown bodies are reported.", + "next_actions": [ + "Use asmtype/asmmember to find exact targets.", + "Use ctxpack to hand off IL evidence." + ] + } + }, + "llvmobjdump": { + "why": "raw llvm-objdump text when you need compact section, symbol, and disassembly shape for COFF/PE or object files.", + "use_when": "Inspect object sections, symbol labels, and disassembly through LLVM backend discovery.", + "prompt_usage": "llvmobjdump [OPTIONS] [PATH...]", + "prompt_example": "llvmobjdump .\\target\\release-fast\\binmeta.exe --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer object sections, symbols, and disassembly shape through LLVM.", + "trust": "Trust LLVM backend output plus parser warnings/raw blocks for unsupported regions.", + "next_actions": [ + "Use llvmreadobj for headers/imports/debug hints.", + "Use pecalls or pesig for PE-focused call/function triage." + ] + } + }, + "llvmreadobj": { + "why": "heavy binary viewers or raw llvm-readobj output when you need a bounded COFF/PE header, import/export, and debug/PDB summary.", + "use_when": "Inspect COFF/PE headers, sections, imports, exports, and debug hints through LLVM backend discovery.", + "prompt_usage": "llvmreadobj [OPTIONS] [PATH...]", + "prompt_example": "llvmreadobj .\\target\\release-fast\\binmeta.exe --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer COFF/PE headers, sections, imports, exports, and debug hints through LLVM.", + "trust": "Trust LLVM backend exit status and parsed blocks; raw blocks preserve unparsed details.", + "next_actions": [ + "Use peimports/peexports for PE-focused grouping.", + "Use llvmobjdump when code layout or disassembly is needed." + ] + } + }, + "llvmnm": { + "why": "raw symbol-table dumps when you need stable symbol rows with kind, address, size, object/archive prefix, and source-line hints.", + "use_when": "List and classify object symbols through LLVM nm output.", + "prompt_usage": "llvmnm [OPTIONS] [PATH...]", + "prompt_example": "llvmnm .\\target\\release-fast\\binmeta.exe --defined-only --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer defined/undefined symbols with stable LLVM nm rows.", + "trust": "Trust POSIX-format symbol parsing and backend stderr tails.", + "next_actions": [ + "Use llvmobjdump for disassembly around symbols.", + "Use llvmreadobj for object headers and debug hints." + ] + } + }, + "peexports": { + "why": "manual PE export-table browsing when you want names, ordinals, RVA/VA, forwarders, and guided follow-up commands without opening a heavy PE debugger.", + "use_when": "Inspect export surfaces, spot forwarded symbols, filter by name or ordinal, and use `report_quality`/`next_actions` to decide the next PE pass.", + "prompt_usage": "peexports [OPTIONS] [PATH...]", + "prompt_example": "peexports C:\\Windows\\System32\\kernel32.dll --forwarders-only --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer PE export names, ordinals, RVAs/VAs, forwarders, and entrypoint hints.", + "trust": "Trust PE export table parsing plus report_quality evidence and limitations.", + "next_actions": [ + "Use peimports to inspect dependencies.", + "Use llvmreadobj --exports to cross-check LLVM output." + ] + } + }, + "peimports": { + "why": "ad-hoc import-table triage when you need API families grouped into human-friendly categories with immediate callsite and role follow-ups.", + "use_when": "Inspect imports by library, name, or category, quickly separate filesystem, registry, device I/O, NT, and network APIs, then follow emitted `next_actions`.", + "prompt_usage": "peimports [OPTIONS] [PATH...]", + "prompt_example": "peimports .\\target\\release-fast\\portping.exe --category device_io --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer imported APIs grouped by library and API category.", + "trust": "Trust PE import table parsing plus report_quality evidence and limitations.", + "next_actions": [ + "Use pecalls on interesting categories.", + "Use drvshape for driver-like/native profiles." + ] + } + }, + "pecalls": { + "why": "guessing import callsites from raw disassembly when you want direct IAT matches plus trust metadata around best-effort disassembly parsing.", + "use_when": "Extract callsites for one or more imported APIs or API categories from LLVM objdump text and inspect `report_quality.limitations` before treating it as exhaustive.", + "prompt_usage": "pecalls [OPTIONS] [PATH...]", + "prompt_example": "pecalls .\\target\\release-fast\\portping.exe --api WSAStartup --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer direct imported API callsites found through LLVM disassembly.", + "trust": "Trust direct IAT-target matches; report_quality limitations mark disassembly truncation or gaps.", + "next_actions": [ + "Use peimports to choose categories.", + "Use pesig to place callsites inside functions." + ] + } + }, + "pesig": { + "why": "manual function-boundary and calling-convention inference when you need a quick v1 signal with explicit confidence and next-step context.", + "use_when": "Infer function boundaries, source hints, and calling conventions from PE text and metadata, then pivot to imports, callsites, or raw objdump via `next_actions`.", + "prompt_usage": "pesig [OPTIONS] [PATH...]", + "prompt_example": "pesig .\\target\\release-fast\\portping.exe --min-confidence medium --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer likely PE function boundaries and calling-convention hints.", + "trust": "Trust confidence/evidence per function and report_quality for parse completeness.", + "next_actions": [ + "Use pecalls to connect functions to APIs.", + "Use llvmobjdump for raw disassembly context." + ] + } + }, + "pestrrefs": { + "why": "opening a disassembler and a string viewer separately when you need string hits with direct xrefs and a clear next triage command.", + "use_when": "Scan ASCII or UTF-16LE strings, report direct references from LLVM disassembly, and use `next_actions` to connect strings to imports, callsites, or IOCTL constants.", + "prompt_usage": "pestrrefs [OPTIONS] [PATH...]", + "prompt_example": "pestrrefs .\\target\\release-fast\\portping.exe --contains DeviceIoControl --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer strings and direct code references in PE files.", + "trust": "Trust direct VA/range xrefs; unresolved counts are not guessed.", + "next_actions": [ + "Use peimports to connect strings to APIs.", + "Use ioctlscan for device-control constants." + ] + } + }, + "drvshape": { + "why": "guessing the driver role from imports and exports when you want a compact Windows driver profile with IOCTL/import/callsite follow-ups already suggested.", + "use_when": "Summarize likely WDM, KMDF, NDIS, minifilter, or native driver traits, surface heuristic driver hints, and treat `report_quality` as the trust boundary.", + "prompt_usage": "drvshape [OPTIONS] [PATH...]", + "prompt_example": "drvshape C:\\Windows\\System32\\drivers\\ndis.sys --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer likely Windows driver role, subsystem, imports, exports, and heuristic hints.", + "trust": "Trust role hints as heuristics, not vulnerability conclusions; report_quality shows evidence.", + "next_actions": [ + "Use ioctlscan to decode CTL_CODE candidates.", + "Use pecalls --category device_io for dispatch/callsite evidence." + ] + } + }, + "ioctlscan": { + "why": "searching for Windows IOCTL constants by hand when you want decoded CTL_CODE fields, code locations, and suggested role/callsite/string follow-ups.", + "use_when": "Scan PE bytes and disassembly-adjacent data for likely IOCTL constants, decode their fields, and use `report_quality` to separate strong hits from heuristic candidates.", + "prompt_usage": "ioctlscan [OPTIONS] [PATH...]", + "prompt_example": "ioctlscan C:\\Windows\\System32\\drivers\\ndis.sys --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer probable IOCTL constants and decoded CTL_CODE fields.", + "trust": "Trust confidence/evidence per candidate; raw byte hits are heuristic until tied to code context.", + "next_actions": [ + "Use drvshape to confirm driver role.", + "Use pestrrefs or pecalls for surrounding evidence." + ] + } + }, + "binmeta": { + "why": "opening heavy PE tooling or shelling out to signature utilities when you first need binary identity, PE basics, version resources, and Windows signing status.", + "use_when": "Inspect file metadata, Windows PE basics, version/company/product identity, embedded Authenticode certificate tables, WinTrust status, catalog signing, signer certificate subjects, chains, timestamps, and expiry hints.", + "guided_triage": { + "answer": "Answer file identity, PE basics, version resource fields, company/product names, signature status, embedded-vs-catalog signing, signer subject, certificate chain, timestamp, and expiry fields before deeper binary analysis.", + "trust": "Trust goblin PE/resource parsing for local structure and Windows WinTrust/catalog status on Windows; signature failures are reported as metadata, not command failures. On non-Windows, trust only embedded certificate table presence and treat WinTrust fields as unsupported.", + "next_actions": [ + "Use peimports or llvmreadobj for deeper PE detail after identity is known.", + "Use drvshape or ioctlscan for signed drivers and native/system binaries.", + "Use stringscan or pestrrefs when identity/version metadata is missing or suspicious." + ] + } + }, + "fileprobe": { + "why": "blindly opening files when you need a fast read-worthiness, encoding, or newline check first.", + "use_when": "Classify files as text, binary, generated, minified, test, vendor, and more, including encoding, BOM, and newline diagnostics.", + "guided_triage": { + "answer": "Answer whether a path is text, binary, generated, minified, vendor, or worth opening.", + "trust": "Trust bounded sniffing, encoding, newline, and classification diagnostics.", + "next_actions": [ + "Use outline or chunkcat for readable text.", + "Use binmeta or stringscan for binary files." + ] + } + }, + "outline": { + "why": "scrolling whole files when you only need the structure.", + "use_when": "List top-level code or config structure.", + "guided_triage": { + "answer": "Answer top-level structure of source or config files.", + "trust": "Trust parser-supported outlines and explicit unsupported-file diagnostics.", + "next_actions": [ + "Use defsnip for exact definitions.", + "Use refs or codeshape for broader symbol maps." + ] + } + }, + "codeshape": { + "why": "plain `tree` output when you need internal declarations and signatures without dumping full source files.", + "use_when": "Map a codebase into files plus AST-backed declarations and signatures from the shared `codeindex` engine.", + "guided_triage": { + "answer": "Answer repository file/declaration shape with AST-backed summaries.", + "trust": "Trust codeindex parser results and skipped-file diagnostics.", + "next_actions": [ + "Use defsnip to extract definitions.", + "Use refs to find use sites." + ] + } + }, + "refs": { + "why": "falling back to raw `rg` when you need exact use-sites or caller summaries for a known symbol.", + "use_when": "Find exact symbol references or caller definitions through the shared `codeindex` engine with text fallback.", + "guided_triage": { + "answer": "Answer symbol references or callers across source files.", + "trust": "Trust codeindex hits and caller grouping under the selected roots.", + "next_actions": [ + "Use hitsnip to expand hit context.", + "Use defsnip for definition bodies." + ] + } + }, + "snip": { + "why": "dumping whole files when you only need exact lines, matches, or symbols.", + "use_when": "Extract precise snippets from files or stdin.", + "guided_triage": { + "answer": "Answer exact bounded source/text snippets by line, match, or symbol.", + "trust": "Trust line numbers and bounded extraction; binary or missing paths are rejected.", + "next_actions": [ + "Use hitsnip after rg for multiple hits.", + "Use ctxpack to bundle snippets." + ] + } + }, + "defsnip": { + "why": "line-number snippets when you need the full function, class, or method body even after nearby edits move the lines.", + "use_when": "Extract full AST-backed definitions by exact symbol name through the shared `codeindex` engine.", + "guided_triage": { + "answer": "Answer full AST-backed definition blocks for symbols.", + "trust": "Trust codeindex definitions and language support; no match means broaden symbol/root.", + "next_actions": [ + "Use refs for callers/use sites.", + "Use ctxpack to package the definition with nearby context." + ] + } + }, + "ctxpack": { + "why": "hand-assembling ad-hoc prompt context from files, hits, diagnostics, and definitions.", + "use_when": "Pack files, snippets, hits, diagnostics, or definition JSON into compact context blocks.", + "guided_triage": { + "answer": "Answer compact prompt-ready context from files, snippets, hits, and diagnostics.", + "trust": "Trust included path and byte/line caps; review omitted/truncated sections for scope.", + "next_actions": [ + "Use fileprobe/codeshape before packing broad roots.", + "Use diagpick or hitsnip to feed focused evidence." + ] + } + }, + "chunkcat": { + "why": "huge file dumps when you need deterministic windows.", + "use_when": "Read large text files in bounded chunks, including quick tail-style log windows.", + "prompt_example": "chunkcat .\\BepInEx\\LogOutput.log --max-lines 20 --tail", + "guided_triage": { + "answer": "Answer a bounded chunk or inventory of a large text file.", + "trust": "Trust chunk indices, line ranges, and binary detection.", + "next_actions": [ + "Use snip when you know the exact line range.", + "Use hitsnip to expand search hits into chunks." + ] + } + }, + "hitsnip": { + "why": "raw `rg` hit lists when you need merged readable context or the enclosing definition instead of a tiny window.", + "use_when": "Turn search hits into compact snippets or promote them to enclosing definitions.", + "prompt_example": "rg -nH \"helper\" .\\fixtures\\polyglot\\repo\\src\\lib.rs | hitsnip --def", + "guided_triage": { + "answer": "Answer merged snippets around rg-style search hits.", + "trust": "Trust hit parsing and merge ranges; malformed hit lines are reported.", + "next_actions": [ + "Use rg -nH to feed hits.", + "Use defsnip when the hit is inside a known symbol." + ] + } + }, + "diagpick": { + "why": "reading full logs when you need the actionable errors first and want direct source or definition context.", + "use_when": "Extract compiler or runtime diagnostics from noisy logs, with optional snippets and enclosing definitions.", + "prompt_example": "diagpick .\\fixtures\\diag\\rust-errors.txt --def", + "guided_triage": { + "answer": "Answer actionable diagnostics extracted from compiler/build/log text.", + "trust": "Trust recognized diagnostic formats and preserved source context.", + "next_actions": [ + "Use snip on referenced files/lines.", + "Use runprobe to capture the failing command reproducibly." + ] + } + }, + "logshape": { + "why": "reading repetitive logs line by line when you need the dominant patterns.", + "use_when": "Group recurring log lines into templates.", + "guided_triage": { + "answer": "Answer repeated log templates, incidents, and message clusters.", + "trust": "Trust grouping counts and examples; rare lines may stay as individual templates.", + "next_actions": [ + "Use diagpick for actionable errors.", + "Use jsonlgrep when logs are structured JSONL." + ] + } + }, + "stringscan": { + "why": "a full reverse-engineering pass when you first need identity clues from strings.", + "use_when": "Extract and classify high-signal strings from binaries or blobs.", + "guided_triage": { + "answer": "Answer strings and classified hints from arbitrary blobs.", + "trust": "Trust extracted string offsets and classification labels; encoding limits are explicit.", + "next_actions": [ + "Use binmeta first for binary identity.", + "Use pestrrefs for PE strings with xrefs." + ] + } + }, + "toon": { + "why": "verbose JSON when you want a denser structured text form.", + "use_when": "Convert between JSON and TOON.", + "guided_triage": { + "answer": "Answer compact model-readable TOON from JSON/JSONL or decode TOON back.", + "trust": "Trust parser/encoder diagnostics and document wrapper metadata.", + "next_actions": [ + "Use native --toon on Mercury producers when available.", + "Use cjson/jsonshape before converting questionable JSON." + ] + } + }, + "csvshape": { + "why": "opening huge CSV or TSV files when you first need the columns, rough types, blanks, example values, or a shape diff.", + "use_when": "Summarize large CSV or TSV files into compact schema/bounded stats, or diff two CSV shapes.", + "guided_triage": { + "answer": "Answer CSV/TSV delimiter, columns, row counts, and schema differences.", + "trust": "Trust parsed rows under the detected or requested delimiter.", + "next_actions": [ + "Use jsonshape after converting CSV to JSON externally.", + "Use config or sqlshape when CSV describes configuration/data imports." + ] + } + }, + "sqliteshape": { + "why": "dropping into an interactive SQLite shell when you only need schema, sample rows, light table stats, or a shape diff.", + "use_when": "Inspect SQLite databases read-only with compact schema/table summaries, or diff two SQLite shapes.", + "guided_triage": { + "answer": "Answer SQLite tables, columns, indexes, triggers, and schema diffs.", + "trust": "Trust SQLite catalog queries against the selected database file.", + "next_actions": [ + "Use sqlshape for normalized cross-engine reports.", + "Use jsonshape/csvshape on exported data samples." + ] + } + }, + "sqlshape": { + "why": "hand-writing per-engine catalog queries or exposing raw connection strings when you need a normalized cross-database schema report or diff.", + "use_when": "Inspect PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, or DuckDB schemas read-only with redacted connection sources and normalized cross-engine diffs.", + "prompt_example": "sqlshape --engine sqlite --url .\\fixtures\\sqliteshape\\sample.db --json | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer normalized SQL schema across SQLite, PostgreSQL, MySQL/MariaDB, SQL Server, or DuckDB.", + "trust": "Trust engine-specific introspection and redacted connection reporting.", + "next_actions": [ + "Use sqliteshape for SQLite-specific detail.", + "Use config to inspect connection/config files safely." + ] + } + }, + "unityasset": { + "why": "opening a heavy Unity GUI tool when you first need an index, object refs, field dump, or a quick extraction path for assets, scripts, audio, or video.", + "use_when": "Inspect Unity serialized assets, bundles, and YAML objects, then trace refs, dump fields, or extract payloads such as TextAsset, MonoScript, AudioClip, and VideoClip.", + "guided_triage": { + "answer": "Answer Unity asset indexes, object dumps, references, and extraction writes.", + "trust": "Trust parsed serialized files/bundles and explicit unresolved reference rows.", + "next_actions": [ + "Use unitydiag for runtime/log symptoms.", + "Use asmtype/asmflow for managed script assemblies." + ] + } + }, + "unityprobe": { + "why": "hand-writing BepInEx reflection plugins or one-off named-pipe helpers when you only need a read-only runtime snapshot from a Mono Unity game.", + "use_when": "Install and query a read-only BepInEx bridge for live scenes, objects, and static state in a running Mono Unity game on Windows.", + "guided_triage": { + "answer": "Answer read-only Unity runtime bridge status, object search, and inspection.", + "trust": "Trust explicit bridge install/status and Windows named-pipe reachability.", + "next_actions": [ + "Use unitydiag when runtime logs explain bridge issues.", + "Use unityasset for offline assets." + ] + } + }, + "unitydiag": { + "why": "reading raw Player.log and BepInEx logs end to end when you need the repeated incidents, top frames, and mod-vs-framework blame split first.", + "use_when": "Summarize Unity Player.log and BepInEx logs into grouped incidents, normalized messages, and domain-aware failure summaries.", + "guided_triage": { + "answer": "Answer Unity/BepInEx log incidents, grouped frames, and mod/runtime warnings.", + "trust": "Trust discovered log paths, grouping counts, and preserved stack snippets.", + "next_actions": [ + "Use asmref diagnose for plugin dependency issues.", + "Use unityprobe when live object state is needed." + ] + } + }, + "envdiff": { + "why": "hand-comparing environment variables before and after a script, especially around PATH pollution and batch wrappers.", + "use_when": "Snapshot, diff, or run commands while reporting environment-variable changes.", + "prompt_usage": "envdiff [OPTIONS] run --shell cmd|pwsh|raw -- ", + "prompt_example": "envdiff run --shell cmd -- .\\scripts\\set-env.cmd", + "guided_triage": { + "answer": "Answer environment variable changes before/after a command or between snapshots.", + "trust": "Trust captured before/after maps and PATH segment deltas.", + "next_actions": [ + "Use pathshadow after PATH changes.", + "Use sysshape for broader shell/tool inventory." + ] + } + }, + "proctree": { + "why": "flat process lists when you need parent-child context, short command lines, and lingering orphan hints.", + "use_when": "Inspect whole-system, rooted, matched, or command-run Windows process trees in AI-friendly form; use --match to filter by image name or command line.", + "prompt_usage": "proctree [OPTIONS] system|root |run -- ", + "prompt_example": "proctree system --match 'pwsh|Mercury' --include-cmdline", + "guided_triage": { + "answer": "Answer process trees, rooted processes, and command ancestry.", + "trust": "Trust OS process snapshots at capture time; short-lived processes may disappear.", + "next_actions": [ + "Use portunlock for port owners.", + "Use runprobe to capture command execution shape." + ] + } + }, + "sysshape": { + "why": "asking users ad-hoc setup questions or checking versions one tool at a time when you need a compact local machine inventory first.", + "use_when": "Capture a compact local system and tool inventory for AI-friendly triage, including shell hints, path-like duplication, and probe status.", + "prompt_example": "sysshape --json --env safe --group shell | ConvertFrom-Json", + "guided_triage": { + "answer": "Answer machine, shell, environment, and tool inventory shape.", + "trust": "Trust detected command paths/versions and grouped environment metadata.", + "next_actions": [ + "Use pathshadow for a specific executable winner.", + "Use envdiff around commands that mutate the environment." + ] + } + }, + "runprobe": { + "why": "reading fragile shell text when what you really need is the stable result object for one command run.", + "use_when": "Run one command and capture exit code, duration, timeout status, and bounded output tails.", + "guided_triage": { + "answer": "Answer command exit code, duration, stdout/stderr tails, and timeout behavior.", + "trust": "Trust captured process result and bounded output tails.", + "next_actions": [ + "Use diagpick on captured stderr/stdout.", + "Use envdiff run when environment mutation matters." + ] + } + }, + "await": { + "why": "writing ad-hoc polling loops for files, ports, HTTP endpoints, or repeat-until-success commands.", + "use_when": "Wait for a path, TCP target, HTTP endpoint, or command condition with bounded polling.", + "guided_triage": { + "answer": "Answer whether a path, port, HTTP endpoint, or command became ready before timeout.", + "trust": "Trust polling attempts, final state, and timeout status.", + "next_actions": [ + "Use portping for one-shot endpoint detail.", + "Use runprobe for command readiness probes." + ] + } + }, + "argv": { + "why": "guessing how PowerShell or cmd really passed arguments to a native process.", + "use_when": "Quote argv for `pwsh` or `cmd`, or inspect what a shell actually passed to a native process.", + "guided_triage": { + "answer": "Answer how arguments should be quoted or how a shell actually passes them.", + "trust": "Trust helper round-trips for the selected shell/platform.", + "next_actions": [ + "Use runprobe to execute the quoted command.", + "Use msudo status before privileged shell wrapping." + ] + } + }, + "config": { + "why": "fighting `jq` or hand-editing config files when you need safe slash-pointer reads or edits across common formats.", + "use_when": "Read, inspect, list, set, or delete config values in JSON, YAML, TOML, env, and INI files.", + "prompt_usage": "config [OPTIONS] [PATH] | config [OPTIONS] [POINTER] [VALUE]", + "prompt_example": "config get .\\package.json /version", + "guided_triage": { + "answer": "Answer get/set/delete operations over JSON/YAML/TOML/env/INI config paths.", + "trust": "Trust parser-specific diagnostics and structured edit reports.", + "next_actions": [ + "Use fileprobe before editing unknown config files.", + "Use jsonshape after JSON config changes." + ] + } + }, + "gitshape": { + "why": "reading raw `git status` and diff noise when you first need compact status counters or changed-file summaries.", + "use_when": "Summarize Git status or diff shape into concise AI-friendly file, line, and touched-symbol reports.", + "guided_triage": { + "answer": "Answer git status, branch, diff, and repository change shape.", + "trust": "Trust git command output and explicit revision/path filters.", + "next_actions": [ + "Use reposhape to connect git changes to ecosystems.", + "Use ctxpack or hitsnip for changed-file handoff." + ] + } + }, + "reposhape": { + "why": "manually browsing an unfamiliar repo when you first need the ecosystems, manifests, commands, and CI entrypoints.", + "use_when": "Detect repository ecosystems, manifests, command entrypoints, and CI hints in one pass.", + "guided_triage": { + "answer": "Answer repository ecosystems, manifests, entrypoints, and project layout.", + "trust": "Trust manifest detection under depth/hidden filters and parse diagnostics.", + "next_actions": [ + "Use codeshape for source declarations.", + "Use dotnetshape/sqlshape/gitshape for ecosystem-specific detail." + ] + } + }, + "dotnetshape": { + "why": "opening a .NET repo one file at a time when you first need project graph shape, shared MSBuild inheritance, package references, and build-shape warnings.", + "use_when": "Inspect .csproj, Directory.Build.props/targets, Directory.Packages.props, project references, package versions, and .NET-specific project graph diagnostics.", + "guided_triage": { + "answer": "Answer .NET project graph, inherited MSBuild properties, package references, and diagnostics.", + "trust": "Trust static XML parsing without invoking MSBuild; generated/effective values are labeled.", + "next_actions": [ + "Use asmref diagnose on built assemblies.", + "Use config to inspect Directory.Build or props files directly." + ] + } + }, + "unlock": { + "why": "ad-hoc PowerShell probing when a file is busy, locked, or refuses delete, move, rename, or copy.", + "use_when": "Identify file lockers and perform unlock-aware file actions on Windows.", + "guided_triage": { + "answer": "Answer which processes lock files and perform unlock-aware copy/move/delete actions.", + "trust": "Trust restart-manager/handle-scan evidence and post-action filesystem checks.", + "next_actions": [ + "Use proctree on blocker PIDs.", + "Use recent after cleanup to confirm generated artifacts." + ] + } + } + } +} diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md new file mode 100644 index 0000000..fe9a25a --- /dev/null +++ b/docs/benchmarks/README.md @@ -0,0 +1,90 @@ +# Benchmark Harness Usage + +This directory stores reproducible benchmark notes and reports for Mercury tools. +The mhash harness lives at `scripts/benchmark-mhash.ps1` and writes generated +artifacts under `target\mhash-benchmark\results` by default. + +## Default Digest Benchmark + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash.ps1 +``` + +The default matrix keeps runtime reasonable: 1MiB, 64MiB, and 256MiB inputs; +MD5, SHA-1, SHA-256, and SHA-512; 1 warmup and 3 measured runs. It compares +Mercury `mhash` auto/read modes against available non-admin external tools. + +## Fast Smoke Check + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash-smoke.ps1 -SkipBuild +``` + +The smoke check runs a 1KiB SHA-256 Mercury-only matrix, validates required +telemetry fields, checks JSONL export counts, and verifies `-SizeOnly` emits +artifact telemetry without process records. + +## Useful Modes + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash.ps1 -SizeOnly -SkipBuild +``` + +Use `-SizeOnly` when you only need binary artifact telemetry. It skips fixture +creation and process benchmarks, but still writes raw JSON, artifact JSON/CSV, +empty summary files, and a Markdown report. + +To inspect a prebuilt size-focused binary, point the harness at it: + +```powershell +cargo build --profile release-size -p mercury-mhash --bin mhash +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash.ps1 -SizeOnly -SkipBuild -DigestPath .\target\release-size\mhash.exe +``` + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash.ps1 -Extended -Hyperfine +``` + +Use `-Extended` for a deeper matrix. Unless explicitly overridden, it raises +the run count to 5, warmups to 2, adds a 1GiB fixture, and benchmarks Mercury +`sum`, `jsonl`, and `json` output formats. Use `-Hyperfine` to add optional +`hyperfine` timing exports when `hyperfine` is available on `PATH`. + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash.ps1 -Sizes 1KiB -Algorithms sha256 -DigestFormats sum,jsonl -Repeat 1 -Warmup 0 -NoExternal -SkipBuild +``` + +Use explicit small matrices for local harness checks or quick regression +triage. + +## Emitted Telemetry + +Each run now records wall time, CPU time, CPU as percent of one core and of the +machine, peak working set, mhash output format, binary path, binary size, and a +short stdout/stderr sample. Summaries include mean wall time, min wall time, +mean CPU milliseconds, throughput, peak working set, CPU percent of one core, +and binary size. + +The raw report includes reproducibility metadata: invocation, script path, +workspace root, OS and process architecture, PowerShell version, logical +processor count, git branch/commit/status entries, fixture SHA-256 hashes, +tool paths/versions, artifact sizes, artifact SHA-256 hashes, and optional +hyperfine suites. + +Generated files include: + +- `*.raw.json`: full environment, records, summary, artifact, and hyperfine data. +- `*.records.jsonl`: one process measurement per line. +- `*.summary.json` and `*.summary.jsonl`: grouped summary rows. +- `*.summary.csv`: spreadsheet-friendly grouped summary rows. +- `*.artifacts.json` and `*.artifacts.csv`: binary size and identity metadata. +- `*.hyperfine.json` and `*.hyperfine.csv`: aggregate optional hyperfine output. +- `*.md`: human-readable report. + +## Measurement Caveats + +Windows does not expose a safe non-admin cache-drop primitive, so results are +warm-cache CLI timings. Very small fixtures mostly measure process startup and +output overhead. Peak working set comes from process telemetry and can miss very +short-lived allocation spikes. Hyperfine is opt-in and complements the built-in +wall/CPU/memory telemetry; it does not replace the harness process metrics. diff --git a/docs/benchmarks/mhash-benchmark-2026-04-25.md b/docs/benchmarks/mhash-benchmark-2026-04-25.md new file mode 100644 index 0000000..3cd1609 --- /dev/null +++ b/docs/benchmarks/mhash-benchmark-2026-04-25.md @@ -0,0 +1,77 @@ +# Digest Benchmark - 2026-04-25 + +This benchmark compares Mercury `mhash` against common Windows and CLI hash tools on the same machine. + +## Method + +- Harness: `scripts/benchmark-mhash.ps1` +- Scenario: warm-cache CLI hashing; Windows does not provide a safe non-admin cache-drop primitive, so cold-cache disk results are intentionally excluded. +- Inputs: deterministic 1MiB, 64MiB, and 256MiB files under `target\mhash-benchmark\data`. +- Algorithms: `md5`, `sha1`, `sha256`, `sha512`. +- Repeats: 1 warmup plus 3 measured runs per tool/algorithm/size. +- Metrics: wall-clock time, throughput, process CPU time as percent of one core, and peak working set. +- Machine: `DESKTOP-EKBHO4B`, Windows `10.0.26200`, 32 logical processors. + +## Tools + +- Mercury `mhash 0.1.0` +- PowerShell `Get-FileHash` via PowerShell `7.6.1` +- Windows `certutil` +- uutils coreutils `0.8.0` commands: `md5sum`, `sha1sum`, `sha256sum`, `sha512sum` +- OpenSSL `3.6.1` + +## Key Finding + +The original Mercury `mhash` default `auto` mode memory-mapped large ordinary hash jobs. On this machine that increased peak working set to roughly the file size and was slower than streaming reads. The default was changed so streaming-friendly hash sets use one-pass buffered reads, while whole-file paths remain available for explicit `--io mmap` and for algorithms that benefit from whole-file/internal parallel processing. + +## 256MiB Before/After + +| Algorithm | Before auto ms | After auto ms | Speedup | Before peak MiB | After peak MiB | Peak drop MiB | +|---|---:|---:|---:|---:|---:|---:| +| md5 | 427.06 | 362.24 | 1.18x | 261.54 | 10.30 | 251.24 | +| sha1 | 201.81 | 163.24 | 1.24x | 255.72 | 10.55 | 245.17 | +| sha256 | 226.63 | 156.31 | 1.45x | 261.05 | 10.29 | 250.76 | +| sha512 | 341.78 | 273.64 | 1.25x | 264.60 | 10.57 | 254.03 | + +## 256MiB Post-Optimization Comparison + +| Algorithm | Tool | Mean ms | MiB/s | Peak WS MiB | CPU % of one core | +|---|---|---:|---:|---:|---:| +| md5 | openssl:dgst | 353.02 | 725.17 | 12.17 | 100.5 | +| md5 | mercury-mhash:auto | 362.24 | 706.71 | 10.30 | 94.9 | +| md5 | mercury-mhash:read | 364.38 | 702.57 | 10.30 | 95.8 | +| md5 | coreutils:md5sum | 377.49 | 678.17 | 9.44 | 96.6 | +| md5 | windows:certutil | 521.46 | 490.93 | 14.71 | 91.9 | +| md5 | powershell:Get-FileHash | 742.67 | 344.70 | 65.20 | 113.7 | +| sha1 | mercury-mhash:read | 155.19 | 1649.56 | 10.54 | 90.6 | +| sha1 | coreutils:sha1sum | 160.77 | 1592.36 | 9.45 | 90.7 | +| sha1 | mercury-mhash:auto | 163.24 | 1568.23 | 10.55 | 98.9 | +| sha1 | openssl:dgst | 196.64 | 1301.90 | 10.98 | 98.0 | +| sha1 | windows:certutil | 398.24 | 642.82 | 14.73 | 99.4 | +| sha1 | powershell:Get-FileHash | 641.07 | 399.33 | 66.26 | 114.5 | +| sha256 | mercury-mhash:read | 155.78 | 1643.35 | 10.29 | 93.6 | +| sha256 | mercury-mhash:auto | 156.31 | 1637.75 | 10.29 | 90.0 | +| sha256 | coreutils:sha256sum | 170.32 | 1503.08 | 10.36 | 97.9 | +| sha256 | openssl:dgst | 206.06 | 1242.36 | 10.99 | 91.0 | +| sha256 | windows:certutil | 270.40 | 946.74 | 14.75 | 92.5 | +| sha256 | powershell:Get-FileHash | 519.56 | 492.72 | 65.04 | 122.3 | +| sha512 | mercury-mhash:auto | 273.64 | 935.52 | 10.57 | 99.0 | +| sha512 | mercury-mhash:read | 273.88 | 934.70 | 10.31 | 93.2 | +| sha512 | openssl:dgst | 277.36 | 922.98 | 10.99 | 99.5 | +| sha512 | coreutils:sha512sum | 299.48 | 854.80 | 9.45 | 93.9 | +| sha512 | windows:certutil | 910.10 | 281.29 | 14.73 | 99.6 | +| sha512 | powershell:Get-FileHash | 1168.26 | 219.13 | 65.48 | 106.6 | + +## Reproduce + +```powershell +just mhash-benchmark +``` + +or: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\benchmark-mhash.ps1 +``` + +The harness writes raw JSON, summary JSON, CSV, and Markdown reports under `target\mhash-benchmark\results`. diff --git a/docs/jade-discipline.md b/docs/jade-discipline.md new file mode 100644 index 0000000..e07a3f0 --- /dev/null +++ b/docs/jade-discipline.md @@ -0,0 +1,103 @@ +# Jade Discipline + +Jade Discipline is the repository-wide Rust standard for Mercury Toolbox / 水星工具箱. + +## Goals + +- Default strictness: rules are either `deny` or `allow`; `warn` is not part of the policy surface. +- Default observability: every command must remain easy to inspect from plain CLI output and machine-readable JSON. +- Default auditability: lint gates, toolchain choices, and verification commands are written down and reproducible. +- Default optimizability: development and release profiles are explicit instead of ad hoc. +- Default narrow exemptions: repository-wide `allow` is treated as hazardous; when a lint must be suppressed, prefer the smallest code-local scope that still explains the exception. +- Default hardening: Miri, fuzzing, sanitizer, no-panic, and Loom checks are Jade gates, not optional tiers. + +## Repository Interpretation + +- The workspace applies the Jade lint baseline through root `Cargo.toml` plus per-crate `[lints] workspace = true`. +- The required hard gate is `cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery`. +- PowerShell scripts under `scripts/` are part of the same discipline surface. Executable `.ps1` files must opt into `Set-StrictMode -Version Latest`, set `$ErrorActionPreference = 'Stop'`, avoid automatic-variable reassignment, and stay free of `Invoke-Expression`. +- Root `Cargo.toml` should not carry broad `allow` exemptions for design, API, or optimization lints. If a lint is not globally denied, that is not the same thing as globally allowing it. +- Global `allow` is reserved for two cases only: a rule that is provably irrelevant to the repository with no realistic future exception, or a naming-only rule that would conflict with the project naming constitution. If a narrower suppression is possible, the narrower suppression wins. +- Local suppressions should start at the smallest reasonable scope: item before impl block, impl block before module, module before crate. When `const`, borrowing, or `#[must_use]` can be added instead of suppressing, code changes are preferred over lint waivers. +- `cargo_common_metadata = "deny"` is kept, and every crate is explicitly marked `publish = false` because Mercury Toolbox is a local toolbox rather than a published crate set. +- Tokio-specific guidance is intentionally not applied because the current project has no async runtime, no Tokio dependencies, and no task scheduler to observe. +- `tracing`, `tracing-subscriber`, `criterion`, `proptest`, `insta`, `rstest`, `anyhow`, and `miette` are approved workspace dependencies for future expansion. They are pinned now, but only crates with a concrete use case should inherit them to avoid dependency drift and `udeps` noise. +- Workspace line coverage is enforced on logic-bearing source files. Thin binary entrypoints in `src/main.rs` and repetitive CLI argument shims in `src/cli.rs` are excluded from the line-rate denominator because they are covered instead by command-specific integration tests plus the PowerShell ecosystem gate. +- Auto-generated lookup tables such as [`managed/src/flow_opcode_table.rs`](../crates/managed/src/flow_opcode_table.rs) are also excluded from the line-rate denominator. They are generated data snapshots rather than authored control flow, and they are verified indirectly through decoder tests that exercise the consuming logic in [`managed/src/flow.rs`](../crates/managed/src/flow.rs). +- Because Mercury Toolbox is a workspace of many tiny binaries rather than one deep library crate, the repository coverage floor is `80%` line coverage after those wrapper exclusions, paired with a mandatory end-to-end `pwsh` ecosystem run. +- Hardening checks are introduced into the main Jade gate. If a machine cannot run one, the run must use the matching `-ExemptMiri`, `-ExemptFuzz`, `-ExemptSanitizers`, `-ExemptNoPanic`, or `-ExemptLoom` flag plus a non-empty reason. Missing tools, missing harnesses, or platform discomfort are failures by default, not quiet skips. +- Exemptions follow the same narrowness rule as lint suppressions: exempt one gate for one reviewed reason, fix the root cause as soon as the underlying toolchain supports it, and never convert the exemption into a broad permanent policy. + +## Hard Gates + +```powershell +pwsh -NoProfile -File .\scripts\check-jade.ps1 -VerificationConfiguration ReleaseFast +``` + +The script is the authoritative entry point. Expanded, it runs `cargo fmt --all --check`, `cargo check --all-targets --all-features`, coverage-backed `cargo llvm-cov --jobs 1 nextest --all-features --summary-only`, strict Clippy, root and fuzz-workspace `cargo +nightly udeps`, root and fuzz-workspace `cargo deny`, the mandatory hardening gates, generated AI asset drift checks, PowerShell analysis, the ecosystem probe, and the selected verification build profile. + +The hardening sub-gate is `scripts/check-jade-hardening.ps1`. Its default `-Only All` path runs the no-panic source scan, Miri JSON-family tests, Loom runtime capture model, JSON-family fuzz target, and address sanitizer tests. The `-Only` switch exists only for focused local repair loops; it is not a Jade tier and it does not prove the repository is clean. + +The `fuzz/` workspace is intentionally separate from the root Cargo workspace. Jade still audits it by running locked metadata generation, `cargo +nightly udeps`, and `cargo deny` against the fuzz manifest. Keep `fuzz/Cargo.lock` committed so fuzz dependency resolution is reproducible. + +## Hardening Exemptions + +Hardening exemptions are explicit audit records, not convenience switches. + +- Use the exact gate flag, such as `-ExemptFuzz`, and include the matching non-empty reason parameter. +- Prefer fixing missing tooling with `just jade-tools` or `pwsh -NoProfile -File .\scripts\install-jade-tooling.ps1` before considering an exemption. +- Do not bake exemptions into `just` recipes, CI defaults, docs, or agent prompts. +- Keep exemption reasons narrow enough to identify one machine, one platform issue, or one temporary toolchain gap. +- Remove the exemption as soon as the underlying issue is fixed. + +The no-panic source gate has its own local escape hatch for rare invariants. The only accepted form is an adjacent Rust comment: `// jade: allow-panic because: `. Prefer `Result`, `Option` handling, `ok_or_else`, and `?` over adding that comment. + +## Install + +Install or refresh the toolchain and cargo subcommands used by the standard. + +```powershell +pwsh -NoProfile -File .\scripts\install-jade-tooling.ps1 +``` + +The tooling script installs `cargo-udeps` and `cargo-llvm-cov`, so they stay in the active toolchain set as required Jade commands. +`install-jade-tooling.ps1` also ensures `PSScriptAnalyzer` is available for the PowerShell gate. +The hardening baseline also installs nightly `miri`, nightly `rust-src`, and `cargo-fuzz`. The repository no-panic gate is an audited source scan that denies `panic!`, `.unwrap()`, and `.expect()` in JSON-family production paths unless a narrow `// jade: allow-panic because: ` comment is placed directly beside the call site. +`just` and `bacon` are part of the local Jade tooling baseline now: `just` is the thin task runner over the repo scripts, and `bacon` is the continuous Rust feedback loop for the edit-compile-test idle gap. +Generated AI assets are part of the repository surface too: the compact prompt under `docs\ai\` and the generated Codex skill under `skills\mercury-toolbox\` should both stay reproducible from their scripts instead of being hand-edited drift. +On Windows, profiler helpers have extra OS prerequisites that Cargo cannot install for you: `cargo flamegraph` falls back to `blondie` unless DTrace is configured, and that `blondie` path requires an elevated shell; `samply` additionally requires `xperf.exe` from Windows Performance Toolkit. +For local one-shot profiling runs, use `scripts/cargo-flamegraph-windows.ps1` to relay through UAC instead of trying to teach the full Jade gate to self-elevate. +When you pass `-LogPath`, that wrapper writes a compact relay log to the requested path and the full elevated-session transcript to a sibling `*.transcript.txt` file so PowerShell-side failures stay auditable. +`sccache` is part of the local Jade tooling baseline now and is configured as the default Cargo `rustc-wrapper`; use `sccache --show-stats` when verifying cache behavior after `cargo clean`. + +## Profiles And Cargo Config + +- `dev` is optimized for local debugging without dropping compile throughput. +- `release` is the day-to-day shipping profile. +- `release-fast` and `release-size` are reserved for speed-first and size-first builds. +- `.cargo/config.toml` owns warning-to-error promotion, future incompatibility reports, and PowerShell-friendly network behavior. + +## PowerShell Gate + +- `scripts/check-powershell.ps1` is the Jade gate for repository PowerShell. +- The gate runs `Invoke-ScriptAnalyzer` with [PSScriptAnalyzerSettings.psd1](../PSScriptAnalyzerSettings.psd1) and treats any reported diagnostic as a hard failure. +- Naming-only analyzer rules such as approved verbs or singular nouns are intentionally left out of the hard gate because Jade reserves hard failures for completion and safety issues, not broad stylistic noise. +- `scripts/toolbox-commands.ps1` is treated as a dot-sourced helper inventory rather than an executable script, so the strict-mode header requirement applies to the executable entry scripts instead. + +## AI Asset Gate + +- `scripts/generate-ai-prompt.ps1` and `scripts/generate-ai-skill.ps1` are the only supported writers for the generated AI prompt and Codex skill. +- `scripts/check-ai-prompt.ps1` and `scripts/check-ai-skill.ps1` self-heal generated asset drift by regenerating once, then fail only if the regenerated files still do not match. +- The generated skill exists partly to stop dumb whole-file reads in agent workflows: it explicitly routes AI toward `fileprobe`, `outline`, `snip`, `chunkcat`, `hitsnip`, `defsnip`, `codeshape`, `refs`, and `ctxpack` before falling back to raw dumps. + +## Local Workflow + +1. Install or update the required cargo tooling with `scripts/install-jade-tooling.ps1`; the local loop expects `cargo-nextest`, `cargo-llvm-cov`, `cargo-deny`, `cargo-udeps`, `just`, and `bacon`. +2. Start new user-facing features from [the Jade feature template](templates/jade-feature-template.md) so behavior, tests, generated assets, and handoff notes stay aligned from the first commit. For daily low-risk maintenance, start from [the daily maintenance template](templates/daily-maintenance-template.md) and `.codex-progress\quality-security-backlog.md`. +3. Keep public APIs fully documented, including `# Errors` and `# Panics` where applicable. +4. Use `just check`, `just test`, `just clippy`, and `just jade-skip-coverage` for the broad local loop, then switch to subsystem loops such as `just code-check`, `just data-test`, `just runtime-check`, `just windows-test`, `just managed-clippy`, or `just ai-check` when the edit is narrower. Keep `just test` as the fast normal path; use `just stable-test` for warning-free incremental cleanup plus ignored slow Windows integrations. +5. Use `just miri`, `just fuzz`, `just sanitizer`, `just no-panic`, and `just loom` only as focused edit loops for a specific failing gate. They are not exemptions and not an optional Jade tier: `just jade` runs the complete hardening gate set through `scripts/check-jade.ps1`. +6. Use `bacon` when you want background compile or test feedback while you keep editing, and prefer the matching scoped jobs such as `bacon code`, `bacon runtime`, `bacon windows`, `bacon managed-test`, or `bacon ai` before falling back to the whole-workspace jobs. +7. Run `pwsh -NoProfile -File .\scripts\check-jade.ps1` before claiming the repository is clean. If a hardening gate truly cannot run, pass the specific `-Exempt*` flag and reason so the missing coverage is visible in logs. +8. Treat any new lint, warning, panic surface, UB finding, fuzz crash, sanitizer report, or Loom interleaving failure as a repository bug, not as backlog. +9. If a lint or hardening check forces an exception, document it in code or command-line reason at the narrowest scope instead of adding a new workspace-wide `allow`. diff --git a/docs/maintainer-notes.md b/docs/maintainer-notes.md new file mode 100644 index 0000000..ce4a1c6 --- /dev/null +++ b/docs/maintainer-notes.md @@ -0,0 +1,73 @@ +# Maintainer Notes + +This document captures the project decisions that are easy to lose between implementation rounds. Treat it as the quick orientation page for future workers before changing Mercury Toolbox internals. + +## Project Direction + +- Mercury Toolbox is not trying to replace every Unix utility. It adds a high-signal layer for local inspection, structure extraction, diagnostics, and AI/model handoff. +- Default text output should stay compact and human-scannable. Use structured output only when the next consumer benefits from it. +- The priority order for JSON-family and code-reading work is capability first, then speed, then memory/CPU footprint, then binary size. All four matter, but do not shrink a tool by removing useful behavior. +- Windows PowerShell UX is a first-class target. Prefer stdin, path streams, clear examples, and failure messages that tell the user what to try next. + +## Code Shape Family + +- `codeindex` is the shared AST/indexing engine. `outline`, `codeshape`, `defsnip`, and `refs` should reuse it instead of growing separate parsers. +- Tree-sitter support exists to make language coverage better, not just faster. When improving outline-like behavior, prefer AST-backed extraction over regex-only expansion. +- If a language has weak output, add tests that demonstrate the missing constructs before tuning presentation. Java feedback was the first strong signal, but the goal is broad polyglot usefulness. +- Keep `outline` short and readable by default. Use `codeshape`, `defsnip`, `refs`, `snip`, and `ctxpack` for deeper or wider context. + +## JSON-Family Tools + +- `common::formats::{toon, ison, zon, tonl}` is the shared home for compact JSON-family logic. Binaries should be thin CLIs around shared Rust implementations. +- Do not introduce Node, npm, TypeScript, Python, or Go runtime dependencies for these formats. Official repositories/specs/fixtures are comparison sources only. +- Every JSON-capable Mercury tool should route structured output through the shared renderer and support `--json`, `--toon`, and `--format text|json|toon`. +- `--json` stays for compatibility. `--toon` is the AI-friendly shortcut. `MERCURY_OUTPUT=json|toon|text` is the session-wide default when no explicit format flag is provided. +- The standalone `toon` binary remains useful for external JSON producers. For Mercury tools, prefer native `--toon`; for path streams, prefer simple pipelines such as `fd ... | fileprobe --input-format lines --toon`. +- `toon` auto-detects JSON input and emits TOON by default. Avoid documenting long forms like `--json | toon --from json --to toon` unless a specific conversion direction truly needs to be explicit. +- Shared TOON decoding must preserve quoted literal keys, reject strict dotted-path conflicts, and round-trip what the shared encoder emits, including root arrays and root primitives. +- `tonl` is more than a converter. Preserve query, validate, index, streaming query, and ETL behavior when refactoring. + +## Jade Is Mandatory + +- Jade has no optional safety tier. Miri, fuzzing, sanitizers, no-panic scanning, and Loom are part of the main gate. +- `scripts/check-jade.ps1` is the authoritative full gate. If a worker only runs a focused loop such as `just fuzz` or `just loom`, they have not run Jade. +- Exemptions are allowed only through explicit `-Exempt*` flags with non-empty reviewed reasons. Missing tools, awkward platforms, or slow checks are failures by default. +- The independent `fuzz/` workspace is intentional. Keep its `Cargo.lock` committed, keep `fuzz/.gitignore` preventing corpus/artifact leaks, and keep root Jade auditing fuzz dependencies with `udeps`, locked metadata, and `cargo deny`. +- The no-panic gate scans JSON-family production paths for `panic!`, `.unwrap()`, and `.expect()`. Prefer fallible propagation. If a panic surface is truly unavoidable, use the strict adjacent comment form `// jade: allow-panic because: `. +- Sanitizer setup is platform-sensitive. Windows needs ASan runtime discovery; non-Windows must not fail because a Windows DLL is absent. +- Loom tests should model production-like concurrency patterns, not unrelated toy examples. Keep the runtime capture model tied to join/publish behavior. + +## Documentation And Generated Assets + +- `README.md` is the user-facing map. `docs/jade-discipline.md` is the safety/verification contract. This file is maintainer orientation. +- Generated AI prompt and skill assets must be changed through `scripts/generate-ai-prompt.ps1` and `scripts/generate-ai-skill.ps1`; do not hand-edit generated output unless the generator changes in the same patch. +- For docs, script, justfile, template, or generated-check hygiene, `just periphery-check` is the focused local proof. It is not a substitute for `just jade` when Rust behavior or release readiness is in scope. +- For docs-only maintenance, prefer a small proof ladder: `just --summary`, `just ai-check` when generated asset references are touched, and `just periphery-check` before handoff if PowerShell scripts, just recipes, or generated-check expectations are mentioned. +- When a pass receives a numeric candidate quota, record the reviewed count and the skipped classes in `.codex-progress\quality-security-backlog.md`; do not create low-value edits just to hit the number. +- Help examples should teach the happy path and the AI-friendly path. Favor examples like `--toon` or `--json | toon` only when they shorten the real user workflow. +- If a tool accepts path input, keep PowerShell wildcard behavior in mind. Native glob expansion is not guaranteed, so either support globs deliberately or give `fd ... | tool --input-format lines` guidance. +- The vendored `tree-sitter` crate is intentionally pinned through `[patch.crates-io]`; follow `vendor/tree-sitter/README.md` before updating or re-vendoring it. + +## CI And Runner Handoff + +- The hosted Gitea CI lane uses the dedicated `windows` runner label, records a toolchain snapshot, runs `just workspace-check`, then performs explicit `cargo nextest`, strict Clippy, and `just ecosystem-fast` steps. +- Treat the toolchain snapshot as runner drift evidence. If `cargo nextest --version`, `cargo llvm-cov --version`, `just --version`, or `pwsh --version` is missing or surprising, fix the runner/tooling before changing repository gates. +- The release workflow is tag-driven and expects the tag to match `[workspace.package].version`; do not document a release as published until the package exists and the Gitea release upload has actually succeeded. +- `scripts\setup-gitea-runner.ps1` creates local runner state and `.runner` credentials on the host. Treat those files and any `GITEA_TOKEN` / `GITEA_API_TOKEN` values as secret material that must stay out of docs, screenshots, package archives, and commits. +- Before and after authenticated Gitea work, use secret-residue checks such as `git remote -v`, `.git\config`, and targeted `rg` scans for `Authorization`, `GITEA_TOKEN`, and token-bearing URLs. Report only the fact that the check passed, not token values. +- When CI behavior changes, update the README workflow overview, this maintainer note, and the daily backlog together so future workers know whether a failing step is expected duplication, runner drift, or a new regression. + +## Moving Or Relocating The Repo + +- Scripts should be path-independent and derive the workspace root from `$PSScriptRoot` where possible. +- Do not hardcode local worktree paths in documentation or scripts. +- This repository may be moved between drives. After moving a Git worktree layout manually, verify both the main worktree and linked worktrees with `git status --short`. + +## Gitea HTTPS Credential Fallback + +- Symptom: Git for Windows can show a `git-remote-https.exe` application error while a push, fetch, or ls-remote operation is resolving HTTPS credentials. In the observed failure, direct authenticated API checks still worked, and a one-shot Git command with a Basic auth header worked, so the failure belonged to the local Git HTTPS credential path rather than repository contents. +- First repair the machine when possible: update Git for Windows, update Git Credential Manager, and check whether the host certificate chain is trusted by the active Git TLS backend. Those changes belong on the affected machine, not in this repository. +- Repo-safe fallback: use `scripts\invoke-gitea-git.ps1` for Gitea Git commands that need authentication. It sets `credential.helper=` for that invocation, uses `http.sslBackend=openssl`, injects an `http.extraHeader` Basic auth header, disables terminal prompts, clears Git curl tracing while the header is in scope, and filters libcurl trace lines from captured Git stderr. +- Pass a token with `-ApiToken` or set `GITEA_API_TOKEN`/`GITEA_TOKEN` only in the current shell. The helper deliberately does not fall back to Git Credential Manager; if no token is provided, it fails closed instead of re-entering the crash path. +- Keep TLS verification on by default. Use `-InsecureSkipTlsVerify` only as a temporary test-host workaround, then remove it once the certificate chain is fixed. +- After any emergency authenticated push, verify that `git remote -v` and `.git\config` contain no token, no `Authorization` header, and no credential-bearing URL. diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md new file mode 100644 index 0000000..83dead8 --- /dev/null +++ b/docs/releases/v0.1.0.md @@ -0,0 +1,46 @@ +# Mercury Toolbox v0.1.0 + +Mercury Toolbox v0.1.0 is the first hosted release for the Gitea-backed repository. It packages the current PowerShell-first Rust toolbox, ships the renamed `mhash` hashing command, carries the generated Codex skill and AI prompt assets, and aligns the portable install path with the current package bundle. + +## Highlights + +- `mhash:` the former `digest` command surface is now published under the permanent `mhash` name across binaries, docs, scripts, packaged assets, and installed skills. +- `Guided triage:` the generated AI prompt, skill catalog, and PE tooling guidance are bundled into the portable package so the hosted release matches the current local install surface. +- `Portable packaging:` the release archive includes install and uninstall scripts, `SHA256SUMS.txt`, `mercury-toolbox-package.json`, and the packaged command catalog for offline inspection. +- `Gitea automation:` this release adds native `.gitea/workflows` CI and tag-driven release publishing for the hosted repo. + +## Breaking Changes + +- `digest -> mhash` +- Any automation or muscle memory that still invokes `digest` should switch to `mhash`. + +## Install Or Upgrade + +Fresh install from source checkout: + +```powershell +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 +``` + +Portable package install from the release assets: + +```powershell +pwsh -NoProfile -File .\MercuryToolbox-x86_64-pc-windows-msvc-ReleaseFast\scripts\install-package-toolbox.ps1 +``` + +## Verification + +- Release gate: `just jade` +- Package build: `just package` +- Release publish: `pwsh -NoProfile -File .\scripts\publish-gitea-release.ps1 -Tag v0.1.0 -NotesPath .\docs\releases\v0.1.0.md` + +## Assets + +- `MercuryToolbox-x86_64-pc-windows-msvc-ReleaseFast.zip` +- `SHA256SUMS.txt` +- `mercury-toolbox-package.json` + +## Notes For Upgraders + +- Open a new PowerShell after install so the updated PATH picks up the current toolbox binaries. +- Codex skill installs are refreshed by the package installer; a new Codex session is the cleanest way to pick up the latest packaged skill copy. diff --git a/docs/superpowers/plans/2026-04-21-mercury-toolbox-v2-plan.md b/docs/superpowers/plans/2026-04-21-mercury-toolbox-v2-plan.md new file mode 100644 index 0000000..7fbda58 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-mercury-toolbox-v2-plan.md @@ -0,0 +1,172 @@ +# Mercury Toolbox V2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebrand the repository as Mercury Toolbox, add four new high-leverage CLI commands, and ship a first-class Windows installer that puts the full toolbox on PATH. + +**Architecture:** Keep the toolbox as a Rust workspace of independent binaries with a shared CLI contract in `crates/common`. Add the new commands as focused crates with heuristic parsers and stable JSON payloads, and implement installation through PowerShell scripts that build and copy the binaries into a dedicated Mercury Toolbox bin directory. + +**Tech Stack:** Rust stable, `lexopt`-style manual CLI parsing, shared `common` crate, `regex`, `serde_json`, PowerShell install scripts, `assert_cmd`, `predicates`, `tempfile`, and the existing Jade verification scripts. + +--- + +### Task 1: Branding And Install Surface + +**Files:** +- Create: `docs/superpowers/specs/2026-04-21-mercury-toolbox-v2-design.md` +- Create: `docs/superpowers/plans/2026-04-21-mercury-toolbox-v2-plan.md` +- Modify: `Cargo.toml` +- Modify: `README.md` +- Modify: `docs/jade-discipline.md` +- Create: `scripts/install-toolbox.ps1` +- Create: `scripts/uninstall-toolbox.ps1` + +- [ ] Record the Mercury Toolbox design and plan docs in the repo. +- [ ] Rename top-level product-facing descriptions and README text from generic toolbox wording to `Mercury Toolbox` / `水星工具箱`. +- [ ] Add an install section to the README covering: + - `pwsh .\scripts\install-toolbox.ps1` + - install root + - PATH behavior + - uninstall path + - manual `cargo install --path` fallback +- [ ] Write `scripts/install-toolbox.ps1` with idempotent bin copy and user PATH update behavior. +- [ ] Write `scripts/uninstall-toolbox.ps1` with idempotent uninstall and PATH removal behavior. +- [ ] Add focused script tests or temp-root verification coverage where practical. + +### Task 2: Workspace Wiring For New Commands + +**Files:** +- Modify: `Cargo.toml` +- Modify: `README.md` +- Create: `fixtures/hits/rg-output.txt` +- Create: `fixtures/diag/rust-errors.txt` +- Create: `fixtures/diag/unity-errors.txt` +- Create: `fixtures/logs/repetitive.log` +- Create: `fixtures/binaries/stringscan-sample.bin` +- Create: `crates/hitsnip/Cargo.toml` +- Create: `crates/hitsnip/src/main.rs` +- Create: `crates/hitsnip/src/lib.rs` +- Create: `crates/diagpick/Cargo.toml` +- Create: `crates/diagpick/src/main.rs` +- Create: `crates/diagpick/src/lib.rs` +- Create: `crates/logshape/Cargo.toml` +- Create: `crates/logshape/src/main.rs` +- Create: `crates/logshape/src/lib.rs` +- Create: `crates/stringscan/Cargo.toml` +- Create: `crates/stringscan/src/main.rs` +- Create: `crates/stringscan/src/lib.rs` + +- [ ] Register the four new crates in the workspace member list. +- [ ] Add any workspace dependencies required by the new crates. +- [ ] Add fixture files with stable, portable sample content for all four commands. +- [ ] Add README command inventory entries for `hitsnip`, `diagpick`, `logshape`, and `stringscan`. + +### Task 3: `hitsnip` + +**Files:** +- Create: `crates/hitsnip/Cargo.toml` +- Create: `crates/hitsnip/src/lib.rs` +- Create: `crates/hitsnip/src/main.rs` +- Create: `crates/hitsnip/tests/hitsnip_cli.rs` +- Use: `fixtures/hits/rg-output.txt` +- Use: `fixtures/reading/sample.rs` + +- [ ] Write failing tests for: + - `rg -n` line parsing + - merged context windows + - JSON output shape + - PowerShell pipeline usage + - `--help` +- [ ] Run `cargo nextest run -p hitsnip` and confirm the new tests fail for the expected missing behavior. +- [ ] Implement hit parsing that correctly handles Windows drive prefixes and `path:line[:column]` shapes. +- [ ] Implement per-file hit grouping, context expansion, max-gap merging, and compact numbered rendering. +- [ ] Re-run `cargo nextest run -p hitsnip` until green. + +### Task 4: `diagpick` + +**Files:** +- Create: `crates/diagpick/Cargo.toml` +- Create: `crates/diagpick/src/lib.rs` +- Create: `crates/diagpick/src/main.rs` +- Create: `crates/diagpick/tests/diagpick_cli.rs` +- Use: `fixtures/diag/rust-errors.txt` +- Use: `fixtures/diag/unity-errors.txt` +- Use: `fixtures/reading/sample.rs` +- Use: `fixtures/reading/sample.cs` + +- [ ] Write failing tests for: + - Rust diagnostic extraction + - Unity/C# diagnostic extraction + - severity filtering + - `--with-source` + - JSON output shape + - `--help` +- [ ] Run `cargo nextest run -p diagpick` and confirm failure. +- [ ] Implement heuristic text parsers for the supported diagnostic families plus JSONL input support. +- [ ] Implement optional source loading with context lines and safe missing-file handling. +- [ ] Re-run `cargo nextest run -p diagpick` until green. + +### Task 5: `logshape` + +**Files:** +- Create: `crates/logshape/Cargo.toml` +- Create: `crates/logshape/src/lib.rs` +- Create: `crates/logshape/src/main.rs` +- Create: `crates/logshape/tests/logshape_cli.rs` +- Use: `fixtures/logs/repetitive.log` + +- [ ] Write failing tests for: + - normalization of volatile fields + - grouping repeated templates + - `--top` and `--min-count` + - JSON output shape + - PowerShell pipeline usage + - `--help` +- [ ] Run `cargo nextest run -p logshape` and confirm failure. +- [ ] Implement conservative normalization and frequency grouping logic. +- [ ] Implement compact text summaries plus JSON records with count and sample metadata. +- [ ] Re-run `cargo nextest run -p logshape` until green. + +### Task 6: `stringscan` + +**Files:** +- Create: `crates/stringscan/Cargo.toml` +- Create: `crates/stringscan/src/lib.rs` +- Create: `crates/stringscan/src/main.rs` +- Create: `crates/stringscan/tests/stringscan_cli.rs` +- Use: `fixtures/binaries/stringscan-sample.bin` + +- [ ] Write failing tests for: + - printable string extraction + - category detection for URL, DLL, namespace, Unity, IL2CPP, and BepInEx-like strings + - summary mode vs detail mode + - JSON output shape + - PowerShell path pipeline + - `--help` +- [ ] Run `cargo nextest run -p stringscan` and confirm failure. +- [ ] Implement printable-string scanning and category classification helpers. +- [ ] Implement summary output, filtered detail output, and JSON payloads. +- [ ] Re-run `cargo nextest run -p stringscan` until green. + +### Task 7: Full Documentation And Install Verification + +**Files:** +- Modify: `README.md` +- Modify: command `after_help` sections in the four new crates +- Modify: install/uninstall scripts as needed by verification + +- [ ] Ensure every new command has PowerShell examples in README and `--help`. +- [ ] Verify the install script copies all toolbox commands, not just the new ones. +- [ ] Verify PATH update behavior against an isolated temp install root when possible. +- [ ] Verify uninstall removes the Mercury Toolbox bin path entry without touching unrelated PATH content. + +### Task 8: Final Verification + +**Files:** +- Modify only as required by verification failures + +- [ ] Run `cargo fmt --all`. +- [ ] Run `cargo nextest run --all-targets --all-features`. +- [ ] Run `cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery`. +- [ ] Run `pwsh -NoProfile -File .\scripts\check-jade.ps1`. +- [ ] Fix any regressions and repeat until the full workspace passes. diff --git a/docs/superpowers/plans/2026-04-21-toolbox-reading-v2-plan.md b/docs/superpowers/plans/2026-04-21-toolbox-reading-v2-plan.md new file mode 100644 index 0000000..2485f0f --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-toolbox-reading-v2-plan.md @@ -0,0 +1,96 @@ +# Toolbox Reading V2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add four new reading-oriented CLI commands that let PowerShell and AI workflows inspect files precisely without dumping whole files. + +**Architecture:** Keep each command as its own crate in the workspace and keep language or file-type heuristics inside focused helpers rather than shared heavyweight parsers. Reuse the existing common CLI contract for stdin behavior, JSON output, and exit codes, while letting each command define small, stable domain-specific JSON payloads. + +**Tech Stack:** Rust stable, `lexopt`-style manual CLI parsing, shared `common` crate, `regex`, `serde_json`, `toml`, `assert_cmd`, and existing workspace verification scripts. + +--- + +### Task 1: Manifest And Documentation Wiring + +**Files:** +- Create: `docs/superpowers/specs/2026-04-21-toolbox-reading-v2-design.md` +- Create: `docs/superpowers/plans/2026-04-21-toolbox-reading-v2-plan.md` +- Modify: `Cargo.toml` +- Modify: `README.md` + +- [ ] Record the reading-tool batch scope in repo docs. +- [ ] Register `crates/snip`, `crates/outline`, `crates/fileprobe`, and `crates/chunkcat` in the workspace. +- [ ] Add any workspace dependencies needed by the four crates. +- [ ] Update README command inventory and usage examples. + +### Task 2: `snip` + +**Files:** +- Create: `crates/snip/Cargo.toml` +- Create: `crates/snip/src/lib.rs` +- Create: `crates/snip/src/main.rs` +- Create: `crates/snip/tests/snip_cli.rs` +- Create: `fixtures/reading/sample.rs` +- Create: `fixtures/reading/sample.cs` + +- [ ] Write failing tests for `--lines`, `--around`, `--symbol`, and `--help`. +- [ ] Run targeted `snip` tests and confirm failure for the new behavior. +- [ ] Implement snippet selection, numbered text rendering, and JSON output. +- [ ] Add heuristic symbol extraction for Rust and C# plus safe fallback behavior. +- [ ] Re-run targeted `snip` tests until green. + +### Task 3: `outline` + +**Files:** +- Create: `crates/outline/Cargo.toml` +- Create: `crates/outline/src/lib.rs` +- Create: `crates/outline/src/main.rs` +- Create: `crates/outline/tests/outline_cli.rs` +- Create: `fixtures/reading/config.json` +- Create: `fixtures/reading/config.toml` +- Create: `fixtures/reading/config.yaml` + +- [ ] Write failing tests for Rust/C#/config outline extraction, JSON shape, and `--help`. +- [ ] Run targeted `outline` tests and confirm failure. +- [ ] Implement heuristic outline extraction and depth limiting. +- [ ] Re-run targeted `outline` tests until green. + +### Task 4: `fileprobe` + +**Files:** +- Create: `crates/fileprobe/Cargo.toml` +- Create: `crates/fileprobe/src/lib.rs` +- Create: `crates/fileprobe/src/main.rs` +- Create: `crates/fileprobe/tests/fileprobe_cli.rs` +- Create: `fixtures/reading/minified.js` +- Create: `fixtures/reading/generated.lock` +- Create: `fixtures/reading/binary.bin` + +- [ ] Write failing tests for text/binary detection, heuristic flags, JSON output, and `--help`. +- [ ] Run targeted `fileprobe` tests and confirm failure. +- [ ] Implement lightweight probing, line stats, and file family heuristics. +- [ ] Re-run targeted `fileprobe` tests until green. + +### Task 5: `chunkcat` + +**Files:** +- Create: `crates/chunkcat/Cargo.toml` +- Create: `crates/chunkcat/src/lib.rs` +- Create: `crates/chunkcat/src/main.rs` +- Create: `crates/chunkcat/tests/chunkcat_cli.rs` + +- [ ] Write failing tests for chunk inventory, selected chunk rendering, JSON output, and `--help`. +- [ ] Run targeted `chunkcat` tests and confirm failure. +- [ ] Implement deterministic chunk planning and selected chunk extraction. +- [ ] Re-run targeted `chunkcat` tests until green. + +### Task 6: Final Verification + +**Files:** +- Modify only as required by verification failures + +- [ ] Run `cargo fmt --all`. +- [ ] Run `cargo nextest run --all-targets --all-features`. +- [ ] Run `cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery`. +- [ ] Run `pwsh -NoProfile -File .\scripts\check-jade.ps1`. +- [ ] Fix any regressions and repeat until the full workspace passes. diff --git a/docs/superpowers/plans/2026-04-21-toolbox-v1-5-plan.md b/docs/superpowers/plans/2026-04-21-toolbox-v1-5-plan.md new file mode 100644 index 0000000..2460c26 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-toolbox-v1-5-plan.md @@ -0,0 +1,95 @@ +# Toolbox V1.5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the existing four toolbox commands with better UX and capability, then add a production-grade `binmeta` binary metadata inspector. + +**Architecture:** Keep the workspace shape flat and consistent: each command remains its own crate, shared behavior stays in `common`, and every new behavior is covered by command-local tests plus workspace verification. `binmeta` will follow the same input/output contract as the existing tools and keep format-specific parsing behind focused helpers. + +**Tech Stack:** Rust stable, `lexopt`-style manual CLI parsing, shared `common` crate, targeted parsing crates for PE inspection, workspace tests via `assert_cmd`. + +--- + +### Task 1: Design And Manifest Wiring + +**Files:** +- Create: `docs/superpowers/specs/2026-04-21-toolbox-v1-5-design.md` +- Create: `docs/superpowers/plans/2026-04-21-toolbox-v1-5-plan.md` +- Modify: `Cargo.toml` +- Modify: `README.md` + +- [ ] Record the approved v1.5 scope in repo docs. +- [ ] Add any new workspace dependencies needed for `binmeta`. +- [ ] Register `crates/binmeta` in the workspace. +- [ ] Update README command inventory and usage examples. + +### Task 2: `jsonlgrep` Upgrade + +**Files:** +- Modify: `crates/jsonlgrep/src/lib.rs` +- Modify: `crates/jsonlgrep/tests/query_and_cli.rs` + +- [ ] Write failing tests for dotted field paths, `!=`, and `!~=` queries. +- [ ] Run targeted `jsonlgrep` tests and confirm the new cases fail for the right reason. +- [ ] Implement nested path resolution and negative predicate parsing/matching with minimal code changes. +- [ ] Add `after_help` examples and test representative help output. +- [ ] Re-run targeted `jsonlgrep` tests until green. + +### Task 3: `recent` Upgrade + +**Files:** +- Modify: `crates/recent/src/lib.rs` +- Modify: `crates/recent/tests/recent_cli.rs` + +- [ ] Write failing tests for `--name` regex filtering and help output examples. +- [ ] Run targeted `recent` tests and confirm failure. +- [ ] Implement basename regex filtering and help text polish. +- [ ] Re-run targeted `recent` tests until green. + +### Task 4: `pathshadow` Upgrade + +**Files:** +- Modify: `crates/pathshadow/src/lib.rs` +- Modify: `crates/pathshadow/tests/pathshadow_cli.rs` + +- [ ] Write failing tests for explanation/rank fields and help examples. +- [ ] Run targeted `pathshadow` tests and confirm failure. +- [ ] Implement richer JSON/text explanation output while preserving existing contract fields. +- [ ] Re-run targeted `pathshadow` tests until green. + +### Task 5: `portping` Upgrade + +**Files:** +- Modify: `crates/portping/src/lib.rs` +- Modify: `crates/portping/src/main.rs` +- Modify: `crates/portping/tests/portping_cli.rs` + +- [ ] Write failing tests for `--method`, `--expect-status`, and help examples. +- [ ] Run targeted `portping` tests and confirm failure. +- [ ] Implement request method selection and explicit status expectation handling. +- [ ] Re-run targeted `portping` tests until green. + +### Task 6: `binmeta` Command + +**Files:** +- Create: `crates/binmeta/Cargo.toml` +- Create: `crates/binmeta/src/lib.rs` +- Create: `crates/binmeta/src/main.rs` +- Create: `crates/binmeta/tests/binmeta_cli.rs` +- Create: `fixtures/binmeta/plain.txt` + +- [ ] Write failing tests for non-PE fallback, PE metadata extraction against a compiled workspace binary, JSON output, and help examples. +- [ ] Run targeted `binmeta` tests and confirm failure. +- [ ] Implement stdin/path loading, metadata hashing, PE parsing, heuristic hints, and text/JSON rendering. +- [ ] Re-run targeted `binmeta` tests until green. + +### Task 7: Full Verification + +**Files:** +- Modify only as required by verification failures + +- [ ] Run `cargo fmt --all`. +- [ ] Run `cargo nextest run --all-targets --all-features`. +- [ ] Run `cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery`. +- [ ] Run `pwsh -NoProfile -File .\scripts\check-jade.ps1`. +- [ ] Fix any regressions and repeat until all commands pass. diff --git a/docs/superpowers/specs/2026-04-21-mercury-toolbox-v2-design.md b/docs/superpowers/specs/2026-04-21-mercury-toolbox-v2-design.md new file mode 100644 index 0000000..7c8888e --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-mercury-toolbox-v2-design.md @@ -0,0 +1,329 @@ +# Mercury Toolbox V2 Design + +## Goal + +Promote the repository from a generic AI-friendly CLI toolbox into the formally named `Mercury Toolbox` / `水星工具箱`, add four new high-leverage commands for search-to-context, diagnostics, log reduction, and binary string triage, and ship a first-class Windows + PowerShell installation path that automatically places the toolbox on the user PATH. + +## Scope + +- Rename the toolbox brand in repository-level docs, workspace metadata, install-facing text, and user help from the generic placeholder wording to `Mercury Toolbox` / `水星工具箱`. +- Add four new commands: + - `hitsnip` + - `diagpick` + - `logshape` + - `stringscan` +- Keep natural binary names such as `snip`, `outline`, `hitsnip`, and `diagpick`; do not introduce a global binary prefix. +- Add a user-facing PowerShell installer that: + - builds the workspace release binaries + - installs all toolbox binaries into `$env:LOCALAPPDATA\MercuryToolbox\bin` + - automatically adds that directory to the user PATH if missing + - reports what changed and how to verify the install +- Add a complementary uninstall path and installation documentation. +- Preserve the shared CLI contract: + - compact ASCII text output by default + - `--json` for machine consumption + - PowerShell-friendly stdin behavior + - stable exit code mapping + +## Non-Goals + +- No GUI, TUI, or long-running background service. +- No package publishing, Scoop bucket, installer EXE, or code signing in this iteration. +- No heavyweight parser stack such as tree-sitter, Roslyn, or full symbol servers. +- No single “master” control binary; the toolbox remains a workspace of small independent commands. + +## Product Direction + +The existing toolbox already covers “find a file”, “probe the environment”, and “read a known target”. The new batch fills the remaining gaps that are especially painful for AI-assisted terminal workflows: + +- `hitsnip` answers “I already have search hits; now give me the smallest useful context.” +- `diagpick` answers “this compiler or runtime log is noisy; show me the actionable diagnostics.” +- `logshape` answers “this log is too repetitive; summarize the patterns before I read it.” +- `stringscan` answers “this binary or generated blob is opaque; show me the strings that reveal identity, runtime, and ecosystem.” + +The installation work is part of the feature set, not a side quest. A toolbox that is annoying to install has near-zero real-world value even if the commands are good. + +## Branding And Naming + +### Repository Name + +- Primary English name: `Mercury Toolbox` +- Primary Chinese name: `水星工具箱` +- Documentation should treat these as the formal product name, not as aliases for the old placeholder wording. + +### Binary Names + +- Keep natural command names: + - `jsonlgrep` + - `recent` + - `pathshadow` + - `portping` + - `binmeta` + - `fileprobe` + - `outline` + - `snip` + - `chunkcat` + - `hitsnip` + - `diagpick` + - `logshape` + - `stringscan` +- Do not add `mercury-` prefixes. +- The installer owns product-level grouping; the binaries stay small and task-specific. + +## Installation Design + +### Main Installation Path + +- Add `scripts/install-toolbox.ps1` +- Default behavior: + - verify `cargo` exists + - run `cargo build --release --workspace` + - collect all known toolbox executables from `target\release` + - create `$env:LOCALAPPDATA\MercuryToolbox\bin` if needed + - copy all toolbox executables into that directory + - inspect the user PATH + - append the install directory if missing + - print a compact summary of installed commands and PATH status + +### PATH Policy + +- PATH updates target the user-level environment variable, not machine-wide PATH. +- The script should avoid duplicate path entries by normalizing path comparison. +- Current-session PATH may also be patched so the commands are usable immediately after install without opening a new shell. + +### Safety And UX + +- Support `-NoPathUpdate` for explicit opt-out. +- Support `-InstallRoot ` for advanced/manual installs, while defaulting to `$env:LOCALAPPDATA\MercuryToolbox`. +- Support `-Configuration Debug|Release`, defaulting to `Release`. +- Text output should clearly distinguish: + - install root + - bin directory + - copied binaries + - whether PATH was already configured, updated, or skipped + +### Uninstall Path + +- Add `scripts/uninstall-toolbox.ps1` +- Default behavior: + - remove installed binaries from the Mercury Toolbox bin directory + - remove the Mercury Toolbox bin directory from the user PATH when present + - leave unrelated files untouched + +### Fallback Installation Path + +- README also documents manual per-command install via `cargo install --path crates/`. +- This is a fallback path for Rust-native users, not the primary recommendation. + +## Command Designs + +### `hitsnip` + +#### Purpose + +Convert search hits into deduplicated, compact context windows so neither humans nor AI have to manually reopen each hit. + +#### Inputs + +- Explicit files plus line references in one of these forms: + - `path:line` + - `path:line:column` +- stdin text from tools such as `rg -n` +- JSONL hit records with at least `path` and `line` + +#### Output + +- Text mode: + - one header per merged snippet + - numbered lines under each snippet + - compact reason metadata such as hit count and merged line span +- JSON mode: + - one JSON array of snippet objects + - fields: + - `path` + - `start_line` + - `end_line` + - `hit_lines` + - `hit_count` + - `lines` + +#### Behavior + +- Merge nearby hits in the same file when the distance between hit windows is at most `--max-gap`. +- Expand each hit by `--context` lines. +- Deduplicate repeated identical hit lines. +- Ignore malformed lines with clear usage/runtime errors rather than panicking. + +#### Key Flags + +- `--context ` +- `--max-gap ` +- `--limit ` +- `--input-format auto|lines|jsonl` +- shared `--json` + +### `diagpick` + +#### Purpose + +Extract actionable diagnostics from compiler, build, and runtime logs and optionally attach source context. + +#### Inputs + +- Plain text logs from stdin or files +- JSONL records with fields such as `path`, `line`, `column`, `severity`, `message` + +#### Recognized Text Patterns + +- Rust diagnostics: + - `error[E0425]: ...` + - `--> path:line:column` +- MSVC/C#/Unity-style: + - `path(line,column): error CSxxxx: ...` + - `path:line:column: error: ...` +- Generic runtime stack/log references where a path and line are present + +#### Output + +- Text mode: + - one compact record per diagnostic + - optional source snippet below when `--with-source` is enabled +- JSON mode: + - array of diagnostics with stable fields: + - `path` + - `line` + - `column` + - `severity` + - `code` + - `message` + - `source` + - `tool_hint` + +#### Key Flags + +- `--with-source` +- `--context ` +- `--limit ` +- `--severity error|warning|note|all` + +### `logshape` + +#### Purpose + +Collapse repetitive logs into pattern groups before anyone spends tokens reading the raw stream. + +#### Inputs + +- Plain log lines from stdin or files + +#### Heuristic Normalization + +- Replace volatile fragments with placeholders: + - timestamps + - decimal and hex numbers + - UUID-like tokens + - long paths and addresses +- Preserve level-like prefixes such as `INFO`, `WARN`, `ERROR` when possible + +#### Output + +- Text mode: + - one compact line per template + - includes count and a representative sample +- JSON mode: + - array of groups with fields: + - `pattern` + - `count` + - `first_line` + - `last_line` + - `sample` + +#### Key Flags + +- `--top ` +- `--min-count ` +- `--show-samples` +- `--keep-level` + +### `stringscan` + +#### Purpose + +Expose the high-signal strings inside binaries, generated files, and opaque artifacts for reverse engineering and quick triage. + +#### Inputs + +- One or more file paths from argv or stdin + +#### Output + +- Text mode: + - one compact summary line per file in summary mode + - optional per-string output when filtering by category +- JSON mode: + - per-file reports with: + - `path` + - `is_binary` + - `string_count` + - `categories` + - `matches` + +#### Heuristic Categories + +- `url` +- `path` +- `dll` +- `namespace` +- `unity` +- `dotnet` +- `il2cpp` +- `bepinex` +- `generic` + +#### Key Flags + +- `--min-len ` +- `--kind all|url|path|dll|namespace|unity|dotnet|il2cpp|bepinex|generic` +- `--unique` +- `--limit ` +- `--details` + +## Shared Fixtures + +Add fixtures that support these commands without introducing heavyweight dependencies: + +- `fixtures/hits/rg-output.txt` +- `fixtures/diag/rust-errors.txt` +- `fixtures/diag/unity-errors.txt` +- `fixtures/logs/repetitive.log` +- `fixtures/binaries/stringscan-sample.bin` + +The binary fixture should embed obvious markers such as URLs, DLL names, Unity namespace fragments, and BepInEx-like strings so category detection can be tested reliably. + +## Testing Strategy + +- Unit tests: + - hit parsing and merge planning for `hitsnip` + - diagnostic parsing helpers for `diagpick` + - normalization and grouping for `logshape` + - string extraction and category heuristics for `stringscan` + - installer path normalization and PATH edit helpers +- Integration tests: + - `--help` examples for all new binaries + - PowerShell pipeline flows + - JSON output shape + - install/uninstall script dry-run or temp-root behavior +- Verification gates stay under the existing Jade standard, including `check-jade.ps1`. + +## Risks + +- Search-hit parsing can get messy when input mixes drive-letter paths and colon-separated line syntax. + - Prefer a path-aware parser that handles Windows drive prefixes before splitting on the final line/column segments. +- Diagnostic parsers can drift when tools change exact wording. + - Keep the patterns heuristic and additive rather than pretending to be a full parser for each toolchain. +- Over-normalizing logs can merge meaningfully different failures. + - Keep the normalization rule set conservative in v1. +- Binary string scanning can explode in output volume. + - Default to concise summaries and require explicit detail flags for large per-string dumps. +- PATH editing is user-hostile if it duplicates entries or stomps unrelated content. + - Use minimal, append-only edits with idempotent detection. diff --git a/docs/superpowers/specs/2026-04-21-toolbox-reading-v2-design.md b/docs/superpowers/specs/2026-04-21-toolbox-reading-v2-design.md new file mode 100644 index 0000000..7edb1a2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-toolbox-reading-v2-design.md @@ -0,0 +1,137 @@ +# Toolbox Reading V2 Design + +## Goal + +Add four new PowerShell-friendly, AI-friendly reading commands to the toolbox so the workflow can move from "find a file" to "read the right part" without falling back to `Get-Content` or wasting tokens on full-file dumps. + +## Scope + +- Add `snip` for precise snippet extraction from files or stdin. +- Add `outline` for heuristic structure summaries of common source and config files. +- Add `fileprobe` for fast file-type and usefulness heuristics before opening a file. +- Add `chunkcat` for deterministic chunk listing and chunk extraction from large text files. +- Add help examples and README usage for all four commands. +- Reuse the existing workspace CLI contract: shared `--json`, `--input-format`, exit codes, and PowerShell pipe support. + +## Non-Goals + +- No TUI, pager, fuzzy picker, or interactive mode. +- No tree-sitter, Roslyn, or heavyweight language parser in this iteration. +- No write or patch workflow in this batch. +- No token estimation command, preview patch command, or replacement engine in this batch. + +## Design Direction + +- Prefer heuristic parsing over heavyweight parsing. + - This keeps the binaries small, predictable, and fast enough for ad hoc shell use. + - False positives are acceptable when they are clearly labeled as heuristic output. +- Prefer compact text output that is useful to both humans and AI. + - Text mode should avoid banners and avoid dumping redundant metadata. + - JSON mode should expose stable machine fields so scripts can select the next action. +- Treat these commands as a chain rather than isolated tools. + - `fileprobe` answers "what is this file and should I read it?" + - `outline` answers "where is the interesting structure?" + - `snip` answers "show me the exact region." + - `chunkcat` answers "how do I traverse this large file safely?" + +## Command Designs + +### `snip` + +- Input: + - one or more file paths from argv + - or stdin content when piped +- Selectors: + - `--lines ` + - `--around ` + - `--symbol ` + - exactly one selector must be present +- Supporting flags: + - `--context ` for `--around` and `--symbol` + - `--max-matches ` for `--around` +- Output: + - text mode emits `path:start-end reason=...` followed by numbered lines + - JSON mode emits an array of snippets with `path`, `start_line`, `end_line`, `reason`, and `lines` +- Heuristic behavior: + - `--symbol` uses language-aware regexes for Rust and C# plus generic fallbacks for other text files + - when possible, symbol extraction expands to a balanced block instead of a single line + +### `outline` + +- Input: + - one or more file paths from argv + - stdin paths in line mode +- Supported heuristic families: + - Rust: `mod`, `struct`, `enum`, `trait`, `impl`, `fn`, `const`, `static`, `type` + - C#: `namespace`, `class`, `struct`, `enum`, `interface`, `record`, method-like members + - JSON: object keys traversed by depth + - TOML: tables and keys + - YAML: indentation-based key outline +- Flags: + - `--depth ` to cap nested output + - `--kind all|code|config` +- Output: + - text mode emits one compact line per item: `line depth kind name` + - JSON mode emits per-file objects with stable `items` +- Heuristic behavior: + - items are marked by file-relative line number and depth, not claimed as exact AST nodes + +### `fileprobe` + +- Input: + - one or more file paths from argv or stdin +- Output fields: + - path, extension, size, modified time + - `exists`, `is_dir`, `is_binary`, `encoding_hint` + - `family` such as `source`, `config`, `data`, `binary`, `archive`, `unknown` + - `language_hint` + - `line_count`, `blank_lines`, `longest_line` + - heuristic flags such as `likely_generated`, `likely_minified`, `likely_test`, `likely_lockfile`, `likely_vendor` + - optional `container_hint` such as `pe`, `zip`, `sqlite`, `pdf` +- Heuristic behavior: + - detect binary vs text from bytes and UTF-8 validity + - infer family and language from extension plus lightweight content checks + - do not parse PE deeply here; `binmeta` remains the dedicated PE inspector + +### `chunkcat` + +- Input: + - one text file path at a time in v1 +- Flags: + - `--max-lines ` defaulting to a budget-friendly size + - `--overlap ` for deterministic overlap between chunks + - `--chunk ` to emit a specific chunk +- Output: + - without `--chunk`, emit chunk inventory only + - with `--chunk`, emit the selected chunk with numbered lines + - JSON mode emits chunk metadata, and chunk content only when `--chunk` is set +- Behavior: + - chunking is line-based and deterministic + - line ranges are stable for the same file contents and options + - overlapping chunks use a fixed stride of `max_lines - overlap` + +## Shared Testing Strategy + +- Add unit tests for: + - selector parsing + - heuristic file classification + - chunk calculation + - outline extraction helpers +- Add integration tests for: + - `--help` examples + - PowerShell pipeline scenarios + - JSON output shape for automation +- Add fixtures for: + - Rust source with multiple symbols + - C# source with Unity-style class structure + - JSON, TOML, and YAML config files + - minified/generated-ish text and simple binary-like samples + +## Risks + +- Heuristic structure detection can misclassify edge-case syntax. + - This is acceptable if outputs are useful and clearly heuristic. +- `snip --symbol` block expansion can drift on malformed files. + - Prefer a safe fallback to line-only snippets over panics or empty output. +- `chunkcat` can become noisy if default chunk size is too small. + - Pick a conservative default tuned for AI reading, not log streaming. diff --git a/docs/superpowers/specs/2026-04-21-toolbox-v1-5-design.md b/docs/superpowers/specs/2026-04-21-toolbox-v1-5-design.md new file mode 100644 index 0000000..a72c67f --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-toolbox-v1-5-design.md @@ -0,0 +1,80 @@ +# Toolbox V1.5 Design + +## Goal + +Promote the existing four commands from "usable prototypes" to daily-driver tools by pairing feature upgrades with first-class help and documentation, and add a production-grade `binmeta` command for fast binary triage in AI-heavy terminal workflows. + +## Scope + +- Upgrade `jsonlgrep` with nested field paths and negative field predicates. +- Upgrade `recent` with regex-based name filtering. +- Upgrade `pathshadow` with clearer shadowing explanations in text and JSON output. +- Upgrade `portping` with selectable HTTP method and expected status checks. +- Add concrete examples to `--help` output for all commands. +- Update `README.md` with scenario-driven examples and PowerShell pipeline usage. +- Add `binmeta` as a new CLI command for binary metadata inspection on Windows PE files, with graceful handling for non-PE files. + +## Non-Goals + +- No GUI or TUI work. +- No installer, release automation, or Scoop packaging in this iteration. +- No multi-format binary analysis beyond production-grade PE support plus safe fallback summaries for unknown formats. + +## Command Changes + +### `jsonlgrep` + +- Extend query syntax to support `field!=value` and `field!~=regex`. +- Resolve dotted field paths such as `event.user.name`. +- Treat scalar JSON values consistently so exact matches and text projections also work for numbers, booleans, and null. +- Improve `--help` with copy-pasteable file and pipeline examples. + +### `recent` + +- Add `--name ` to filter by file or directory basename. +- Preserve `.gitignore`-aware traversal and current sorting behavior. +- Improve `--help` with examples for recent Rust work, recent directories, and JSON pipeline usage. + +### `pathshadow` + +- Append stable explanation fields to JSON output so scripts and AI can tell why an entry won or lost. +- Include PATH rank and winner path context where applicable. +- Keep text output compact but clearer about origin and shadowing reason. +- Improve `--help` with explicit PATH diagnosis examples. + +### `portping` + +- Add `--method GET|HEAD` for HTTP(S) requests. +- Add `--expect-status` to turn mismatched HTTP status codes into probe failure while keeping actual status visible. +- Preserve TCP behavior unchanged. +- Improve `--help` with health-check and HEAD examples. + +### `binmeta` + +- Input: one or more file paths from argv or stdin lines. +- Text output: one concise summary line per file for human scanning. +- JSON output: one object per file with stable fields for automation and AI use. +- PE analysis: + - file kind, machine/architecture, subsystem, executable vs DLL hint, PE timestamp + - section summaries + - imported DLLs and imported symbol count summary + - exported symbol count summary when available + - SHA-256, file size, modified time + - heuristic hints for `.NET`, Unity, IL2CPP, Mono, and BepInEx-related artifacts +- Non-PE fallback: + - still emit hash, size, modified time, file extension, and "not_pe" kind without failing the whole command + +## Testing Strategy + +- Add focused unit tests for new parsing helpers and negative query logic. +- Expand integration tests to cover help examples and new CLI flags. +- Add `binmeta` integration tests against: + - a known text fixture for non-PE fallback + - compiled workspace binaries for PE parsing +- Keep the existing Jade verification gate unchanged. + +## Risks + +- PE parsing APIs can be noisy; the implementation should wrap parser details behind small helpers. +- `binmeta` hints must remain heuristic and clearly labeled as hints, not definitive claims. +- Help text examples must stay in sync with actual behavior, so tests should assert representative help fragments. diff --git a/docs/templates/daily-maintenance-template.md b/docs/templates/daily-maintenance-template.md new file mode 100644 index 0000000..fa1fb16 --- /dev/null +++ b/docs/templates/daily-maintenance-template.md @@ -0,0 +1,83 @@ +# Mercury Toolbox Daily Maintenance Template + +Use this template for small, behavior-preserving maintenance passes. Start from the durable backlog, choose a narrow scope, and stop with evidence instead of broad cleanup drift. + +## Header + +- Date: +- Worker or scope: +- Starting branch: +- Initial worktree status: +- Backlog source: `.codex-progress\quality-security-backlog.md` +- Candidate review quota: +- Actual candidate slots reviewed: +- Existing unrelated changes to avoid: + +## Scope + +- Owned files or surfaces: +- Explicitly out of scope: +- Other workers expected nearby: +- Conflict-avoidance note: + +## Candidate Scan + +List only concrete, evidence-backed candidates. Prefer stale references, duplicated docs snippets, recipe naming consistency, metadata drift, and tiny verification hygiene. + +| # | Candidate | Evidence | Risk | Decision | +| --- | --- | --- | --- | --- | +| 1 | | | low \| medium \| high | land \| skip | + +When a worker receives a numbered quota, keep this table countable even if most entries are skipped. Use one row per backlog item or directly evidenced follow-up. +Group obvious no-edit candidates by range only when the reason is identical, for example generated assets that must be regenerated by script or code-owner items outside the worker's surface. + +## Landed Changes + +- [ ] Tiny change: +- [ ] Tiny change: +- [ ] Tiny change: + +## Skipped Candidates + +- Candidate: + Reason: +- Candidate: + Reason: + +## CI And Runner Notes + +- Runner or CI files inspected: +- Toolchain snapshot expectations changed: yes | no +- Release or publish path touched: yes | no +- Runner credential or token surfaces checked: yes | no | not applicable +- Secret-residue checks: + - `git remote -v`: + - `.git\config`: + - Targeted `rg` scan: + +## Verification + +Choose the smallest proof that covers the touched surface. + +```powershell +just --summary +just ai-check +just periphery-check +cargo fmt --all --check +just --fmt --check +pwsh -NoProfile -File .\scripts\check-powershell.ps1 +pwsh -NoProfile -File .\scripts\check-ai-prompt.ps1 -Configuration ReleaseFast -SkipBuild +pwsh -NoProfile -File .\scripts\check-ai-skill.ps1 -Configuration ReleaseFast -SkipBuild +``` + +- Commands run: +- Outcomes: +- Commands intentionally not run: + +## Handoff + +- Changed files: +- Micro-optimizations landed: +- Follow-up backlog items: +- Commit: +- Push: not run unless explicitly requested diff --git a/docs/templates/jade-feature-template.md b/docs/templates/jade-feature-template.md new file mode 100644 index 0000000..50a46d0 --- /dev/null +++ b/docs/templates/jade-feature-template.md @@ -0,0 +1,133 @@ +# Jade Feature Template + +Use this template for any new Mercury Toolbox feature, command, subcommand, output mode, parser path, generated asset, or cross-tool behavior change. Keep the filled copy close to the work item, then remove sections that truly do not apply before merging. + +## Header + +- Feature: +- Owner: +- Date: +- Status: draft | implementing | verifying | ready +- Tracking issue or branch: +- Affected crates, scripts, and docs: + +## Intent + +- User problem: +- Primary workflow: +- Non-goals: +- Compatibility promise: +- Rollback or fallback plan: + +## User-Facing Contract + +Write the exact CLI shape before coding. + +```powershell + --help + --help + --json + --toon +``` + +- Text output: +- JSON schema or fields: +- TOON shape: +- Exit codes: +- Stdin behavior: +- Path, glob, and missing-root behavior: +- Windows-specific behavior: +- Cross-platform behavior: + +## Codebase Fit + +- Existing command or helper to mirror: +- Shared helper APIs to reuse: +- New dependencies: none | list with reason +- Feature flags or platform cfgs: +- Generated files that must be regenerated: +- Packaging or install changes: + +## Test Plan First + +Write the first failing test before implementation. + +- Red test command: + +```powershell +cargo nextest run -p --test +``` + +- Expected failing assertion: +- Green command after implementation: +- Regression cases: +- Parser/help contract cases: +- Error-path cases: +- Platform-specific cases: + +## Implementation Checklist + +- [ ] Add or update failing tests first. +- [ ] Reuse `common` helpers for shared CLI, path, stdin, format, color, and error behavior. +- [ ] Keep public Rust APIs documented, including `# Errors` and `# Panics` where applicable. +- [ ] Avoid `.unwrap()`, `.expect()`, and `panic!` in production paths unless a narrow `// jade: allow-panic because: ` comment is justified beside the call site. +- [ ] Keep subcommand `--help` aligned with parser-supported flags. +- [ ] Preserve literal paths unless a documented glob mode is explicitly requested. +- [ ] Keep `--json`, `--toon`, and `--format text|json|toon` behavior consistent with the toolbox contract. +- [ ] Update README or command docs when user-facing behavior changes. +- [ ] Update `docs/ai/toolbox-ai-prompt-notes.json` when AI routing, examples, caveats, or command coverage changes. +- [ ] Regenerate generated AI assets instead of hand-editing them. +- [ ] Update package/install scripts only when the shipped artifact surface changes. + +## Jade Verification + +Focused loop: + +```powershell +cargo fmt --all --check +cargo nextest run -p --all-targets --all-features +cargo clippy -p --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery +``` + +Docs or generated AI asset loop: + +```powershell +just --summary +pwsh -NoProfile -File .\scripts\check-powershell.ps1 +pwsh -NoProfile -File .\scripts\check-ai-prompt.ps1 -Configuration ReleaseFast -SkipBuild +pwsh -NoProfile -File .\scripts\check-ai-skill.ps1 -Configuration ReleaseFast -SkipBuild +``` + +Shared behavior or broad refactor loop: + +```powershell +$env:CARGO_INCREMENTAL = '0' +cargo nextest run --workspace --all-targets --all-features --no-fail-fast +Remove-Item Env:\CARGO_INCREMENTAL +``` + +Full Jade gate before claiming repository-clean status: + +```powershell +pwsh -NoProfile -File .\scripts\check-jade.ps1 +``` + +If a hardening gate cannot run on the current machine, use only the specific `-Exempt*` flag with a non-empty reason. Do not add a CI skip, broad allow, or permanent template bypass. + +## Review Checklist + +- [ ] The diff is scoped to the feature and does not include unrelated cleanup. +- [ ] Existing behavior is either preserved or deliberately documented as changed. +- [ ] Help text, parser support, README/docs, generated AI prompt, and skill catalog agree. +- [ ] Tests cover success, failure, and format-specific output where relevant. +- [ ] `cargo nextest` is the test signal; plain `cargo test` is not used as the main proof. +- [ ] Verification commands and outcomes are recorded in the final handoff. +- [ ] Worktree is clean after commit or intentionally lists follow-up files. + +## Handoff Notes + +- What changed: +- Tradeoffs: +- Verification run: +- Known follow-ups: +- Commit: diff --git a/docs/templates/release-notes-template.md b/docs/templates/release-notes-template.md new file mode 100644 index 0000000..e8ff339 --- /dev/null +++ b/docs/templates/release-notes-template.md @@ -0,0 +1,71 @@ +# Mercury Toolbox Release Notes Template + +Use this template when publishing a release on Gitea. Keep it short, concrete, and oriented around what changed for someone installing or upgrading the toolbox. + +## Title + +`Mercury Toolbox vX.Y.Z` + +## Summary + +One short paragraph: + +- What kind of release this is. +- Who should care. +- Whether there are any rename, install, or compatibility notes. + +Example: + +> Mercury Toolbox vX.Y.Z focuses on local inspection and packaging polish. This release updates the hashing command surface to `mhash`, refreshes the packaged Codex skill and AI prompt assets, and keeps the Windows + PowerShell install path aligned with the current release bundle. + +## Highlights + +- `Command / feature:` what changed and why it matters. +- `Command / feature:` what changed and why it matters. +- `Packaging / docs / skills:` what changed and why it matters. + +## Breaking Changes + +- `Old name or behavior -> new name or behavior` +- `Migration note` + +If none: + +- None. + +## Install Or Upgrade + +Fresh install: + +```powershell +pwsh -NoProfile -File .\scripts\install-toolbox.ps1 +``` + +Portable package install: + +```powershell +pwsh -NoProfile -File .\dist\MercuryToolbox--ReleaseFast\scripts\install-package-toolbox.ps1 +``` + +## Verification + +- Built with: `ReleaseFast | ReleaseSize | Debug` +- Key verification commands: + - `cargo nextest run -p --all-features` + - `pwsh -NoProfile -File .\scripts\check-powershell.ps1` + - `pwsh -NoProfile -File .\scripts\generate-ai-prompt.ps1` + - `pwsh -NoProfile -File .\scripts\generate-ai-skill.ps1` + - `pwsh -NoProfile -File .\scripts\check-ai-prompt.ps1 -Configuration ReleaseFast -SkipBuild` + - `pwsh -NoProfile -File .\scripts\check-ai-skill.ps1 -Configuration ReleaseFast -SkipBuild` + - `pwsh -NoProfile -File .\scripts\package-toolbox.ps1` + +## Assets + +- `MercuryToolbox--ReleaseFast.zip` +- Optional extra artifacts + +## Notes For Upgraders + +- PATH reload or shell restart note if relevant. +- Config, skill, or naming migration note if relevant. +- Any intentionally removed compatibility surface. diff --git a/fixtures/binaries/stringscan-sample.bin b/fixtures/binaries/stringscan-sample.bin new file mode 100644 index 0000000000000000000000000000000000000000..46acbe0fbdd511f9de65a150fc55a6dc68e70a7a GIT binary patch literal 244 zcmY+7Jr2S!422Dxhg7T`DOJVL39+CoX=;^7&X1B5np<$=Vh{_mWWUdTdhTy8*a5&j z=aA~EvVKeg7i9yX(B4)mnCfwWb<@ fixtures/reading/sample.rs:25:11 + | +25 | match modee { + | ^^^^^ not found in this scope + +warning: function `helper` is never used + --> fixtures/reading/sample.rs:33:4 + | +33 | fn helper(value: i32) -> i32 { + | ^^^^^^ diff --git a/fixtures/diag/unity-errors.txt b/fixtures/diag/unity-errors.txt new file mode 100644 index 0000000..70de311 --- /dev/null +++ b/fixtures/diag/unity-errors.txt @@ -0,0 +1,2 @@ +fixtures\reading\sample.cs(9,17): error CS0103: The name 'speeed' does not exist in the current context +fixtures\reading\sample.cs(4,21): warning CS0168: The variable 'unusedValue' is declared but never used diff --git a/fixtures/hits/rg-output.txt b/fixtures/hits/rg-output.txt new file mode 100644 index 0000000..b0933bf --- /dev/null +++ b/fixtures/hits/rg-output.txt @@ -0,0 +1,4 @@ +fixtures\reading\sample.rs:18:pub fn run(args: &[String]) -> Result<(), String> { +fixtures\reading\sample.rs:22: Mode::Fast +fixtures\reading\sample.rs:26: Mode::Fast => println!("fast"), +fixtures\reading\sample.cs:9: private int ComputeScore(int baseScore) { diff --git a/fixtures/json-family/ison/README.md b/fixtures/json-family/ison/README.md new file mode 100644 index 0000000..d7d1687 --- /dev/null +++ b/fixtures/json-family/ison/README.md @@ -0,0 +1,6 @@ +# ISON Fixtures + +These fixtures track the public examples on https://ison.dev/: `table.` +block syntax for ISON and `object.|fields|values` line records for ISONL. +The local v1 implementation covers deterministic JSON object and JSONL record +roundtrips without adding a Node, TypeScript, or external runtime dependency. diff --git a/fixtures/json-family/ison/users.ison b/fixtures/json-family/ison/users.ison new file mode 100644 index 0000000..21d8b86 --- /dev/null +++ b/fixtures/json-family/ison/users.ison @@ -0,0 +1,4 @@ +table.users +id:int name:str active:bool +1 Ada true +2 Bob false diff --git a/fixtures/json-family/ison/users.isonl b/fixtures/json-family/ison/users.isonl new file mode 100644 index 0000000..0cb34e4 --- /dev/null +++ b/fixtures/json-family/ison/users.isonl @@ -0,0 +1,2 @@ +object.record|id:int name:str active:bool|1 Ada true +object.record|id:int name:str active:bool|2 Bob false diff --git a/fixtures/json-family/ison/users.json b/fixtures/json-family/ison/users.json new file mode 100644 index 0000000..147b638 --- /dev/null +++ b/fixtures/json-family/ison/users.json @@ -0,0 +1 @@ +{"users":[{"id":1,"name":"Ada","active":true},{"id":2,"name":"Bob","active":false}]} diff --git a/fixtures/json-family/tonl/README.md b/fixtures/json-family/tonl/README.md new file mode 100644 index 0000000..c3f4dcb --- /dev/null +++ b/fixtures/json-family/tonl/README.md @@ -0,0 +1,15 @@ +# TONL v1 fixture assumptions + +These fixtures define Mercury Toolbox's Rust-native TONL v1 surface for tests +and examples. + +The representation is JSON-backed and line-oriented: + +- A document object is written as `key = ` per non-empty line. +- Record streams are separated by `---`. +- Lines beginning with `#` are comments. +- Values after `=` must be valid JSON literals, arrays, or objects. +- Non-object root values are represented by the reserved `$ = ` key. + +This keeps TONL deterministic, streamable, and easy to convert to and from JSON +or JSONL without a Node/TypeScript runtime. diff --git a/fixtures/json-family/tonl/invalid.tonl b/fixtures/json-family/tonl/invalid.tonl new file mode 100644 index 0000000..a67b1be --- /dev/null +++ b/fixtures/json-family/tonl/invalid.tonl @@ -0,0 +1,3 @@ +# Invalid because TONL values must be JSON values. +name = "Ada" +score = not-json diff --git a/fixtures/json-family/tonl/records.jsonl b/fixtures/json-family/tonl/records.jsonl new file mode 100644 index 0000000..9645a8f --- /dev/null +++ b/fixtures/json-family/tonl/records.jsonl @@ -0,0 +1,2 @@ +{"id":"user-1","name":"Ada","active":true,"score":42} +{"id":"user-2","name":"Bob","active":false,"score":7} diff --git a/fixtures/json-family/tonl/records.tonl b/fixtures/json-family/tonl/records.tonl new file mode 100644 index 0000000..703f99f --- /dev/null +++ b/fixtures/json-family/tonl/records.tonl @@ -0,0 +1,11 @@ +# TONL v1 stream fixture: records separated by ---. +--- +id = "user-1" +name = "Ada" +active = true +score = 42 +--- +id = "user-2" +name = "Bob" +active = false +score = 7 diff --git a/fixtures/json-family/tonl/sample.json b/fixtures/json-family/tonl/sample.json new file mode 100644 index 0000000..d439574 --- /dev/null +++ b/fixtures/json-family/tonl/sample.json @@ -0,0 +1 @@ +{"id":"user-1","name":"Ada","active":true,"score":42,"tags":["math","logic"],"meta":{"city":"London"}} diff --git a/fixtures/json-family/tonl/sample.tonl b/fixtures/json-family/tonl/sample.tonl new file mode 100644 index 0000000..126c755 --- /dev/null +++ b/fixtures/json-family/tonl/sample.tonl @@ -0,0 +1,7 @@ +# TONL v1 fixture: JSON-backed key/value lines. +id = "user-1" +name = "Ada" +active = true +score = 42 +tags = ["math","logic"] +meta = {"city":"London"} diff --git a/fixtures/json-family/zon/invalid-nested.zon b/fixtures/json-family/zon/invalid-nested.zon new file mode 100644 index 0000000..d23fef0 --- /dev/null +++ b/fixtures/json-family/zon/invalid-nested.zon @@ -0,0 +1,3 @@ +name:"Ada" +profile: + city:"London" diff --git a/fixtures/json-family/zon/person.json b/fixtures/json-family/zon/person.json new file mode 100644 index 0000000..32f50e4 --- /dev/null +++ b/fixtures/json-family/zon/person.json @@ -0,0 +1,10 @@ +{ + "name": "Ada", + "active": true, + "score": 42, + "tags": ["math", "logic"], + "profile": { + "city": "London", + "year": 1843 + } +} diff --git a/fixtures/json-family/zon/person.zon b/fixtures/json-family/zon/person.zon new file mode 100644 index 0000000..1134ebd --- /dev/null +++ b/fixtures/json-family/zon/person.zon @@ -0,0 +1,5 @@ +name:"Ada" +active:true +score:42 +tags:["math","logic"] +profile:{"city":"London","year":1843} diff --git a/fixtures/json-family/zon/records.jsonl b/fixtures/json-family/zon/records.jsonl new file mode 100644 index 0000000..ff220fe --- /dev/null +++ b/fixtures/json-family/zon/records.jsonl @@ -0,0 +1,2 @@ +{"name":"Ada","score":10,"active":true} +{"name":"Bob","score":11,"active":false} diff --git a/fixtures/json-family/zon/records.zon b/fixtures/json-family/zon/records.zon new file mode 100644 index 0000000..7277d9c --- /dev/null +++ b/fixtures/json-family/zon/records.zon @@ -0,0 +1,3 @@ +@(2):name,score,active +Ada,10,true +Bob,11,false diff --git a/fixtures/jsonl/events.jsonl b/fixtures/jsonl/events.jsonl new file mode 100644 index 0000000..017c3fa --- /dev/null +++ b/fixtures/jsonl/events.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-04-20T12:00:00Z","level":"info","msg":"boot complete","component":"engine"} +{"ts":"2026-04-20T12:01:00Z","level":"error","msg":"failed login","component":"auth"} +{"ts":"2026-04-20T12:02:00Z","level":"warn","msg":"cache warmup","component":"cache"} +{"ts":"2026-04-20T12:03:00Z","level":"error","msg":"login throttled","component":"auth"} diff --git a/fixtures/jsonshape/events.jsonl b/fixtures/jsonshape/events.jsonl new file mode 100644 index 0000000..f2c86a9 --- /dev/null +++ b/fixtures/jsonshape/events.jsonl @@ -0,0 +1,2 @@ +{"event":"login","user":{"id":7,"name":"Ada"},"ok":true} +{"event":"logout","user":{"id":7,"name":"Ada"},"ok":false,"error":null} diff --git a/fixtures/logs/repetitive.log b/fixtures/logs/repetitive.log new file mode 100644 index 0000000..b040c76 --- /dev/null +++ b/fixtures/logs/repetitive.log @@ -0,0 +1,5 @@ +2026-04-21T12:00:01Z INFO worker=12 user=42 connected to tcp://127.0.0.1:8080 +2026-04-21T12:00:02Z INFO worker=18 user=57 connected to tcp://127.0.0.1:8080 +2026-04-21T12:00:03Z ERROR worker=18 user=57 failed request id=991 path=C:\games\demo\mods\plugin.dll +2026-04-21T12:00:04Z ERROR worker=22 user=98 failed request id=992 path=C:\games\demo\mods\plugin.dll +2026-04-21T12:00:05Z WARN worker=22 retry in 3s for request id=992 diff --git a/fixtures/managed/FixtureSupport/FixtureSupport.csproj b/fixtures/managed/FixtureSupport/FixtureSupport.csproj new file mode 100644 index 0000000..6cf795a --- /dev/null +++ b/fixtures/managed/FixtureSupport/FixtureSupport.csproj @@ -0,0 +1,11 @@ + + + net8.0 + disable + disable + FixtureSupport + none + MercuryFixture.Support + latest + + diff --git a/fixtures/managed/FixtureSupport/SupportTypes.cs b/fixtures/managed/FixtureSupport/SupportTypes.cs new file mode 100644 index 0000000..f9b1082 --- /dev/null +++ b/fixtures/managed/FixtureSupport/SupportTypes.cs @@ -0,0 +1,22 @@ +namespace MercuryFixture.Support; + +public interface ILaunchable +{ +} + +public abstract class LaunchVehicleBase +{ + public int QueueSize; + protected int HiddenCounter; + + public virtual string StartLaunch(int count) + { + HiddenCounter += count; + return $"launch:{count}"; + } + + protected void FinishLaunch() + { + HiddenCounter += 1; + } +} diff --git a/fixtures/managed/GameAssembly/GameAssembly.csproj b/fixtures/managed/GameAssembly/GameAssembly.csproj new file mode 100644 index 0000000..88bb23c --- /dev/null +++ b/fixtures/managed/GameAssembly/GameAssembly.csproj @@ -0,0 +1,14 @@ + + + + + + net8.0 + disable + disable + GameAssembly + none + Game + latest + + diff --git a/fixtures/managed/GameAssembly/GameTypes.cs b/fixtures/managed/GameAssembly/GameTypes.cs new file mode 100644 index 0000000..93c31da --- /dev/null +++ b/fixtures/managed/GameAssembly/GameTypes.cs @@ -0,0 +1,65 @@ +using MercuryFixture.Support; + +namespace Game.UI.Windows.Windows +{ + public class SpaceCraftConstructionWindow : LaunchVehicleBase, ILaunchable + { + private int _buildTicks; + private static int GlobalProjects; + + public string ProjectName { get; private set; } = "Mercury"; + + internal int QueueDepth { get; set; } + + public bool Completed => _buildTicks > 100; + + public void StartProject(int units, string tag) + { + _buildTicks += units; + ProjectName = tag; + GlobalProjects += 1; + } + + private int FinishProject(bool instant) + { + if (instant) + { + _buildTicks = 999; + } + + return _buildTicks; + } + + public override string StartLaunch(int count) + { + return base.StartLaunch(count); + } + } + + public class SpaceCraftProjectWindow + { + public void QueueVehicle() + { + } + + internal void CancelProject() + { + } + } + + public class SpaceCraftInfoWindow + { + public int BuildProgress { get; set; } + } +} + +namespace Data +{ + public struct SpacecraftConstructData + { + public float buildTime; + public float progress; + + public string VehicleName { get; set; } + } +} diff --git a/fixtures/managed/bin/FixtureSupport.dll b/fixtures/managed/bin/FixtureSupport.dll new file mode 100644 index 0000000000000000000000000000000000000000..0d373c25b131ddb38e150165458236ec9e812ff1 GIT binary patch literal 4096 zcmeHK-ESP#7608`d$VyGCr<9zcRe`qcKN%0ne0!~;SCAtAL-(5k2}RV(oawCYPAs!DD8J9lQ|wXKG# z5)uzR>wE4!ANSmI&OLMQ^|^1pjy!-qwwpJBci0O|8GL(mjpD)k-yOu;dp~;M9kuk) z18eJEY_+4X896P>b%G#FteUo>PGEU~HFv&hwZgi#_wOHgC^Nk>4=kxXe)sXEx4Wx- ziU+NIsst3tDWu$|kFmGdrr3akW}Hj?7KZq5;2JUc0(o5dv8c-5R-ci=BK-Xk;Jgf` zfL}@?cET0lOXU5LdiU;>-Hx_W_q!@Z(r3E_dppq^3Hy~jL>8OGn)%I<0SmZdM{(qm zGQLf5Ab}3B^}rOpwd~#Bp?1xYMDJ>2Efc3=XuConSlgJC2v1t5_dAo!C z@UvAE`_md2e(Kr9Ir5H>zE|eX_+d?y37oDj-kHN**_VH)GVV%qA}N(Jq1^PGX3A?H zdl~mO-~%PJ6mh?r&lPad;HNN_I{-}+1w?pJ6>vuVf%szxe#PMH20v{0&zbRGAQqC` zC)D{T?TFpfy5!~O^x_`8ByRKf5~)+9P8q6zj+$WM9L-H&#gciyQom4-Q}&vneuSUU zW)441sUs)2j+j??A-5MNatcGl1$>!!0AD3Oh?53S8a!?AqQTD?{0)O0VukM#-$VSC zI!HVKi}=rK6dw5n9K&-MQa1Ks3N{X7k@zcECmyDrjZwTwT*imQkK?z*v-lJ7*A2f) zd?)&@bu&Sy4ZK13W#hW=DERpj?&Erdubm6)9bccpbi1vC`a&4BoFvh8EG{{nz+HE0 zzD9L3PISv&?F5O}(nuv2^t$Kzdd7)0=5(#oY>Mu5!a}Qc5{>CN)~%Yqx#lIk>@_E9 z>STeZqJBP%zO!3@!SnS69mQT4>=rHt4dWq&j{hIEX2YQ2H9L`$Y%{W|8&#*Flg(8| z9B&z)4O?x`H_EHpcQ(wZN8XKOC1S4JWNTtqAO@UZb4!qA%J37f=J{UIBYmc$JG$z9 zZ)aldEFgZUSnvWbUQaWPYT`smHct0x$xC$P_*C0SI+3n++U+n(?7Ht~n{{i?bi>!K zUAuFh z(+t8m@!Yt}%7V7jbn2+8qpO~)E*;`P#SX5nM5gb2@T6(PkEhodvQZF4pM* z9}YSQ;PQ(&EtQMcejWU5`PilT_doQ$^_xR4zlAgQl+NXWsyJ!)vO-^!_C zu~1S<9Yub?C=C{GJbC%Wq2CXj&J~JO9V`}A_Mse=b7L)c*Tu+bFNeYWhO66>!L{`$ zd_GpxlOq9mLg9$LJij&@Mw;7lBHO+vu1?q`8W_CeR+Z0raocw`ms#{hnPE}mu)^T> z?cr6?bxMqT$v~l`G{+ zW4x?OWBlwD&yw9>o&Y^;E4%lEby~)jjCMo!eoQ5o8TFj+pL4tbt=MG>v?)K4-h7xg zb}qBq;1`;sOaMoKYunvSZ(i=mF23l=+u_m_-!gJBX0Vd4aC8AxTqd5!Do2ZaX)Tjp zBwk2gU3jnWum4I<@ANL~p5#wvc6e=Pg_kkd$(^le)#2^Azmc`fjkA6@5ym<&n?BXP2c{jZO*OUj;RefipP3xwEv_Lffpd$%q_M zEMS6I+MwkQZS%Ne-{vnQB`-da%lz(obJU6Xg^M5UZGL{1of1FYEjt%zC!(dTI8XAP zEZu6d?qTXnrV^tY&}YBL^V_XwqfT9UWdS4H5rrk%Xc`NWi8eM(1Wle)5~anqY}PxU z@g>W$UN>%`(v1DL>!x{Mp}mmabeOZG$NSID`w#Ib{oX!*XV$iIHp+U^{FT@$^z|`} zW0JfvQx6Vto!oKuF6Ay|W0;`aWtE)bi=|>(gBoRHg=Z6C!tB{J_bDc~MO~knvslgB bcW=uN*~8Bftn`0|rSZ>kjsKhe!e-##xV%O7 literal 0 HcmV?d00001 diff --git a/fixtures/managed/bin/GameAssembly.dll b/fixtures/managed/bin/GameAssembly.dll new file mode 100644 index 0000000000000000000000000000000000000000..88a1a8f8f55f78f73cfc604efebb53ae7f2e560e GIT binary patch literal 5120 zcmeHLU2Ggz6+Sa#Z`MC?;~ViIfE#7>>GY3hI1Ax=`SF1++@c$q)i5ZBcExmT-w$H5pv|JZ8tl#98o7EnMC^hPP8WKFe=ezq4gwg7I6GhR)COmG3eCS*p&aRwkgXZlqcZ# z2ou9Z-{wTDEvJ$&`eFBpwYod;CQ-IQp9TL!18>D@DF%O~0|1@OHH{mb6qD$b6-J>0 zPGTDd!4vI5Z79Qt-U^i$z>rf$r%>6~U8oIZm}sN}p*6UI1ko@IJVADYZFt)RD@ zSm9>JywTsGn}3G$#6&vtczsAq`;LQph}f&=c`Ute6N@tVZPWX(3)<11!?Gmd2vJvH zs-yXXqgpz1&f}RuD`ySl26pY^JhYMrd>`ZXK0|a8amA>;`8ag_a>N!_;qV@`y~pzO z4rtzXy;H}g$I)H~-;cn0N4=l`o(q;pJHF+L*2c^YZGi4g^3KbKsmlK8PFr3N_O}%k zA&8#)v7DEh=q;UTJ}{knkCCBJ@UYfpY@!i#$dkr#x% zuCa$L+BMh=2t5IB?bM@P2Y#QfL;Dlsnhczb%6V(kHd8x&lJ7>T{$T6{_haEM z(5uD)MEMKheoimzAEOQQ+r)Bf78%LXJ88ck!7fYh3U`ryCAz;fS-zie*^@>Nmj6sS z-_XEmJ%n;ed?ssrnYRixBHsr(eM{u;g4XCg!OOZq6Vygocm{e zU@xrzchYOX0lEr&l>P`DqrU<_C-OXSSp1w4v;|iLuL!;__%p#j32GXT`?etIOzW7x zfqb;ex%eGMAo{!D@AWCr4`DQ-(3pY^TWV<8-hjP^6>gQj8$f5UraEdHa1-XlySRf| zR+5tjCuu3-?Bht^F85Pb4nlGo>f#PqrDgw2P^@|C0OhN;qnyw##bbdV#bM2f$L-jr zvWgeRf=bm>u`1I3d9~;|o;qw-)Pd6r3!}Dk8u!ekt2`)=hQZUyi9cBRWKGr7xT?mB z>pPCtT(5}U?%F~vRy3{(wQ`x`T$PPP zk*XBDJ) zq$w78>=s#lDoU3eVc{HsT}Zc-b2^gPnl`qC z*DR@~d!DB7{k6Sxb4FD2-8dqS-#RvJ9e4d=a5k#9bfmz?j3!;*jTX~BG-msbs(VP1 zR9Cq;(6%3w6JUSSj0&AvxIF1D#kEjb`C7FagzQcDIxF|VSkrxgL?w|VmxU;KC$1Bw zC!W7)rHEf-fHSvmJPFbctP9;aUbcp1e05F8$ST%^m~to zcJ*-JPb@jOkT9>g#SjVBV5dtwk7=~kIy^Bq7KCb~TJ2A_-(zR?S~)oAy65)E9e1Ou zXD{=em*pNNEV?!7Y@T(eMjLRb6kj03%r3m)@FToq$P#8}NvWI~v>zVYSK6J+*}0-U zlye51eY^LToWh=g!GXcj(16PA#ygR(bHvJz^yb2EvBpwO-_b04mT8lz^`-vaPBggi z!*S1>v0a}kk%Js4IX^JA$l&eqMV1Mx}2(>TzfzoKKFS z+7#oReDI4=yB+U5e1!0_I=@|bUFB|9{-duO_MD+uY<rW@5NnE%?UAXWS@%zur^xn3}!o(H+xAV>aI{*3q z|C~9eCtrLUjR2bX+_?i>=Ub@H;Jsmq=FUey>%=E*H+EcCV>fn8&%2RT34>DTl&zxU z`$23Kv=uJ+mg`$X6M3r~%xb%%qkVT8dU6;TQ;pctd-2_Dwp-X~ty4Lmg&KKCZ|)&j zeERqR>kZ$K2B&knU0&{1;5 z3?GSQ1in-10|qiad(X2jA<3-)%{XHLUvLg3%6~yyLXNCFiGloy0hLCPG!}d8oZX%4 z-hWtvHp3%&blvqD@Eza33ZIWd*Dpolag%Ey%eL!O9=9_7*s0MW>VBqtQ)~lXP~cmL z<*H*FfHo0+SKZi@)XV zMEnSX^9C;%Y%uymhX2|`_(4@o$Rw_G;0?7NKdN>dB(E@O$$Xb_No}Xph7XuQ7qJD8 z5ZmztaXt1MJZ!Mv;Hbgp48CB{Au2qEm+&QQ*0q!PF!mC+;skLQ^2EJ(huDoP#Ha8v zaS)#ppEdeCv1v)VC%=qtGx_>0wh!<`$5zMzJp2Qwx6V zmNkZSVWCuV3ZCwdxuS)ISff9Rbh+SFr`@<-J?(@g9gpz1(C32i)wTX3uBT7yFmeNb zt#Q( zAiaQHjDs-q8FHPHA4IWRj51Z4iJjD_!@Ld`-J*`tCF7)AU5XQhR~UUc0a^WZ2e@mJ zN5uF4xc%O~-!2`$eDJH0UmC7$LzAUcYoi6#Os1<##Lm{MN6)_A^-X&(KiJMzm2Rr- z@*PY!YkFQpxmu7@Ug~{gSPCL}9Z%K0G}bgu1^{*_Rf2Wbu%lV}Q;L z%iCtijVhi~9p^4;l@S&_Iyha{K%RsO4{$ZL6X`@aV9PHDv@0|lfqPz=<3Y+>LFW3X z)4ZIXnW9=0-V$ytWoK(*ImL6qsmv@O~OdoRyjKgj-YMNGu+K6@sG5rEc30-Znn5$w= zF}+;sOLvL%C(l8J-14sn1@hom&_?|rO%{Za1! IRj%y7-=25L*Z=?k literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-bin/compile/MissingOnly.dll b/fixtures/managed/diagnose-bin/compile/MissingOnly.dll new file mode 100644 index 0000000000000000000000000000000000000000..ad8d96beaa986a2741535324d3615d3913827008 GIT binary patch literal 3584 zcmeHJO>9(E6#g##v6R0yBEd-TK_Pws9BYx+;dF`~Nn7D93Jk$H#dw%Y@ z=bU@ry&ZkyDp~-vvORbJ++r`3HTYz-PWVG!P-HK} zRMp{)z-nRsP<_~x#J?`lnMl@fEKTx~29#COx8n}JQ0>^Q1fRyoY6I?2)s7qvQ_`g_ z6R$(?ZG)E#{%-I^gWnhr_tXXBGVv=Fcvp4ds=BZCa;z|6$+{oHQCrEa#x+LJO_Wuy zsZQ)BcA?kceuD!BM-0AZ@U%gPsPG)##J8}S*LLEQ*h72s(q524?6!gKs`wPoXna0k4b((=|`OihMnab;X{n`LSEk$mwFOTy~0{ z9*DV(#agT}5JkFD^y&pSZWb4uu&m=@-Vgdh5T09ZKkRz?qz)rD@Rut`{FxxEI5Cxu zx1!Zx;Lo__T4?MRoJ{JOyfdTY`XqCR7UY9LrRsXdcv5@LMKfyBr-6?PV)TX2)h&qVQQ6gow6T9v0I8#Q6nfjNmqw?9iDefI%@15SLE)J9WS1x|J1oeksg3k zSAKl|*hjw&e{%iwr}0nsHlWo~s-wk%YU7~0TiEp-HxHh<()~l{OZ-q(hiV+At#bYg zt2Uhqo$6TN4_z$js^n0Z4TB4j*zqGK9Z=Y8j|~+DgHR7tt1mQ)bKv|wJ3|NSH_o4& zoEud=r#{Am)FCr0T68I_X{sJCK!qo`r`m~h!u0Yc;t%LlXfq6tJ7=XS&iCfYnCF0r z#yu&bvof}1w4BoWG|`-49y!k&bzC2nsKlhS*;3Iy*hwEtms#%cKb0sG&}KeMirtTAp){i2K}@@VTtYWn`{`6MrkGYP z<>iNX>M}MNq`!U?IR^IeVO6|K15{8oYt)&MLyo76G)j*Ve~I65diNk7QP#{$BhJw} zLdmROT@>F+^E3RI)O?&LIX^V3q@*qEl3ywSV( zQ7Rng+_IS|8CRK!RIH3ydWHE6j+k}E8*<57)=BdeE~(5vt(Vkwf_?(VP-AW4rf65L zYld$%U)M$RmR4t>HnM+G#{siPnz0obZJn8{JV)yKn39$JoA}P&$27nH(|TkD{sweB B(boU~ literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-bin/compile/RuntimeDependency.dll b/fixtures/managed/diagnose-bin/compile/RuntimeDependency.dll new file mode 100644 index 0000000000000000000000000000000000000000..5aaa638eeed142b8426be338f4cce61cc8041bcf GIT binary patch literal 4096 zcmeHKU2GIp6h3$RV=1z>3JnB-b|&Q5Q)1G6*3%xqy3 zqcssjj1MLzY79>XpM1e+A~7-YMoh#PW8w=555^}G-%K<{{LY=(pSFrh7%f7byzlcQeY)z(=8IzN%I5sM6=n&1ea)KoEwSsVVWKh7M7git{<=EbHM(D0EmA~nkhDh9 zqYt3h(9&o`9g0pwakGHqSFi{SIYSf8oMBV`tM@U>B0wAbPBM`uvbhk;VTR~V$bQ;N zbnkNAjd(2zziQG3{y>#MuZQwmMh(uiL7 zrR~CyW2G51_LW3yfHcuy1FOhATvq_cmY^2$G@_SUhy)A&13czqq?hPH9PJNnO7yJZ zzO_ctVD8|ch`n>H3;lXZ?@RUd?&8T>$OgUy7u_!tZAT6V(7JP>Z#l()EkfkK7Jc`T z9GwNtOWVEw$Y=)rd*FXye$aLc=ov6v>|aN;ikaU;A9Y2!acOwDF+@d!IB9iEN4bA2 zqP~^ZieE)5%_%%hKZ?6(1KhUKtMmrs%|d*_KaH zR-PDZNg#e+IB8<9p>_9bOx zpdd)* z$q1n|?7y`da-BJ=SoW1&-APu?<%~HQRWa8LN*-DT%eKM>?Sf&K<+6h7aWXiQ6dg^3t?jXl6?nE$nZTB7;}IGxx^RT!TGUI6t6tC@*aOlC zWW@C1D#Z^suAmmh;A{zXt*Iy)k$Msv1gZEPV9`OQHKtZV^}UER$B_%YZ--~nc#HW#jiIBdAJjDE z=os)YWkHSNOKSrBDDX)1)kR-4|NKYvCPeRH^?leE`{DOFIzzDlT?S3St3f{eL}z-G z9Gb%^XV{`%^*po&qyf&nK_Tp1a1mT+_TW??rVv&Zgp;?7ZSmT>l`KU(EHUXI`7H>(cH{&aX+ep;n zpN20V-s<`G;tfvK8qeJYd#+fhY#qdIH)Qb7bLz;&#*grwl7O!<_$ewcTon%)a#f2o zj~Pf^C3!XHWGv^3^PJWEnTx9TZ|08HVG2H7#8XE7!iFqvZXHtijuW)h$MOo+>qoRx r)p`K+4UlsQJHke`8g}E-s@KPG?QdbwqW>A5-*F4acssQJtpa}n26GX- literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-bin/compile/TestOnlySupport.dll b/fixtures/managed/diagnose-bin/compile/TestOnlySupport.dll new file mode 100644 index 0000000000000000000000000000000000000000..e029004bd42b594c0ca59c0ff628a4a825419f58 GIT binary patch literal 3584 zcmeHJTWl0n82%2uP>Pf`R)W#MKp~3AWSc?)Dzc?put+a<7sM-_-JWhoW@k1tv%pHC zm1yFlBb4`KMZVJJx4o@^5z#s55n@d6P3_0W)?Og(Y$(w=5@8Xsctg6ld*FdjSn4SIqDc zUxwkBQWiMam^a2*)+HpltwIM17I2j@lnDO;bw0u%`6CBa>OoRCz1z;%Lz$t$ed4eK z9?@fff!BcR5{S7DE41IP0CmUCIYfy2kfavXmo|CR6uy`acZh_9=| z{lFSwZmJ<{Nb+Bo=u9SSIhRMmCAF1RGIrt{EUHfIP=fd41GU~{)rlMqQ_`!>6E8vV zC4;XT{Mq0xgP$1>Kd5uYW#U&l@P_KeyXuD8!@0tg#o%}!j@m?S4K6Z+KB7dusJgL( z*o#4f`wR{n95eWg!RHJ*M1_a&0=|UJy0#MU#ZKY_I6@pip12bih(owcd>kJVM{t$+ zw2|kD?F)@=Ffl_nn0)K@x)0qA74XD(FkAEVlgQVjSXb=nnjgCrjhrsl%4MhM>EW2m zSggew!%?IwMXz3P<5qFO3ClV@#PdO)3Bpq=?GL%0KCZ*a4g8hLF@G)yD^5(MfULRMdjn=dvL;=u9i zO-0%lBahvp>$!1@c4_nMv?|RPDCFz1O z9(Tf1I)q({gRo(ebDgpuM6p|n8lp5SJE>QPc^#g1OFBxIj|p&j$xaquq4VWwq`P4A zh0CjV*6oq^&VBOoF6)a5+AXEJ+AOFJPWt+U?diI5;Nfvhj(X>JPPw%xebkNg(d-vqr zsOmZO32vq?dBdVbzry-u9ppi%u$pVDok%ClAWtOz0o@86hT*nX=7dNKO$T#zw1`fm z=cR~3nOibjNsV(g(VS#0InNt+TpyLF#KN>$R?*(tP9Mv|taSLFNR&xrBe1YETymPT zT`S~bCwIq*EbvRRej?(0H}N>np_9Z>OmjBIn|6Zy81YbY>*CY)pMFiwpybqSoJR+Y zm*gI3aciV<4=8$d5Yms!`$W^Hk2wTJzs=kp1!^5iBkCQ*vad(jl93y(3q+btpMyb)RO8byf>6g6U z$UJAPOD2!%l05&+Plg{>)6a4G2^qIp=OEAe%!NdpOO%%U#XQsO?1rr~MXZN0a z&bjBDd(OFc?!DtLy+;j1)QEQbHqmAD31}S4|*MK0F9_!(OMWc3pjoSOTdsbG|Uy#hED+W-hoG=WwJ z1Bl-6r0qhHW2G51_LW4dg8`yLb*v)Wxvl_?O`|4S)QQrlxnSXcfVa94L87~r5vETx zbhj~y6-ab<06o^Jb#LSzz7J)eJ0zl+IPQf$BW3iadVBWs#7$%a>&T<)b)s|VuQHtt zJj*HitTEOUmZR%vmZm`SGIt$5I+{j51O8X$57}-WJp;_e;mt(rnfX)n(v~pKP2m`i zt7s4(ZHQ>N0emgOdNk8^(XTbrDTPhitF_TI%$n&OeJGmgxcD9TH)8yW!q*h;SMrLY z*D2eIc3mW}9KBeiR_%hek+x}zT7oi=Y@yFUHPh$fx|%ken=2(mJ6+O#5Kk~oQ#!}? z4*ey1z?}-Yt-BFln8~|hJ?+*6bpSWhL%<1o6u6yw6yC4!pu(dHzo765g-u|1rLV+h zsw(`Z!e?nSe7#RX>!gp!(RPvr&1Tv|FVoLtpmI+FZ=!zSZS*Q|7ZrgYM@0-|_a*Q% zbPafjz6X9@$+N(XORM+YEu_vLMhxAwOOatkVkz&9)s`35kS!{ihUzFnnSUCS&yKsZ z6BiaRRF(DdQGS%3zrta!X~)mXnTM z%~i`74%J4a8B{z;X_>DSi)P-Ig8{a1z7j|p^nF>%L*EMO#W~X}%3uU%s66d@C-2lB z!92&L=Uc9G=isO_=XxbGz@Ta0Rcpv~=B#4HQ+hQg89A3V=VVaLpcZ~j9ww)hGtxHC zD5`Ee&Sc6%6$?SlFdkqBrc;71WI`n07_3D!36PgU_GBQ-E@Ar7(|$ zaDyqkQnVan*0v+7czMEsI-O;&a?X|oE)y>mMb5c;G;aAmW*IlVlhUJ{^n-{r3W4jz z4$_ucbX-5M3Vtk#k}{&b;AN$^U=^evtv>t2{pS;^UoZoj%)f>R)C9b3vXye#^@4ck zq-9w;v(hP44&hVLnEOWVX z^PYR!7tjm8odbfl8WY30A=i_G9?Vk8zU%=(*rRxY*bSrvfnr(6S&euHmU&>7sX==Igd~ML@$^=T+L5y}?q-)J%&@6QLlFOBh@yhp1a;Bgy{;*|mGBx`VYj+E vwb~HvQ*|6f?R?}`!m8THO2>{`S<#ve*Y!RHEFizVQ%~Ke`Td{PJuC1JC$lBJ literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-bin/server-a/0Harmony.dll b/fixtures/managed/diagnose-bin/server-a/0Harmony.dll new file mode 100644 index 0000000000000000000000000000000000000000..19faac9d1eb41bfd08f4eb35ec545d23d122a0eb GIT binary patch literal 3584 zcmeHJOKcTY82(OQw#8N&fglhZUhx5$Txdz4iu4V|($?OB_(5NnE%?UAXWS@%zur^xn3}!o(H+xAV>aI{*3q z|C~9eCtrLUjR2bX+_?i>=Ub@H;Jsmq=FUey>%=E*H+EcCV>fn8&%2RT34>DTl&zxU z`$23Kv=uJ+mg`$X6M3r~%xb%%qkVT8dU6;TQ;pctd-2_Dwp-X~ty4Lmg&KKCZ|)&j zeERqR>kZ$K2B&knU0&{1;5 z3?GSQ1in-10|qiad(X2jA<3-)%{XHLUvLg3%6~yyLXNCFiGloy0hLCPG!}d8oZX%4 z-hWtvHp3%&blvqD@Eza33ZIWd*Dpolag%Ey%eL!O9=9_7*s0MW>VBqtQ)~lXP~cmL z<*H*FfHo0+SKZi@)XV zMEnSX^9C;%Y%uymhX2|`_(4@o$Rw_G;0?7NKdN>dB(E@O$$Xb_No}Xph7XuQ7qJD8 z5ZmztaXt1MJZ!Mv;Hbgp48CB{Au2qEm+&QQ*0q!PF!mC+;skLQ^2EJ(huDoP#Ha8v zaS)#ppEdeCv1v)VC%=qtGx_>0wh!<`$5zMzJp2Qwx6V zmNkZSVWCuV3ZCwdxuS)ISff9Rbh+SFr`@<-J?(@g9gpz1(C32i)wTX3uBT7yFmeNb zt#Q( zAiaQHjDs-q8FHPHA4IWRj51Z4iJjD_!@Ld`-J*`tCF7)AU5XQhR~UUc0a^WZ2e@mJ zN5uF4xc%O~-!2`$eDJH0UmC7$LzAUcYoi6#Os1<##Lm{MN6)_A^-X&(KiJMzm2Rr- z@*PY!YkFQpxmu7@Ug~{gSPCL}9Z%K0G}bgu1^{*_Rf2Wbu%lV}Q;L z%iCtijVhi~9p^4;l@S&_Iyha{K%RsO4{$ZL6X`@aV9PHDv@0|lfqPz=<3Y+>LFW3X z)4ZIXnW9=0-V$ytWoK(*ImL6qsmv@O~OdoRyjKgj-YMNGu+K6@sG5rEc30-Znn5$w= zF}+;sOLvL%C(l8J-14sn1@hom&_?|rO%{Za1! IRj%y7-=25L*Z=?k literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-bin/server-a/NotManaged.dll b/fixtures/managed/diagnose-bin/server-a/NotManaged.dll new file mode 100644 index 0000000..bea555d --- /dev/null +++ b/fixtures/managed/diagnose-bin/server-a/NotManaged.dll @@ -0,0 +1 @@ +not a managed assembly diff --git a/fixtures/managed/diagnose-bin/server-a/RuntimeDependency.dll b/fixtures/managed/diagnose-bin/server-a/RuntimeDependency.dll new file mode 100644 index 0000000000000000000000000000000000000000..2a65c2deb6a28a0ba59542031be27312740eeeed GIT binary patch literal 4096 zcmeHKO>9(E6h3$QV<|GU3JnH>PYVcYUWT?RpvbhH7AmDJGXo;V&^NEQ( z&pqe7``5!K-lsMqYDc+qhv+hDrmV)h!xFmFPkfc8FVrKo>lp;Q70^mKv%aBO>-klcb(=5@$kbMfh z$5;2=jW=TV>ndH~57rsfrjlnB>J1#9fx@vSexs$yAeuG9D73+8-?C_Mpxr1?_Tpu&p#o6J?1;npoZZ8F4YOZz<}a1%qgJ2a#amzo3B-BZJfq_|g5$meh`Q zT*J;t;nfk5SY;mJx)hbQSE7x*ED_Mbxoz7L`F7gXqY<%l(g5cdYu{YMLQ z5N-aX{RfUtvcH0)SVLFfTQ9H^qppZ<+6nBTT^jdj z9M(9Y@rcIPHCjMHr>QNqksP!;>1ld{u9J!AcLE=!0pMmj0_>*~z-Q?KaF9L+zC>4n zqx3!Skd_yK?MrbU;a)<$aFrKwoi;a?>dS}yxvDE)p+YTEvSQ||o^mRZ^1gjassUkMynE9a$aoz<^xQ=S-U9{Y)T$Nxm2?iU9;{8O4k(>;qHzz&2 zX3n{8GHiLYoOh+o=^)cqepr|19INd4k#g*)E=tUqakMfl$Z*lIWt4n8OpH$t7Rj?M zMbo9XV7S!@%FDpDY*~?>n)cip*3kNhB!#r7aj#xf=f3-R`@$!Cr#=`sbo$FnxB95v z5TdKiAkl$FZ!fdy4oaod>8?xrPrlRpL-*cx65vF#GPm$%E2jEh3$0+v_r}iJGT=Ci z3!#4|642xQMzl}RCUa`6IO>OTI0&9g^0;qtx0!)~^oHf7mUAL_UYo+o>*5gxH2Sbn zTk?$KS*zo5|VDqTAdyxYbJaGd}J) ziGXshd)RV3szf%TlluE}@11S1v3i(bw%i-HvbGc-Pf$l>HdZgsVN-kDAqx z0}5ERa5jwNHgj=4Opr(O*edQ=g;7NimSEmF|)!Nqi`J-~DVJ}GE9keC0&(xleO zAny1#gqt5Zpd3Adv7@k8p+L`3LqrxhY-&)Rs<6m2vLuHRpm;Q=(I!6HCbf;a_pB3< zt$hhr_ZB3c8hab?yUV>tKsF&Xjy3_UO0$38xA?LW)F EAETfDmH+?% literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-bin/server-a/TestOnlySupport.dll b/fixtures/managed/diagnose-bin/server-a/TestOnlySupport.dll new file mode 100644 index 0000000000000000000000000000000000000000..e029004bd42b594c0ca59c0ff628a4a825419f58 GIT binary patch literal 3584 zcmeHJTWl0n82%2uP>Pf`R)W#MKp~3AWSc?)Dzc?put+a<7sM-_-JWhoW@k1tv%pHC zm1yFlBb4`KMZVJJx4o@^5z#s55n@d6P3_0W)?Og(Y$(w=5@8Xsctg6ld*FdjSn4SIqDc zUxwkBQWiMam^a2*)+HpltwIM17I2j@lnDO;bw0u%`6CBa>OoRCz1z;%Lz$t$ed4eK z9?@fff!BcR5{S7DE41IP0CmUCIYfy2kfavXmo|CR6uy`acZh_9=| z{lFSwZmJ<{Nb+Bo=u9SSIhRMmCAF1RGIrt{EUHfIP=fd41GU~{)rlMqQ_`!>6E8vV zC4;XT{Mq0xgP$1>Kd5uYW#U&l@P_KeyXuD8!@0tg#o%}!j@m?S4K6Z+KB7dusJgL( z*o#4f`wR{n95eWg!RHJ*M1_a&0=|UJy0#MU#ZKY_I6@pip12bih(owcd>kJVM{t$+ zw2|kD?F)@=Ffl_nn0)K@x)0qA74XD(FkAEVlgQVjSXb=nnjgCrjhrsl%4MhM>EW2m zSggew!%?IwMXz3P<5qFO3ClV@#PdO)3Bpq=?GL%0KCZ*a4g8hLF@G)yD^5(MfULRMdjn=dvL;=u9i zO-0%lBahvp>$!1@c4_nMv?|RPDCFz1O z9(Tf1I)q({gRo(ebDgpuM6p|n8lp5SJE>QPc^#g1OFBxIj|p&j$xaquq4VWwq`P4A zh0CjV*6oq^&VBOoF6)a5+AXEJ+AOFJPWt+U?diI5;Nfvhj(X>JPPw%xebkNg(d-vqr zsOmZO32vq?dBdVbzry-u9ppi%u$pVDok%ClAWtOz0o@86hT*nX=7dNKO$T#zw1`fm z=cR~3nOibjNsV(g(VS#0InNt+TpyLF#KN>$R?*(tP9Mv|taSLFNR&xrBe1YETymPT zT`S~bCwIq*EbvRRej?(0H}N>np_9Z>OmjBIn|6Zy81YbY>*CY)pMFiwpybqSoJR+Y zm*gI3aciV<4=8$d5Yms!`$W^Hk2wTJzs=kp1!^5iBkCQ*vad(jl93y(3q+btpMyb)RO8byf>6g6U z$UJAPOD2!%l05&+Plg{>)6a4G2^qIp=OEAe%3JnB-b|&Q5Q)1G6*3%xqy3 zqcssjj1MLzY79>XpM1e+A~7-YMoh#PW8w=555^}G-%K<{{LY=(pSFrh7%f7byzlcQeY)z(=8IzN%I5sM6=n&1ea)KoEwSsVVWKh7M7git{<=EbHM(D0EmA~nkhDh9 zqYt3h(9&o`9g0pwakGHqSFi{SIYSf8oMBV`tM@U>B0wAbPBM`uvbhk;VTR~V$bQ;N zbnkNAjd(2zziQG3{y>#MuZQwmMh(uiL7 zrR~CyW2G51_LW3yfHcuy1FOhATvq_cmY^2$G@_SUhy)A&13czqq?hPH9PJNnO7yJZ zzO_ctVD8|ch`n>H3;lXZ?@RUd?&8T>$OgUy7u_!tZAT6V(7JP>Z#l()EkfkK7Jc`T z9GwNtOWVEw$Y=)rd*FXye$aLc=ov6v>|aN;ikaU;A9Y2!acOwDF+@d!IB9iEN4bA2 zqP~^ZieE)5%_%%hKZ?6(1KhUKtMmrs%|d*_KaH zR-PDZNg#e+IB8<9p>_9bOx zpdd)* z$q1n|?7y`da-BJ=SoW1&-APu?<%~HQRWa8LN*-DT%eKM>?Sf&K<+6h7aWXiQ6dg^3t?jXl6?nE$nZTB7;}IGxx^RT!TGUI6t6tC@*aOlC zWW@C1D#Z^suAmmh;A{zXt*Iy)k$Msv1gZEPV9`OQHKtZV^}UER$B_%YZ--~nc#HW#jiIBdAJjDE z=os)YWkHSNOKSrBDDX)1)kR-4|NKYvCPeRH^?leE`{DOFIzzDlT?S3St3f{eL}z-G z9Gb%^XV{`%^*po&qyf&nK_Tp1a1mT+_TW??rVv&Zgp;?7ZSmT>l`KU(EHUXI`7H>(cH{&aX+ep;n zpN20V-s<`G;tfvK8qeJYd#+fhY#qdIH)Qb7bLz;&#*grwl7O!<_$ewcTon%)a#f2o zj~Pf^C3!XHWGv^3^PJWEnTx9TZ|08HVG2H7#8XE7!iFqvZXHtijuW)h$MOo+>qoRx r)p`K+4UlsQJHke`8g}E-s@KPG?QdbwqW>A5-*F4acssQJtpa}n26GX- literal 0 HcmV?d00001 diff --git a/fixtures/managed/diagnose-src/MissingOnly/MissingMarker.cs b/fixtures/managed/diagnose-src/MissingOnly/MissingMarker.cs new file mode 100644 index 0000000..e45846f --- /dev/null +++ b/fixtures/managed/diagnose-src/MissingOnly/MissingMarker.cs @@ -0,0 +1,9 @@ +namespace MissingOnly +{ + public static class MissingMarker + { + public static void Touch() + { + } + } +} diff --git a/fixtures/managed/diagnose-src/MissingOnly/MissingOnly.csproj b/fixtures/managed/diagnose-src/MissingOnly/MissingOnly.csproj new file mode 100644 index 0000000..8999bcf --- /dev/null +++ b/fixtures/managed/diagnose-src/MissingOnly/MissingOnly.csproj @@ -0,0 +1,13 @@ + + + net8.0 + disable + disable + MissingOnly + none + MissingOnly + 1.0.0.0 + 1.0.0.0 + 1.0.0 + + diff --git a/fixtures/managed/diagnose-src/RootPlugin/PluginEntry.cs b/fixtures/managed/diagnose-src/RootPlugin/PluginEntry.cs new file mode 100644 index 0000000..899dd98 --- /dev/null +++ b/fixtures/managed/diagnose-src/RootPlugin/PluginEntry.cs @@ -0,0 +1,18 @@ +using HarmonyLib; +using MissingOnly; +using Rocket.Runtime; +using TestOnlySupport; + +namespace Rocket.Plugin +{ + public sealed class PluginEntry + { + public string Run(string value) + { + CompileOnlyType.Touch(); + MissingMarker.Touch(); + TestMarker.Touch(); + return RuntimeFeature.RequiredMethod(value) + Harmony.VersionInfo(); + } + } +} diff --git a/fixtures/managed/diagnose-src/RootPlugin/RootPlugin.csproj b/fixtures/managed/diagnose-src/RootPlugin/RootPlugin.csproj new file mode 100644 index 0000000..319ab5c --- /dev/null +++ b/fixtures/managed/diagnose-src/RootPlugin/RootPlugin.csproj @@ -0,0 +1,31 @@ + + + net8.0 + disable + disable + RootPlugin + none + Rocket.Plugin + 1.0.0.0 + 1.0.0.0 + 1.0.0 + + + + ..\..\diagnose-bin\compile\RuntimeDependency.dll + false + + + ..\..\diagnose-bin\compile\0Harmony.dll + false + + + ..\..\diagnose-bin\compile\TestOnlySupport.dll + false + + + ..\..\diagnose-bin\compile\MissingOnly.dll + false + + + diff --git a/fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeDependencyCompile.csproj b/fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeDependencyCompile.csproj new file mode 100644 index 0000000..b2d8cfc --- /dev/null +++ b/fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeDependencyCompile.csproj @@ -0,0 +1,13 @@ + + + net8.0 + disable + disable + RuntimeDependency + none + Rocket.Runtime + 1.0.0.0 + 1.0.0.0 + 1.0.0 + + diff --git a/fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeFeature.cs b/fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeFeature.cs new file mode 100644 index 0000000..b5121d8 --- /dev/null +++ b/fixtures/managed/diagnose-src/RuntimeDependencyCompile/RuntimeFeature.cs @@ -0,0 +1,17 @@ +namespace Rocket.Runtime +{ + public sealed class RuntimeFeature + { + public static string RequiredMethod(string value) + { + return "compile:" + value; + } + } + + public sealed class CompileOnlyType + { + public static void Touch() + { + } + } +} diff --git a/fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeDependencyServer.csproj b/fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeDependencyServer.csproj new file mode 100644 index 0000000..181f1dd --- /dev/null +++ b/fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeDependencyServer.csproj @@ -0,0 +1,13 @@ + + + net8.0 + disable + disable + RuntimeDependency + none + Rocket.Runtime + 2.0.0.0 + 2.0.0.0 + 2.0.0 + + diff --git a/fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeFeature.cs b/fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeFeature.cs new file mode 100644 index 0000000..6eab831 --- /dev/null +++ b/fixtures/managed/diagnose-src/RuntimeDependencyServer/RuntimeFeature.cs @@ -0,0 +1,10 @@ +namespace Rocket.Runtime +{ + public sealed class RuntimeFeature + { + public static string ReplacementOnly(string value) + { + return "server:" + value; + } + } +} diff --git a/fixtures/managed/diagnose-src/TestOnlySupport/TestMarker.cs b/fixtures/managed/diagnose-src/TestOnlySupport/TestMarker.cs new file mode 100644 index 0000000..07cb63c --- /dev/null +++ b/fixtures/managed/diagnose-src/TestOnlySupport/TestMarker.cs @@ -0,0 +1,9 @@ +namespace TestOnlySupport +{ + public static class TestMarker + { + public static void Touch() + { + } + } +} diff --git a/fixtures/managed/diagnose-src/TestOnlySupport/TestOnlySupport.csproj b/fixtures/managed/diagnose-src/TestOnlySupport/TestOnlySupport.csproj new file mode 100644 index 0000000..ac3a39c --- /dev/null +++ b/fixtures/managed/diagnose-src/TestOnlySupport/TestOnlySupport.csproj @@ -0,0 +1,13 @@ + + + net8.0 + disable + disable + TestOnlySupport + none + TestOnlySupport + 1.0.0.0 + 1.0.0.0 + 1.0.0 + + diff --git a/fixtures/managed/diagnose-src/ZeroHarmony/Harmony.cs b/fixtures/managed/diagnose-src/ZeroHarmony/Harmony.cs new file mode 100644 index 0000000..d6f9c2f --- /dev/null +++ b/fixtures/managed/diagnose-src/ZeroHarmony/Harmony.cs @@ -0,0 +1,10 @@ +namespace HarmonyLib +{ + public static class Harmony + { + public static string VersionInfo() + { + return "2.2.2"; + } + } +} diff --git a/fixtures/managed/diagnose-src/ZeroHarmony/ZeroHarmony.csproj b/fixtures/managed/diagnose-src/ZeroHarmony/ZeroHarmony.csproj new file mode 100644 index 0000000..32b89b1 --- /dev/null +++ b/fixtures/managed/diagnose-src/ZeroHarmony/ZeroHarmony.csproj @@ -0,0 +1,13 @@ + + + net8.0 + disable + disable + 0Harmony + none + HarmonyLib + 2.2.2.0 + 2.2.2.0 + 2.2.2 + + diff --git a/fixtures/pathshadow/commands.txt b/fixtures/pathshadow/commands.txt new file mode 100644 index 0000000..b8e0f65 --- /dev/null +++ b/fixtures/pathshadow/commands.txt @@ -0,0 +1,3 @@ +rg +cargo +pwsh diff --git a/fixtures/polyglot/repo/nested/worker.py b/fixtures/polyglot/repo/nested/worker.py new file mode 100644 index 0000000..ea0910c --- /dev/null +++ b/fixtures/polyglot/repo/nested/worker.py @@ -0,0 +1,12 @@ +class Worker: + def build(self, value: int) -> int: + return value + 1 + + +def helper(value: int) -> int: + return value * 2 + + +def build_twice(value: int) -> int: + worker = Worker() + return helper(worker.build(value)) diff --git a/fixtures/polyglot/repo/scripts/module.psm1 b/fixtures/polyglot/repo/scripts/module.psm1 new file mode 100644 index 0000000..4f6772c --- /dev/null +++ b/fixtures/polyglot/repo/scripts/module.psm1 @@ -0,0 +1,7 @@ +function Invoke-ModuleTask { + param( + [int]$Count + ) + + $Count + 1 +} diff --git a/fixtures/polyglot/repo/scripts/tools.ps1 b/fixtures/polyglot/repo/scripts/tools.ps1 new file mode 100644 index 0000000..eda4151 --- /dev/null +++ b/fixtures/polyglot/repo/scripts/tools.ps1 @@ -0,0 +1,28 @@ +function Invoke-Helper { + param( + [string]$Name + ) + + "hello $Name" +} + +class TaskRunner { + [string] Format([string]$Name) { + return "task:$Name" + } +} + +function Invoke-Formatting { + param( + [string]$Name + ) + + $runner = [TaskRunner]::new() + Invoke-Helper -Name $Name | Out-Null + $runner.Format($Name) +} + +enum TaskState { + Ready = 0 + Running = 1 +} diff --git a/fixtures/polyglot/repo/src/Game.cs b/fixtures/polyglot/repo/src/Game.cs new file mode 100644 index 0000000..3185567 --- /dev/null +++ b/fixtures/polyglot/repo/src/Game.cs @@ -0,0 +1,28 @@ +namespace Mercury.Game; + +public class RocketBuilder +{ + public void Build(string name) + { + } + + public class NestedThing + { + public int Build(int value) + { + return value + 1; + } + } +} + +public record BuildPlan(string Name); + +public static class BuildPlanFactory +{ + public static void BuildRocket(string name) + { + var builder = new RocketBuilder(); + builder.Build(name); + _ = new RocketBuilder.NestedThing().Build(1); + } +} diff --git a/fixtures/polyglot/repo/src/Service.java b/fixtures/polyglot/repo/src/Service.java new file mode 100644 index 0000000..4f8aa49 --- /dev/null +++ b/fixtures/polyglot/repo/src/Service.java @@ -0,0 +1,9 @@ +package mercury.demo; + +public class Service { + public void execute() { + } + + private record Result(String name) { + } +} diff --git a/fixtures/polyglot/repo/src/lib.rs b/fixtures/polyglot/repo/src/lib.rs new file mode 100644 index 0000000..4220473 --- /dev/null +++ b/fixtures/polyglot/repo/src/lib.rs @@ -0,0 +1,29 @@ +pub mod nested { + pub struct Widget { + value: i32, + } + + impl Widget { + pub fn new(value: i32) -> Self { + Self { value } + } + + pub fn helper(&self) -> i32 { + self.value + 1 + } + } +} + +pub trait Runner { + fn run(&self) -> bool; +} + +pub fn helper(name: &str) -> String { + format!("hello, {name}") +} + +pub fn call_helper(widget: &nested::Widget, name: &str) -> (String, i32) { + (helper(name), widget.helper()) +} + +pub const HELPER_LABEL: &str = "widget"; diff --git a/fixtures/polyglot/repo/src/service.go b/fixtures/polyglot/repo/src/service.go new file mode 100644 index 0000000..e305c82 --- /dev/null +++ b/fixtures/polyglot/repo/src/service.go @@ -0,0 +1,21 @@ +package service + +type Runner interface { + Run() error +} + +type Worker struct { + Name string +} + +func NewWorker(name string) *Worker { + return &Worker{Name: name} +} + +func (w *Worker) Run() error { + return w.help() +} + +func (w *Worker) help() error { + return nil +} diff --git a/fixtures/polyglot/repo/web/app.js b/fixtures/polyglot/repo/web/app.js new file mode 100644 index 0000000..fa236d2 --- /dev/null +++ b/fixtures/polyglot/repo/web/app.js @@ -0,0 +1,18 @@ +export function helper(value) { + return value.trim(); +} + +export const makeGreeter = (name) => { + return `hello ${name}`; +}; + +export class BrowserThing { + open(url) { + return url; + } +} + +export function demo(value, url) { + const browser = new BrowserThing(); + return helper(browser.open(url ?? value)); +} diff --git a/fixtures/polyglot/repo/web/app.ts b/fixtures/polyglot/repo/web/app.ts new file mode 100644 index 0000000..413cc10 --- /dev/null +++ b/fixtures/polyglot/repo/web/app.ts @@ -0,0 +1,25 @@ +export interface Report { + title: string; +} + +export type Builder = (name: string) => string; + +export function helper(name: string): string { + return name.toUpperCase(); +} + +export const makeTyped = (value: number): number => { + return value + 1; +}; + +export namespace Tools { + export function open(path: string): string { + return path; + } +} + +export function renderReport(name: string, path: string): Report { + return { + title: helper(Tools.open(path)) + name, + }; +} diff --git a/fixtures/polyglot/repo/web/view.tsx b/fixtures/polyglot/repo/web/view.tsx new file mode 100644 index 0000000..51850c3 --- /dev/null +++ b/fixtures/polyglot/repo/web/view.tsx @@ -0,0 +1,13 @@ +export abstract class Screen { + render(): string { + return "screen"; + } +} + +export const makeScreen = (name: string): string => { + return `
${name}
`; +}; + +export const renderScreen = (screen: Screen): string => { + return screen.render(); +}; diff --git a/fixtures/portping/targets.txt b/fixtures/portping/targets.txt new file mode 100644 index 0000000..3726089 --- /dev/null +++ b/fixtures/portping/targets.txt @@ -0,0 +1,2 @@ +tcp://127.0.0.1:65535 +http://127.0.0.1:8080/health diff --git a/fixtures/reading/binary.bin b/fixtures/reading/binary.bin new file mode 100644 index 0000000000000000000000000000000000000000..860258eeb15f73dbe75579c841fdc5d1851e8f3f GIT binary patch literal 13 UcmZQzWb%#rFW}_q=NJ?T02H(Xe*gdg literal 0 HcmV?d00001 diff --git a/fixtures/reading/config.json b/fixtures/reading/config.json new file mode 100644 index 0000000..d7114d0 --- /dev/null +++ b/fixtures/reading/config.json @@ -0,0 +1,15 @@ +{ + "app": { + "name": "toolbox", + "logging": { + "level": "debug", + "targets": [ + "stderr" + ] + } + }, + "workspace": { + "root": "C:/src/toolbox", + "cache": true + } +} diff --git a/fixtures/reading/config.toml b/fixtures/reading/config.toml new file mode 100644 index 0000000..e65a46a --- /dev/null +++ b/fixtures/reading/config.toml @@ -0,0 +1,8 @@ +[workspace] +root = "C:/src/toolbox" + +[logging] +level = "debug" + +[plugins.metrics] +enabled = true diff --git a/fixtures/reading/config.yaml b/fixtures/reading/config.yaml new file mode 100644 index 0000000..7068f0c --- /dev/null +++ b/fixtures/reading/config.yaml @@ -0,0 +1,9 @@ +app: + name: toolbox + logging: + level: debug + outputs: + - stderr +plugins: + metrics: + enabled: true diff --git a/fixtures/reading/generated.lock b/fixtures/reading/generated.lock new file mode 100644 index 0000000..515f51f --- /dev/null +++ b/fixtures/reading/generated.lock @@ -0,0 +1,3 @@ +# This file is automatically @generated by the build system. +package = "toolbox" +version = 4 diff --git a/fixtures/reading/minified.js b/fixtures/reading/minified.js new file mode 100644 index 0000000..079e4a8 --- /dev/null +++ b/fixtures/reading/minified.js @@ -0,0 +1 @@ +function boot(){const state={ready:true,mode:"fast",retries:3};if(state.ready){console.log("boot",state.mode,state.retries)}}boot(); diff --git a/fixtures/reading/sample.cs b/fixtures/reading/sample.cs new file mode 100644 index 0000000..cd4b224 --- /dev/null +++ b/fixtures/reading/sample.cs @@ -0,0 +1,17 @@ +using UnityEngine; + +namespace Demo.Game { + public class PlayerController : MonoBehaviour { + public void Start() { + Debug.Log("ready"); + } + + private int ComputeScore(int baseScore) { + if (baseScore < 0) { + return 0; + } + + return baseScore + 10; + } + } +} diff --git a/fixtures/reading/sample.go b/fixtures/reading/sample.go new file mode 100644 index 0000000..85c4de8 --- /dev/null +++ b/fixtures/reading/sample.go @@ -0,0 +1,27 @@ +package demo + +import "context" + +const DefaultLimit = 10 + +var SharedName = "mercury" + +type Runner interface { + Run(context.Context) error +} + +type Service struct { + name string +} + +func NewService(name string) *Service { + return &Service{name: name} +} + +func (s *Service) Run(ctx context.Context) error { + return s.helper(ctx) +} + +func (s *Service) helper(ctx context.Context) error { + return nil +} diff --git a/fixtures/reading/sample.java b/fixtures/reading/sample.java new file mode 100644 index 0000000..ecbf311 --- /dev/null +++ b/fixtures/reading/sample.java @@ -0,0 +1,25 @@ +package com.acme.demo; + +import java.util.List; + +public final class OutlineDemo extends BaseDemo implements Runnable { + private static final int LIMIT = 10; + + public OutlineDemo() { + } + + @Override + public void run() { + helper("demo"); + } + + private List helper(String name) throws IOException { + return List.of(name); + } + + public record Result(String name, int score) {} + + interface Nested { + void call(); + } +} diff --git a/fixtures/reading/sample.js b/fixtures/reading/sample.js new file mode 100644 index 0000000..34b060f --- /dev/null +++ b/fixtures/reading/sample.js @@ -0,0 +1,15 @@ +class Widget { + constructor(name) { + this.name = name; + } + + render() { + return this.name.toUpperCase(); + } +} + +function createWidget(name) { + return new Widget(name); +} + +const loadWidget = async (id) => createWidget(String(id)); diff --git a/fixtures/reading/sample.ps1 b/fixtures/reading/sample.ps1 new file mode 100644 index 0000000..fd08ffd --- /dev/null +++ b/fixtures/reading/sample.ps1 @@ -0,0 +1,15 @@ +function Get-Widget { + param([string]$Name) + "widget:$Name" +} + +class WidgetBuilder { + [string] Build([string]$Name) { + return $Name.ToUpperInvariant() + } +} + +enum WidgetMode { + Fast = 1 + Safe = 2 +} diff --git a/fixtures/reading/sample.py b/fixtures/reading/sample.py new file mode 100644 index 0000000..bba6673 --- /dev/null +++ b/fixtures/reading/sample.py @@ -0,0 +1,13 @@ +class Worker: + def __init__(self, name: str) -> None: + self.name = name + + def run(self) -> str: + return self._format() + + def _format(self) -> str: + return f"worker:{self.name}" + + +def build_worker(name: str) -> Worker: + return Worker(name) diff --git a/fixtures/reading/sample.rs b/fixtures/reading/sample.rs new file mode 100644 index 0000000..e78b1fc --- /dev/null +++ b/fixtures/reading/sample.rs @@ -0,0 +1,35 @@ +pub mod net { + pub struct Client { + pub id: u32, + } + + impl Client { + pub fn connect(id: u32) -> Self { + Self { id } + } + } +} + +pub enum Mode { + Fast, + Safe, +} + +pub fn run(args: &[String]) -> Result<(), String> { + let mode = if args.is_empty() { + Mode::Safe + } else { + Mode::Fast + }; + + match mode { + Mode::Fast => println!("fast"), + Mode::Safe => println!("safe"), + } + + Ok(()) +} + +fn helper(value: i32) -> i32 { + value + 1 +} diff --git a/fixtures/reading/sample.ts b/fixtures/reading/sample.ts new file mode 100644 index 0000000..1676fee --- /dev/null +++ b/fixtures/reading/sample.ts @@ -0,0 +1,21 @@ +interface Runner { + run(): Promise; +} + +type WorkerOptions = { + name: string; +}; + +class Worker implements Runner { + constructor(private readonly options: WorkerOptions) {} + + async run(): Promise { + return this.options.name; + } +} + +export function createWorker(options: WorkerOptions): Runner { + return new Worker(options); +} + +export const defaultWorker = () => createWorker({ name: "default" }); diff --git a/fixtures/sqliteshape/sample.db b/fixtures/sqliteshape/sample.db new file mode 100644 index 0000000000000000000000000000000000000000..2874f00e5b1029bf10a62eca37d5448dab8c6220 GIT binary patch literal 16384 zcmeI$J#W)M7zgk>J8kMz#7GEP*-n`nsi~aM@R9}6aIGRjQrsZ5EYT&tRu-wJae-koa7ztZXbSOw63^2DK2u+DQLP>^t8*&%OLMd+$x#izGV?!ebG!N8~=yH1dKm zLWriWjJjq;SB;y`bT;shS|b}ndQS!D`7$xX{6|%xKmY;|fB*y_009U<00Izzz+D#* zde*8H=#+ZCE6+Xmyf4qBAC3Eyu?+j~$&SJ z&M!^-TGlEQ=!Zfi1|ylqq<8&#)?Q<)&1cy;ri$mPrFwjaJ8ai!y>2)M>=i#)PsXmN z{~gfj_E_g_yPb~4YqB2SSADnohoKaabo=U^c+TjFxEKW@O-$$6?mE1+(}|az9jL?{ zzRexpY4SZ5?_#{>xuslbx=OQ_Wzp#*IbkxA+_YQgOEdm^JYO8kd0QNM(M8gq?oSLO z?@a!MkSitc=H?e-{xH9(3IzfXfB*y_009U<00Izz00bZafrSys>r3RJWi&-7c17ro zSF|PafUIlNwWELSVNie0zk|GTH!GBK>$^TEU$xik55+htUR7w4S^0D7I5V00vgnE>5rem8%r x3IzfXfB*y_009U<00Izz00bZafyEP8%}|S`pBnUB#;|DeQ9(0Q(@4G&`~svk%`X4| literal 0 HcmV?d00001 diff --git a/fixtures/sqliteshape/sample.db-shm b/fixtures/sqliteshape/sample.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3(input) { + let _ = toon::encode_value_default(&value); + let _ = zon::encode_value(&value); + let _ = tonl::encode_documents(&[value]); + } +}); diff --git a/justfile b/justfile new file mode 100644 index 0000000..246d1a3 --- /dev/null +++ b/justfile @@ -0,0 +1,294 @@ +set windows-shell := ["pwsh.exe", "-NoProfile", "-Command"] + +core-packages := "-p common -p codeindex -p configsupport -p runtimekit -p managed -p unitysupport -p windowsupport" +code-packages := "-p codeindex -p fileprobe -p outline -p snip -p chunkcat -p hitsnip -p diagpick -p defsnip -p codeshape -p refs -p context" +data-packages := "-p cjson -p ison -p isonl -p zon -p tonl -p jsonlgrep -p jsonshape -p mercury-mhash -p toon -p csvshape -p sqliteshape -p sqlshape -p stringscan" +runtime-packages := "-p runtimekit -p runprobe -p await -p argv -p recent -p pathshadow -p sysshape -p portping -p logshape -p config -p gitshape -p reposhape -p dotnetshape -p binmeta" +windows-packages := "-p windowsupport -p msudo -p envdiff -p proctree -p unlock -p portunlock" +managed-packages := "-p managed -p asmtype -p asmmember -p asmref -p asmapi -p asmflow -p llvmtools -p petools" +unity-packages := "-p unitysupport -p unityasset -p unityprobe -p unitydiag" +clippy-flags := "-- -D warnings -W clippy::pedantic -W clippy::nursery" + +default: + @just --list + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt --all --check + +check: + cargo check --all-targets --all-features + +workspace-check: + just fmt-check + just check + just build + just ai-check + just powershell + +commit-check: + pwsh -NoProfile -File .\scripts\check-conventional-commits.ps1 + +release-version-check tag: + pwsh -NoProfile -File .\scripts\check-release-version.ps1 -Tag {{tag}} + +changelog: + git-cliff --config cliff.toml --output CHANGELOG.md + +changelog-unreleased: + git-cliff --config cliff.toml --unreleased + +core-check: + cargo check {{core-packages}} --all-targets --all-features + +code-check: + cargo check {{code-packages}} --all-targets --all-features + +data-check: + cargo check {{data-packages}} --all-targets --all-features + +runtime-check: + cargo check {{runtime-packages}} --all-targets --all-features + +windows-check: + cargo check {{windows-packages}} --all-targets --all-features + +managed-check: + cargo check {{managed-packages}} --all-targets --all-features + +unity-check: + cargo check {{unity-packages}} --all-targets --all-features + +msudo-check: + cargo check -p msudo --all-targets --all-features + +test: + cargo nextest run --all-features + +stable-test: + $env:CARGO_INCREMENTAL = '0'; cargo nextest run --all-features --run-ignored all + +core-test: + cargo nextest run {{core-packages}} --all-features + +code-test: + cargo nextest run {{code-packages}} --all-features + +data-test: + cargo nextest run {{data-packages}} --all-features + +runtime-test: + cargo nextest run {{runtime-packages}} --all-features + +windows-test: + cargo nextest run {{windows-packages}} --all-features + +managed-test: + cargo nextest run {{managed-packages}} --all-features + +unity-test: + cargo nextest run {{unity-packages}} --all-features + +msudo-test: + cargo nextest run -p msudo --all-features + +clippy: + cargo clippy --all-targets --all-features {{clippy-flags}} + +core-clippy: + cargo clippy {{core-packages}} --all-targets --all-features {{clippy-flags}} + +code-clippy: + cargo clippy {{code-packages}} --all-targets --all-features {{clippy-flags}} + +data-clippy: + cargo clippy {{data-packages}} --all-targets --all-features {{clippy-flags}} + +mhash-check: + cargo check -p mercury-mhash --all-targets --all-features + +mhash-test: + cargo nextest run -p mercury-mhash --all-features + +mhash-clippy: + cargo clippy -p mercury-mhash --all-targets --all-features {{clippy-flags}} + +mhash-bench: + cargo run --profile release-fast -p mercury-mhash --bin mhash -- bench --algorithm sha256,blake3-256 README.md + +mhash-benchmark: + pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/benchmark-mhash.ps1 + +runtime-clippy: + cargo clippy {{runtime-packages}} --all-targets --all-features {{clippy-flags}} + +windows-clippy: + cargo clippy {{windows-packages}} --all-targets --all-features {{clippy-flags}} + +managed-clippy: + cargo clippy {{managed-packages}} --all-targets --all-features {{clippy-flags}} + +unity-clippy: + cargo clippy {{unity-packages}} --all-targets --all-features {{clippy-flags}} + +msudo-clippy: + cargo clippy -p msudo --all-targets --all-features {{clippy-flags}} + +udeps: + cargo +nightly udeps --all-targets --all-features + +deny: + cargo deny check + +build: + cargo build --profile release-fast --workspace + +build-debug: + cargo build --workspace + +msudo-build: + cargo build -p msudo + +build-release: + cargo build --release --workspace + +build-size: + cargo build --profile release-size --workspace + +slim: + cargo build --profile release-fast -p codeshape -p refs -p defsnip + +coverage: + cargo llvm-cov nextest --all-features --summary-only --ignore-filename-regex 'cli\.rs$|main\.rs$|flow_opcode_table\.rs$' --fail-under-lines 80 + +jade: + pwsh -NoProfile -File .\scripts\check-jade.ps1 -VerificationConfiguration ReleaseFast + +jade-skip-coverage: + pwsh -NoProfile -File .\scripts\check-jade.ps1 -SkipCoverage -VerificationConfiguration ReleaseFast + +miri: + cargo +nightly miri setup + cargo +nightly miri test -p common --test miri_json_family + +fuzz: + pwsh -NoProfile -File .\scripts\check-jade-hardening.ps1 -Only Fuzz + +sanitizer: + pwsh -NoProfile -File .\scripts\check-jade-hardening.ps1 -Only Sanitizers + +no-panic: + pwsh -NoProfile -File .\scripts\check-no-panic.ps1 + +loom: + cargo nextest run -p runtimekit --test loom_capture + +ecosystem: + pwsh -NoProfile -File .\scripts\check-ecosystem.ps1 -Configuration ReleaseFast + +ecosystem-fast: + cargo build --profile release-fast --workspace + pwsh -NoProfile -File .\scripts\check-ecosystem.ps1 -Configuration ReleaseFast -SkipBuild -SkipPromptGeneration + +powershell: + pwsh -NoProfile -File .\scripts\check-powershell.ps1 + +gitea-ci-check: + pwsh -NoProfile -File .\scripts\check-gitea-ci.ps1 -Wait + +gitea-ci-dispatch: + pwsh -NoProfile -File .\scripts\check-gitea-ci.ps1 -DispatchIfMissing -Wait + +prompt: + pwsh -NoProfile -File .\scripts\generate-ai-prompt.ps1 + +prompt-check: + pwsh -NoProfile -File .\scripts\check-ai-prompt.ps1 -Configuration ReleaseFast -SkipBuild + +skill: + pwsh -NoProfile -File .\scripts\generate-ai-skill.ps1 + +skill-check: + pwsh -NoProfile -File .\scripts\check-ai-skill.ps1 -Configuration ReleaseFast -SkipBuild + +ai-check: + just prompt-check + just skill-check + +periphery-check: + just --summary + just powershell + just ai-check + +assets: + just prompt + just skill + +install: + pwsh -NoProfile -File .\scripts\install-toolbox.ps1 + +uninstall: + pwsh -NoProfile -File .\scripts\uninstall-toolbox.ps1 + +package: + pwsh -NoProfile -File .\scripts\package-toolbox.ps1 + +publish-release tag notes_path: + pwsh -NoProfile -File .\scripts\publish-gitea-release.ps1 -Tag {{tag}} -NotesPath {{notes_path}} + +release-local tag notes_path: + just jade + just package + just publish-release {{tag}} {{notes_path}} + +install-package: + $installer = Get-ChildItem -LiteralPath .\dist -Recurse -File -Filter install-package-toolbox.ps1 | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($null -eq $installer) { throw 'No package installer found under .\dist. Run `just package` first.' }; & $installer.FullName + +uninstall-package: + pwsh -NoProfile -File .\scripts\uninstall-package-toolbox.ps1 + +package-roundtrip: + just package + just install-package + just uninstall-package + +probe: + pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 + +probe-fast: + pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseFast -Warmup 1 -Runs 4 + +probe-size: + pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseSize -SkipBenchmarks + +msudo-help: + cargo run -p msudo -- --help + +msudo-run-help: + cargo run -p msudo -- run --help + +msudo-status: + cargo run -p msudo -- status --json + +msudo-status-fast: + cargo build --profile release-fast -p msudo + .\target\release-fast\msudo.exe status --json + +msudo-verify: + just msudo-check + just msudo-test + cargo build --profile release-fast --workspace + just prompt-check + just skill-check + just ecosystem-fast + +probe-bloat: + pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseFast -IncludeBloat -IncludeLlvmLines + +probe-flamegraph: + pwsh -NoProfile -File .\scripts\probe-toolbox.ps1 -BuildProfile ReleaseFast -IncludeFlamegraph + +jade-tools: + pwsh -NoProfile -File .\scripts\install-jade-tooling.ps1 diff --git a/opcode-table.txt b/opcode-table.txt new file mode 100644 index 0000000..93e3848 --- /dev/null +++ b/opcode-table.txt @@ -0,0 +1,226 @@ + -482 => Some(OpcodeInfo { name: "readonly.", operand: OperandKind::InlineNone }), + -483 => Some(OpcodeInfo { name: "refanytype", operand: OperandKind::InlineNone }), + -484 => Some(OpcodeInfo { name: "sizeof", operand: OperandKind::InlineType }), + -486 => Some(OpcodeInfo { name: "rethrow", operand: OperandKind::InlineNone }), + -488 => Some(OpcodeInfo { name: "initblk", operand: OperandKind::InlineNone }), + -489 => Some(OpcodeInfo { name: "cpblk", operand: OperandKind::InlineNone }), + -490 => Some(OpcodeInfo { name: "constrained.", operand: OperandKind::InlineType }), + -491 => Some(OpcodeInfo { name: "initobj", operand: OperandKind::InlineType }), + -492 => Some(OpcodeInfo { name: "tail.", operand: OperandKind::InlineNone }), + -493 => Some(OpcodeInfo { name: "volatile.", operand: OperandKind::InlineNone }), + -494 => Some(OpcodeInfo { name: "unaligned.", operand: OperandKind::ShortInlineI }), + -495 => Some(OpcodeInfo { name: "endfilter", operand: OperandKind::InlineNone }), + -497 => Some(OpcodeInfo { name: "localloc", operand: OperandKind::InlineNone }), + -498 => Some(OpcodeInfo { name: "stloc", operand: OperandKind::InlineVar }), + -499 => Some(OpcodeInfo { name: "ldloca", operand: OperandKind::InlineVar }), + -500 => Some(OpcodeInfo { name: "ldloc", operand: OperandKind::InlineVar }), + -501 => Some(OpcodeInfo { name: "starg", operand: OperandKind::InlineVar }), + -502 => Some(OpcodeInfo { name: "ldarga", operand: OperandKind::InlineVar }), + -503 => Some(OpcodeInfo { name: "ldarg", operand: OperandKind::InlineVar }), + -505 => Some(OpcodeInfo { name: "ldvirtftn", operand: OperandKind::InlineMethod }), + -506 => Some(OpcodeInfo { name: "ldftn", operand: OperandKind::InlineMethod }), + -507 => Some(OpcodeInfo { name: "clt.un", operand: OperandKind::InlineNone }), + -508 => Some(OpcodeInfo { name: "clt", operand: OperandKind::InlineNone }), + -509 => Some(OpcodeInfo { name: "cgt.un", operand: OperandKind::InlineNone }), + -510 => Some(OpcodeInfo { name: "cgt", operand: OperandKind::InlineNone }), + -511 => Some(OpcodeInfo { name: "ceq", operand: OperandKind::InlineNone }), + -512 => Some(OpcodeInfo { name: "arglist", operand: OperandKind::InlineNone }), + 0 => Some(OpcodeInfo { name: "nop", operand: OperandKind::InlineNone }), + 1 => Some(OpcodeInfo { name: "break", operand: OperandKind::InlineNone }), + 10 => Some(OpcodeInfo { name: "stloc.0", operand: OperandKind::InlineNone }), + 100 => Some(OpcodeInfo { name: "shr.un", operand: OperandKind::InlineNone }), + 101 => Some(OpcodeInfo { name: "neg", operand: OperandKind::InlineNone }), + 102 => Some(OpcodeInfo { name: "not", operand: OperandKind::InlineNone }), + 103 => Some(OpcodeInfo { name: "conv.i1", operand: OperandKind::InlineNone }), + 104 => Some(OpcodeInfo { name: "conv.i2", operand: OperandKind::InlineNone }), + 105 => Some(OpcodeInfo { name: "conv.i4", operand: OperandKind::InlineNone }), + 106 => Some(OpcodeInfo { name: "conv.i8", operand: OperandKind::InlineNone }), + 107 => Some(OpcodeInfo { name: "conv.r4", operand: OperandKind::InlineNone }), + 108 => Some(OpcodeInfo { name: "conv.r8", operand: OperandKind::InlineNone }), + 109 => Some(OpcodeInfo { name: "conv.u4", operand: OperandKind::InlineNone }), + 11 => Some(OpcodeInfo { name: "stloc.1", operand: OperandKind::InlineNone }), + 110 => Some(OpcodeInfo { name: "conv.u8", operand: OperandKind::InlineNone }), + 111 => Some(OpcodeInfo { name: "callvirt", operand: OperandKind::InlineMethod }), + 112 => Some(OpcodeInfo { name: "cpobj", operand: OperandKind::InlineType }), + 113 => Some(OpcodeInfo { name: "ldobj", operand: OperandKind::InlineType }), + 114 => Some(OpcodeInfo { name: "ldstr", operand: OperandKind::InlineString }), + 115 => Some(OpcodeInfo { name: "newobj", operand: OperandKind::InlineMethod }), + 116 => Some(OpcodeInfo { name: "castclass", operand: OperandKind::InlineType }), + 117 => Some(OpcodeInfo { name: "isinst", operand: OperandKind::InlineType }), + 118 => Some(OpcodeInfo { name: "conv.r.un", operand: OperandKind::InlineNone }), + 12 => Some(OpcodeInfo { name: "stloc.2", operand: OperandKind::InlineNone }), + 121 => Some(OpcodeInfo { name: "unbox", operand: OperandKind::InlineType }), + 122 => Some(OpcodeInfo { name: "throw", operand: OperandKind::InlineNone }), + 123 => Some(OpcodeInfo { name: "ldfld", operand: OperandKind::InlineField }), + 124 => Some(OpcodeInfo { name: "ldflda", operand: OperandKind::InlineField }), + 125 => Some(OpcodeInfo { name: "stfld", operand: OperandKind::InlineField }), + 126 => Some(OpcodeInfo { name: "ldsfld", operand: OperandKind::InlineField }), + 127 => Some(OpcodeInfo { name: "ldsflda", operand: OperandKind::InlineField }), + 128 => Some(OpcodeInfo { name: "stsfld", operand: OperandKind::InlineField }), + 129 => Some(OpcodeInfo { name: "stobj", operand: OperandKind::InlineType }), + 13 => Some(OpcodeInfo { name: "stloc.3", operand: OperandKind::InlineNone }), + 130 => Some(OpcodeInfo { name: "conv.ovf.i1.un", operand: OperandKind::InlineNone }), + 131 => Some(OpcodeInfo { name: "conv.ovf.i2.un", operand: OperandKind::InlineNone }), + 132 => Some(OpcodeInfo { name: "conv.ovf.i4.un", operand: OperandKind::InlineNone }), + 133 => Some(OpcodeInfo { name: "conv.ovf.i8.un", operand: OperandKind::InlineNone }), + 134 => Some(OpcodeInfo { name: "conv.ovf.u1.un", operand: OperandKind::InlineNone }), + 135 => Some(OpcodeInfo { name: "conv.ovf.u2.un", operand: OperandKind::InlineNone }), + 136 => Some(OpcodeInfo { name: "conv.ovf.u4.un", operand: OperandKind::InlineNone }), + 137 => Some(OpcodeInfo { name: "conv.ovf.u8.un", operand: OperandKind::InlineNone }), + 138 => Some(OpcodeInfo { name: "conv.ovf.i.un", operand: OperandKind::InlineNone }), + 139 => Some(OpcodeInfo { name: "conv.ovf.u.un", operand: OperandKind::InlineNone }), + 14 => Some(OpcodeInfo { name: "ldarg.s", operand: OperandKind::ShortInlineVar }), + 140 => Some(OpcodeInfo { name: "box", operand: OperandKind::InlineType }), + 141 => Some(OpcodeInfo { name: "newarr", operand: OperandKind::InlineType }), + 142 => Some(OpcodeInfo { name: "ldlen", operand: OperandKind::InlineNone }), + 143 => Some(OpcodeInfo { name: "ldelema", operand: OperandKind::InlineType }), + 144 => Some(OpcodeInfo { name: "ldelem.i1", operand: OperandKind::InlineNone }), + 145 => Some(OpcodeInfo { name: "ldelem.u1", operand: OperandKind::InlineNone }), + 146 => Some(OpcodeInfo { name: "ldelem.i2", operand: OperandKind::InlineNone }), + 147 => Some(OpcodeInfo { name: "ldelem.u2", operand: OperandKind::InlineNone }), + 148 => Some(OpcodeInfo { name: "ldelem.i4", operand: OperandKind::InlineNone }), + 149 => Some(OpcodeInfo { name: "ldelem.u4", operand: OperandKind::InlineNone }), + 15 => Some(OpcodeInfo { name: "ldarga.s", operand: OperandKind::ShortInlineVar }), + 150 => Some(OpcodeInfo { name: "ldelem.i8", operand: OperandKind::InlineNone }), + 151 => Some(OpcodeInfo { name: "ldelem.i", operand: OperandKind::InlineNone }), + 152 => Some(OpcodeInfo { name: "ldelem.r4", operand: OperandKind::InlineNone }), + 153 => Some(OpcodeInfo { name: "ldelem.r8", operand: OperandKind::InlineNone }), + 154 => Some(OpcodeInfo { name: "ldelem.ref", operand: OperandKind::InlineNone }), + 155 => Some(OpcodeInfo { name: "stelem.i", operand: OperandKind::InlineNone }), + 156 => Some(OpcodeInfo { name: "stelem.i1", operand: OperandKind::InlineNone }), + 157 => Some(OpcodeInfo { name: "stelem.i2", operand: OperandKind::InlineNone }), + 158 => Some(OpcodeInfo { name: "stelem.i4", operand: OperandKind::InlineNone }), + 159 => Some(OpcodeInfo { name: "stelem.i8", operand: OperandKind::InlineNone }), + 16 => Some(OpcodeInfo { name: "starg.s", operand: OperandKind::ShortInlineVar }), + 160 => Some(OpcodeInfo { name: "stelem.r4", operand: OperandKind::InlineNone }), + 161 => Some(OpcodeInfo { name: "stelem.r8", operand: OperandKind::InlineNone }), + 162 => Some(OpcodeInfo { name: "stelem.ref", operand: OperandKind::InlineNone }), + 163 => Some(OpcodeInfo { name: "ldelem", operand: OperandKind::InlineType }), + 164 => Some(OpcodeInfo { name: "stelem", operand: OperandKind::InlineType }), + 165 => Some(OpcodeInfo { name: "unbox.any", operand: OperandKind::InlineType }), + 17 => Some(OpcodeInfo { name: "ldloc.s", operand: OperandKind::ShortInlineVar }), + 179 => Some(OpcodeInfo { name: "conv.ovf.i1", operand: OperandKind::InlineNone }), + 18 => Some(OpcodeInfo { name: "ldloca.s", operand: OperandKind::ShortInlineVar }), + 180 => Some(OpcodeInfo { name: "conv.ovf.u1", operand: OperandKind::InlineNone }), + 181 => Some(OpcodeInfo { name: "conv.ovf.i2", operand: OperandKind::InlineNone }), + 182 => Some(OpcodeInfo { name: "conv.ovf.u2", operand: OperandKind::InlineNone }), + 183 => Some(OpcodeInfo { name: "conv.ovf.i4", operand: OperandKind::InlineNone }), + 184 => Some(OpcodeInfo { name: "conv.ovf.u4", operand: OperandKind::InlineNone }), + 185 => Some(OpcodeInfo { name: "conv.ovf.i8", operand: OperandKind::InlineNone }), + 186 => Some(OpcodeInfo { name: "conv.ovf.u8", operand: OperandKind::InlineNone }), + 19 => Some(OpcodeInfo { name: "stloc.s", operand: OperandKind::ShortInlineVar }), + 194 => Some(OpcodeInfo { name: "refanyval", operand: OperandKind::InlineType }), + 195 => Some(OpcodeInfo { name: "ckfinite", operand: OperandKind::InlineNone }), + 198 => Some(OpcodeInfo { name: "mkrefany", operand: OperandKind::InlineType }), + 2 => Some(OpcodeInfo { name: "ldarg.0", operand: OperandKind::InlineNone }), + 20 => Some(OpcodeInfo { name: "ldnull", operand: OperandKind::InlineNone }), + 208 => Some(OpcodeInfo { name: "ldtoken", operand: OperandKind::InlineTok }), + 209 => Some(OpcodeInfo { name: "conv.u2", operand: OperandKind::InlineNone }), + 21 => Some(OpcodeInfo { name: "ldc.i4.m1", operand: OperandKind::InlineNone }), + 210 => Some(OpcodeInfo { name: "conv.u1", operand: OperandKind::InlineNone }), + 211 => Some(OpcodeInfo { name: "conv.i", operand: OperandKind::InlineNone }), + 212 => Some(OpcodeInfo { name: "conv.ovf.i", operand: OperandKind::InlineNone }), + 213 => Some(OpcodeInfo { name: "conv.ovf.u", operand: OperandKind::InlineNone }), + 214 => Some(OpcodeInfo { name: "add.ovf", operand: OperandKind::InlineNone }), + 215 => Some(OpcodeInfo { name: "add.ovf.un", operand: OperandKind::InlineNone }), + 216 => Some(OpcodeInfo { name: "mul.ovf", operand: OperandKind::InlineNone }), + 217 => Some(OpcodeInfo { name: "mul.ovf.un", operand: OperandKind::InlineNone }), + 218 => Some(OpcodeInfo { name: "sub.ovf", operand: OperandKind::InlineNone }), + 219 => Some(OpcodeInfo { name: "sub.ovf.un", operand: OperandKind::InlineNone }), + 22 => Some(OpcodeInfo { name: "ldc.i4.0", operand: OperandKind::InlineNone }), + 220 => Some(OpcodeInfo { name: "endfinally", operand: OperandKind::InlineNone }), + 221 => Some(OpcodeInfo { name: "leave", operand: OperandKind::InlineBrTarget }), + 222 => Some(OpcodeInfo { name: "leave.s", operand: OperandKind::ShortInlineBrTarget }), + 223 => Some(OpcodeInfo { name: "stind.i", operand: OperandKind::InlineNone }), + 224 => Some(OpcodeInfo { name: "conv.u", operand: OperandKind::InlineNone }), + 23 => Some(OpcodeInfo { name: "ldc.i4.1", operand: OperandKind::InlineNone }), + 24 => Some(OpcodeInfo { name: "ldc.i4.2", operand: OperandKind::InlineNone }), + 248 => Some(OpcodeInfo { name: "prefix7", operand: OperandKind::InlineNone }), + 249 => Some(OpcodeInfo { name: "prefix6", operand: OperandKind::InlineNone }), + 25 => Some(OpcodeInfo { name: "ldc.i4.3", operand: OperandKind::InlineNone }), + 250 => Some(OpcodeInfo { name: "prefix5", operand: OperandKind::InlineNone }), + 251 => Some(OpcodeInfo { name: "prefix4", operand: OperandKind::InlineNone }), + 252 => Some(OpcodeInfo { name: "prefix3", operand: OperandKind::InlineNone }), + 253 => Some(OpcodeInfo { name: "prefix2", operand: OperandKind::InlineNone }), + 254 => Some(OpcodeInfo { name: "prefix1", operand: OperandKind::InlineNone }), + 255 => Some(OpcodeInfo { name: "prefixref", operand: OperandKind::InlineNone }), + 26 => Some(OpcodeInfo { name: "ldc.i4.4", operand: OperandKind::InlineNone }), + 27 => Some(OpcodeInfo { name: "ldc.i4.5", operand: OperandKind::InlineNone }), + 28 => Some(OpcodeInfo { name: "ldc.i4.6", operand: OperandKind::InlineNone }), + 29 => Some(OpcodeInfo { name: "ldc.i4.7", operand: OperandKind::InlineNone }), + 3 => Some(OpcodeInfo { name: "ldarg.1", operand: OperandKind::InlineNone }), + 30 => Some(OpcodeInfo { name: "ldc.i4.8", operand: OperandKind::InlineNone }), + 31 => Some(OpcodeInfo { name: "ldc.i4.s", operand: OperandKind::ShortInlineI }), + 32 => Some(OpcodeInfo { name: "ldc.i4", operand: OperandKind::InlineI }), + 33 => Some(OpcodeInfo { name: "ldc.i8", operand: OperandKind::InlineI8 }), + 34 => Some(OpcodeInfo { name: "ldc.r4", operand: OperandKind::ShortInlineR }), + 35 => Some(OpcodeInfo { name: "ldc.r8", operand: OperandKind::InlineR }), + 37 => Some(OpcodeInfo { name: "dup", operand: OperandKind::InlineNone }), + 38 => Some(OpcodeInfo { name: "pop", operand: OperandKind::InlineNone }), + 39 => Some(OpcodeInfo { name: "jmp", operand: OperandKind::InlineMethod }), + 4 => Some(OpcodeInfo { name: "ldarg.2", operand: OperandKind::InlineNone }), + 40 => Some(OpcodeInfo { name: "call", operand: OperandKind::InlineMethod }), + 41 => Some(OpcodeInfo { name: "calli", operand: OperandKind::InlineSig }), + 42 => Some(OpcodeInfo { name: "ret", operand: OperandKind::InlineNone }), + 43 => Some(OpcodeInfo { name: "br.s", operand: OperandKind::ShortInlineBrTarget }), + 44 => Some(OpcodeInfo { name: "brfalse.s", operand: OperandKind::ShortInlineBrTarget }), + 45 => Some(OpcodeInfo { name: "brtrue.s", operand: OperandKind::ShortInlineBrTarget }), + 46 => Some(OpcodeInfo { name: "beq.s", operand: OperandKind::ShortInlineBrTarget }), + 47 => Some(OpcodeInfo { name: "bge.s", operand: OperandKind::ShortInlineBrTarget }), + 48 => Some(OpcodeInfo { name: "bgt.s", operand: OperandKind::ShortInlineBrTarget }), + 49 => Some(OpcodeInfo { name: "ble.s", operand: OperandKind::ShortInlineBrTarget }), + 5 => Some(OpcodeInfo { name: "ldarg.3", operand: OperandKind::InlineNone }), + 50 => Some(OpcodeInfo { name: "blt.s", operand: OperandKind::ShortInlineBrTarget }), + 51 => Some(OpcodeInfo { name: "bne.un.s", operand: OperandKind::ShortInlineBrTarget }), + 52 => Some(OpcodeInfo { name: "bge.un.s", operand: OperandKind::ShortInlineBrTarget }), + 53 => Some(OpcodeInfo { name: "bgt.un.s", operand: OperandKind::ShortInlineBrTarget }), + 54 => Some(OpcodeInfo { name: "ble.un.s", operand: OperandKind::ShortInlineBrTarget }), + 55 => Some(OpcodeInfo { name: "blt.un.s", operand: OperandKind::ShortInlineBrTarget }), + 56 => Some(OpcodeInfo { name: "br", operand: OperandKind::InlineBrTarget }), + 57 => Some(OpcodeInfo { name: "brfalse", operand: OperandKind::InlineBrTarget }), + 58 => Some(OpcodeInfo { name: "brtrue", operand: OperandKind::InlineBrTarget }), + 59 => Some(OpcodeInfo { name: "beq", operand: OperandKind::InlineBrTarget }), + 6 => Some(OpcodeInfo { name: "ldloc.0", operand: OperandKind::InlineNone }), + 60 => Some(OpcodeInfo { name: "bge", operand: OperandKind::InlineBrTarget }), + 61 => Some(OpcodeInfo { name: "bgt", operand: OperandKind::InlineBrTarget }), + 62 => Some(OpcodeInfo { name: "ble", operand: OperandKind::InlineBrTarget }), + 63 => Some(OpcodeInfo { name: "blt", operand: OperandKind::InlineBrTarget }), + 64 => Some(OpcodeInfo { name: "bne.un", operand: OperandKind::InlineBrTarget }), + 65 => Some(OpcodeInfo { name: "bge.un", operand: OperandKind::InlineBrTarget }), + 66 => Some(OpcodeInfo { name: "bgt.un", operand: OperandKind::InlineBrTarget }), + 67 => Some(OpcodeInfo { name: "ble.un", operand: OperandKind::InlineBrTarget }), + 68 => Some(OpcodeInfo { name: "blt.un", operand: OperandKind::InlineBrTarget }), + 69 => Some(OpcodeInfo { name: "switch", operand: OperandKind::InlineSwitch }), + 7 => Some(OpcodeInfo { name: "ldloc.1", operand: OperandKind::InlineNone }), + 70 => Some(OpcodeInfo { name: "ldind.i1", operand: OperandKind::InlineNone }), + 71 => Some(OpcodeInfo { name: "ldind.u1", operand: OperandKind::InlineNone }), + 72 => Some(OpcodeInfo { name: "ldind.i2", operand: OperandKind::InlineNone }), + 73 => Some(OpcodeInfo { name: "ldind.u2", operand: OperandKind::InlineNone }), + 74 => Some(OpcodeInfo { name: "ldind.i4", operand: OperandKind::InlineNone }), + 75 => Some(OpcodeInfo { name: "ldind.u4", operand: OperandKind::InlineNone }), + 76 => Some(OpcodeInfo { name: "ldind.i8", operand: OperandKind::InlineNone }), + 77 => Some(OpcodeInfo { name: "ldind.i", operand: OperandKind::InlineNone }), + 78 => Some(OpcodeInfo { name: "ldind.r4", operand: OperandKind::InlineNone }), + 79 => Some(OpcodeInfo { name: "ldind.r8", operand: OperandKind::InlineNone }), + 8 => Some(OpcodeInfo { name: "ldloc.2", operand: OperandKind::InlineNone }), + 80 => Some(OpcodeInfo { name: "ldind.ref", operand: OperandKind::InlineNone }), + 81 => Some(OpcodeInfo { name: "stind.ref", operand: OperandKind::InlineNone }), + 82 => Some(OpcodeInfo { name: "stind.i1", operand: OperandKind::InlineNone }), + 83 => Some(OpcodeInfo { name: "stind.i2", operand: OperandKind::InlineNone }), + 84 => Some(OpcodeInfo { name: "stind.i4", operand: OperandKind::InlineNone }), + 85 => Some(OpcodeInfo { name: "stind.i8", operand: OperandKind::InlineNone }), + 86 => Some(OpcodeInfo { name: "stind.r4", operand: OperandKind::InlineNone }), + 87 => Some(OpcodeInfo { name: "stind.r8", operand: OperandKind::InlineNone }), + 88 => Some(OpcodeInfo { name: "add", operand: OperandKind::InlineNone }), + 89 => Some(OpcodeInfo { name: "sub", operand: OperandKind::InlineNone }), + 9 => Some(OpcodeInfo { name: "ldloc.3", operand: OperandKind::InlineNone }), + 90 => Some(OpcodeInfo { name: "mul", operand: OperandKind::InlineNone }), + 91 => Some(OpcodeInfo { name: "div", operand: OperandKind::InlineNone }), + 92 => Some(OpcodeInfo { name: "div.un", operand: OperandKind::InlineNone }), + 93 => Some(OpcodeInfo { name: "rem", operand: OperandKind::InlineNone }), + 94 => Some(OpcodeInfo { name: "rem.un", operand: OperandKind::InlineNone }), + 95 => Some(OpcodeInfo { name: "and", operand: OperandKind::InlineNone }), + 96 => Some(OpcodeInfo { name: "or", operand: OperandKind::InlineNone }), + 97 => Some(OpcodeInfo { name: "xor", operand: OperandKind::InlineNone }), + 98 => Some(OpcodeInfo { name: "shl", operand: OperandKind::InlineNone }), + 99 => Some(OpcodeInfo { name: "shr", operand: OperandKind::InlineNone }), diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..8b63e4e --- /dev/null +++ b/renovate.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":dependencyDashboard"], + "timezone": "Asia/Shanghai", + "labels": ["dependencies"], + "prConcurrentLimit": 3, + "prHourlyLimit": 1, + "rangeStrategy": "pin", + "packageRules": [ + { + "matchManagers": ["cargo", "github-actions"], + "matchUpdateTypes": ["patch"], + "automerge": true, + "automergeType": "pr", + "minimumReleaseAge": "3 days" + }, + { + "matchPackageNames": ["/^tree-sitter/"], + "enabled": false, + "description": "Vendored tree-sitter updates need a coordinated source refresh." + } + ] +} diff --git a/scripts/benchmark-mhash-smoke.ps1 b/scripts/benchmark-mhash-smoke.ps1 new file mode 100644 index 0000000..bb3615d --- /dev/null +++ b/scripts/benchmark-mhash-smoke.ps1 @@ -0,0 +1,138 @@ +[CmdletBinding()] +param( + [string]$DigestPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\release-fast\mhash.exe'), + [string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\mhash-benchmark\smoke'), + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-Smoke { + param( + [Parameter(Mandatory = $true)] + [bool]$Condition, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if (-not $Condition) { + throw "Smoke assertion failed: $Message" + } +} + +function Get-SingleLatestFile { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + [Parameter(Mandatory = $true)] + [string]$Filter + ) + + $file = Get-ChildItem -LiteralPath $Root -Filter $Filter -File | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + Assert-Smoke -Condition ($null -ne $file) -Message "Expected $Filter under $Root." + return $file +} + +function Read-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +$harness = Join-Path $PSScriptRoot 'benchmark-mhash.ps1' +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$smokeRoot = Join-Path $OutputRoot $stamp +$runRoot = Join-Path $smokeRoot 'results' +$dataRoot = Join-Path $smokeRoot 'data' + +$benchmarkArgs = @{ + DataRoot = $dataRoot + OutputRoot = $runRoot + Sizes = @('1KiB') + Algorithms = @('sha256') + Repeat = 1 + Warmup = 0 + DigestPath = $DigestPath + NoExternal = $true + DigestFormats = @('sum', 'jsonl') +} +if ($SkipBuild) { + $benchmarkArgs.SkipBuild = $true +} + +& $harness @benchmarkArgs + +$rawPath = Get-SingleLatestFile -Root $runRoot -Filter '*.raw.json' +$summaryPath = Get-SingleLatestFile -Root $runRoot -Filter '*.summary.json' +$recordsJsonlPath = Get-SingleLatestFile -Root $runRoot -Filter '*.records.jsonl' +$summaryJsonlPath = Get-SingleLatestFile -Root $runRoot -Filter '*.summary.jsonl' +$artifactsPath = Get-SingleLatestFile -Root $runRoot -Filter '*.artifacts.json' + +$raw = Read-JsonFile -Path $rawPath.FullName +$summary = @(Read-JsonFile -Path $summaryPath.FullName) +$artifacts = @(Read-JsonFile -Path $artifactsPath.FullName) +$records = @($raw.records) +$recordsJsonlLines = @(Get-Content -LiteralPath $recordsJsonlPath.FullName) +$summaryJsonlLines = @(Get-Content -LiteralPath $summaryJsonlPath.FullName) + +Assert-Smoke -Condition ($raw.environment.mhash_formats.Count -eq 2) -Message 'Expected both sum and jsonl mhash formats in environment metadata.' +Assert-Smoke -Condition ($raw.environment.size_only -eq $false) -Message 'Expected normal benchmark mode in raw environment metadata.' +Assert-Smoke -Condition ($artifacts.Count -ge 1) -Message 'Expected at least one tool artifact record.' +Assert-Smoke -Condition ($records.Count -eq 4) -Message "Expected 4 measured records, got $($records.Count)." +Assert-Smoke -Condition ($summary.Count -eq 4) -Message "Expected 4 summary records, got $($summary.Count)." +Assert-Smoke -Condition ($recordsJsonlLines.Count -eq $records.Count) -Message 'Records JSONL line count should match raw record count.' +Assert-Smoke -Condition ($summaryJsonlLines.Count -eq $summary.Count) -Message 'Summary JSONL line count should match summary count.' + +foreach ($record in $records) { + Assert-Smoke -Condition ($record.binary_size_bytes -gt 0) -Message 'Each measured record should include binary_size_bytes.' + Assert-Smoke -Condition (-not [string]::IsNullOrWhiteSpace($record.output_format)) -Message 'Each measured record should include output_format.' + Assert-Smoke -Condition ($record.peak_working_set_bytes -gt 0) -Message 'Each measured record should include peak_working_set_bytes.' +} + +foreach ($row in $summary) { + Assert-Smoke -Condition ($row.binary_size_bytes -gt 0) -Message 'Each summary row should include binary_size_bytes.' + Assert-Smoke -Condition (-not [string]::IsNullOrWhiteSpace($row.output_format)) -Message 'Each summary row should include output_format.' +} + +$sizeOnlyRoot = Join-Path $smokeRoot 'size-only' +$sizeOnlyArgs = @{ + DataRoot = (Join-Path $smokeRoot 'size-only-data') + OutputRoot = $sizeOnlyRoot + Sizes = @('1KiB') + Algorithms = @('sha256', 'blake3-256', 'blake2sp', 'xxh3-64', 'xxh3-128', 'crc32', 'crc64-xz', 'k12-256', 'parallelhash256-528') + Repeat = 1 + Warmup = 0 + DigestPath = $DigestPath + SkipBuild = $true + NoExternal = $true + SizeOnly = $true +} + +& $harness @sizeOnlyArgs + +$sizeOnlyRawPath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.raw.json' +$sizeOnlySummaryPath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.summary.json' +$sizeOnlyHyperfinePath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.hyperfine.json' +$sizeOnlyArtifactsPath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.artifacts.json' +$sizeOnlyRaw = Read-JsonFile -Path $sizeOnlyRawPath.FullName +$sizeOnlySummary = @(Read-JsonFile -Path $sizeOnlySummaryPath.FullName) +$sizeOnlyHyperfine = @(Read-JsonFile -Path $sizeOnlyHyperfinePath.FullName) +$sizeOnlyArtifacts = @(Read-JsonFile -Path $sizeOnlyArtifactsPath.FullName) + +Assert-Smoke -Condition ($sizeOnlyRaw.environment.size_only -eq $true) -Message 'Expected size-only mode in raw environment metadata.' +Assert-Smoke -Condition ($sizeOnlyRaw.environment.algorithms -contains 'blake3-256') -Message 'Size-only smoke should accept extended mhash benchmark algorithms.' +Assert-Smoke -Condition ($sizeOnlyRaw.environment.algorithms -contains 'parallelhash256-528') -Message 'Size-only smoke should accept ParallelHash mhash benchmark algorithms.' +Assert-Smoke -Condition (@($sizeOnlyRaw.records).Count -eq 0) -Message 'Size-only mode should not emit measured records.' +Assert-Smoke -Condition ($sizeOnlySummary.Count -eq 0) -Message 'Size-only summary JSON should be an empty array.' +Assert-Smoke -Condition ($sizeOnlyHyperfine.Count -eq 0) -Message 'Size-only hyperfine JSON should be an empty array.' +Assert-Smoke -Condition ($sizeOnlyArtifacts.Count -ge 1) -Message 'Size-only mode should still emit artifact telemetry.' + +Write-Host "Smoke benchmark raw: $($rawPath.FullName)" +Write-Host "Smoke size-only raw: $($sizeOnlyRawPath.FullName)" +Write-Host "Smoke assertions passed: records=$($records.Count), summary=$($summary.Count), artifacts=$($artifacts.Count)" diff --git a/scripts/benchmark-mhash.ps1 b/scripts/benchmark-mhash.ps1 new file mode 100644 index 0000000..d145e8e --- /dev/null +++ b/scripts/benchmark-mhash.ps1 @@ -0,0 +1,1041 @@ +[CmdletBinding()] +param( + [string]$DataRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\mhash-benchmark\data'), + [string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\mhash-benchmark\results'), + [string[]]$Sizes = @('1MiB', '64MiB', '256MiB'), + [ValidateSet( + 'crc32', 'crc-32', 'crc64-xz', 'crc-64-xz', 'crc64', + 'xxh32', 'xxhash32', 'xxhash-32', 'xxh64', 'xxhash64', 'xxhash-64', + 'xxh3', 'xxh3-64', 'xxhash3-64', 'xxh3-128', 'xxhash3-128', + 'md4', 'md5', 'ripemd160', 'ripemd-160', 'blake2sp', 'blake2-sp', + 'sha1', 'sha-1', 'sha224', 'sha-224', 'sha256', 'sha-256', + 'sha384', 'sha-384', 'sha512', 'sha-512', + 'sha3-224', 'sha3_224', 'sha3-256', 'sha3_256', + 'sha3-384', 'sha3_384', 'sha3-512', 'sha3_512', + 'blake3', 'blake3-256', 'blake3-512', + 'kangarootwelve-264', 'k12-264', 'kangarootwelve-256', + 'k12-256', 'kangarootwelve', 'kangarootwelve-512', 'k12-512', + 'parallelhash128-264', 'parallelhash-128-264', + 'parallelhash256-528', 'parallelhash-256-528', + 'streebog-256', 'streebog256', 'gost-256', + 'streebog-512', 'streebog512', 'gost-512' + )] + [string[]]$Algorithms = @('md5', 'sha1', 'sha256', 'sha512'), + [ValidateSet('text', 'sum', 'json', 'jsonl')] + [string[]]$DigestFormats = @('sum'), + [int]$Repeat = 3, + [int]$Warmup = 1, + [string]$DigestPath, + [switch]$SkipBuild, + [switch]$NoExternal, + [switch]$Extended, + [switch]$SizeOnly, + [switch]$Hyperfine, + [int]$HyperfineRuns = 0, + [int]$HyperfineWarmup = -1 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if ($Extended) { + if (-not $PSBoundParameters.ContainsKey('Sizes')) { + $Sizes = @('1MiB', '64MiB', '256MiB', '1GiB') + } + if (-not $PSBoundParameters.ContainsKey('Repeat')) { + $Repeat = 5 + } + if (-not $PSBoundParameters.ContainsKey('Warmup')) { + $Warmup = 2 + } + if (-not $PSBoundParameters.ContainsKey('DigestFormats')) { + $DigestFormats = @('sum', 'jsonl', 'json') + } +} + +if ($Repeat -lt 0) { + throw 'Repeat must be zero or greater.' +} +if (($Repeat -eq 0) -and (-not $SizeOnly)) { + throw 'Repeat must be at least 1 unless -SizeOnly is set.' +} +if ($Warmup -lt 0) { + throw 'Warmup must be zero or greater.' +} +if ($HyperfineRuns -lt 0) { + throw 'HyperfineRuns must be zero or greater. Use 0 to mirror -Repeat.' +} +if ($HyperfineWarmup -lt -1) { + throw 'HyperfineWarmup must be -1 or greater. Use -1 to mirror -Warmup.' +} +if ($HyperfineRuns -eq 0) { + $HyperfineRuns = [Math]::Max(1, $Repeat) +} +if ($HyperfineWarmup -eq -1) { + $HyperfineWarmup = $Warmup +} + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function ConvertTo-ByteCount { + param( + [Parameter(Mandatory = $true)] + [string]$Value + ) + + if ($Value -notmatch '^(?\d+)(?B|KiB|MiB|GiB|K|M|G)?$') { + throw "Invalid size '$Value'. Use values like 1MiB, 64MiB, or 1GiB." + } + + $number = [int64]$Matches.number + $unit = if ($Matches.unit) { $Matches.unit } else { 'B' } + switch ($unit) { + 'B' { return $number } + { $_ -in @('K', 'KiB') } { return $number * 1024L } + { $_ -in @('M', 'MiB') } { return $number * 1024L * 1024L } + { $_ -in @('G', 'GiB') } { return $number * 1024L * 1024L * 1024L } + default { throw "Unsupported size unit '$unit'." } + } +} + +function Format-ByteSize { + param( + [Parameter(Mandatory = $true)] + [int64]$Bytes + ) + + if ($Bytes -ge 1GB) { + return ('{0:N0}GiB' -f ($Bytes / 1GB)) + } + if ($Bytes -ge 1MB) { + return ('{0:N0}MiB' -f ($Bytes / 1MB)) + } + if ($Bytes -ge 1KB) { + return ('{0:N0}KiB' -f ($Bytes / 1KB)) + } + return "${Bytes}B" +} + +function ConvertTo-FileSha256 { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + catch { + return $null + } +} + +function ConvertTo-JsonLines { + param( + [object[]]$InputObject = @(), + [int]$Depth = 8 + ) + + foreach ($item in $InputObject) { + $item | ConvertTo-Json -Depth $Depth -Compress + } +} + +function Write-JsonLines { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [object[]]$InputObject = @(), + [int]$Depth = 8 + ) + + $lines = @(ConvertTo-JsonLines -InputObject $InputObject -Depth $Depth) + if ($lines.Count -eq 0) { + [System.IO.File]::WriteAllText($Path, '', [System.Text.UTF8Encoding]::new($false)) + return + } + Set-Content -LiteralPath $Path -Value $lines -Encoding utf8NoBOM +} + +function ConvertTo-QuotedArgument { + param( + [Parameter(Mandatory = $true)] + [string]$Argument + ) + + if ($Argument -match '^[A-Za-z0-9_./:\\-]+$') { + return $Argument + } + + return '"' + ($Argument -replace '"', '\"') + '"' +} + +function ConvertTo-NativeCommandLine { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $parts = @($FilePath) + $ArgumentList + return (($parts | ForEach-Object { ConvertTo-QuotedArgument -Argument $_ }) -join ' ') +} + +function New-DeterministicFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [int64]$Bytes + ) + + if ((Test-Path -LiteralPath $Path -PathType Leaf) -and ((Get-Item -LiteralPath $Path).Length -eq $Bytes)) { + return + } + + $directory = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null + } + + $buffer = [byte[]]::new(1MB) + for ($index = 0; $index -lt $buffer.Length; $index++) { + $buffer[$index] = [byte](($index * 31 + 17) -band 0xff) + } + + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) + try { + $remaining = $Bytes + while ($remaining -gt 0) { + $write = [int][Math]::Min($buffer.Length, $remaining) + $stream.Write($buffer, 0, $write) + $remaining -= $write + } + } + finally { + $stream.Dispose() + } +} + +function Resolve-CommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + return $command.Source +} + +function Resolve-CoreutilsCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $source = Resolve-CommandPath -Name $Name + if ([string]::IsNullOrWhiteSpace($source)) { + return $null + } + + $candidate = Join-Path $env:USERPROFILE "scoop\apps\uutils-coreutils\current\$Name.exe" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + + return $source +} + +function Get-ToolVersion { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Path, + [string[]]$VersionArguments = @('--version') + ) + + try { + $output = @(& $Path @VersionArguments 2>&1) + $firstLine = @($output | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -First 1) + if ($firstLine.Count -gt 0) { + $candidate = [string]$firstLine[0] + if ($candidate.IndexOf([char]0xfffd) -lt 0) { + return $candidate + } + } + } + catch { + } + + try { + $item = Get-Item -LiteralPath $Path -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($item.VersionInfo.ProductVersion)) { + return "$Name $($item.VersionInfo.ProductVersion)" + } + if (-not [string]::IsNullOrWhiteSpace($item.VersionInfo.FileVersion)) { + return "$Name $($item.VersionInfo.FileVersion)" + } + } + catch { + } + + return $Name +} + +function Get-BinaryArtifactInfo { + param( + [Parameter(Mandatory = $true)] + [string]$Tool, + [Parameter(Mandatory = $true)] + [string]$Path, + [string]$Version = '' + ) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + return [pscustomobject]@{ + tool = $Tool + path = $fullPath + exists = $false + size_bytes = $null + size_label = '' + sha256 = '' + last_write_utc = '' + product_version = '' + file_version = '' + version = $Version + } + } + + $item = Get-Item -LiteralPath $fullPath + [pscustomobject]@{ + tool = $Tool + path = $fullPath + exists = $true + size_bytes = [int64]$item.Length + size_label = Format-ByteSize -Bytes ([int64]$item.Length) + sha256 = ConvertTo-FileSha256 -Path $fullPath + last_write_utc = $item.LastWriteTimeUtc.ToString('o') + product_version = if ($item.VersionInfo) { [string]$item.VersionInfo.ProductVersion } else { '' } + file_version = if ($item.VersionInfo) { [string]$item.VersionInfo.FileVersion } else { '' } + version = $Version + } +} + +function Get-GitMetadata { + param( + [Parameter(Mandatory = $true)] + [string]$Root + ) + + $git = Resolve-CommandPath -Name 'git' + if ([string]::IsNullOrWhiteSpace($git)) { + return [pscustomobject]@{ + available = $false + commit = '' + branch = '' + status_short = @() + } + } + + Push-Location -LiteralPath $Root + try { + $commit = (& $git rev-parse --short HEAD 2>$null) + $branch = (& $git branch --show-current 2>$null) + $status = @(& $git status --short 2>$null) + return [pscustomobject]@{ + available = $true + commit = if ($LASTEXITCODE -eq 0) { [string]$commit } else { '' } + branch = [string]$branch + status_short = $status + } + } + catch { + return [pscustomobject]@{ + available = $true + commit = '' + branch = '' + status_short = @("git metadata failed: $($_.Exception.Message)") + } + } + finally { + Pop-Location + } +} + +function New-Candidate { + param( + [Parameter(Mandatory = $true)] + [string]$Tool, + [Parameter(Mandatory = $true)] + [string]$Algorithm, + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [string]$OutputFormat = 'native', + [string]$Version = '' + ) + + [pscustomobject]@{ + Tool = $Tool + Algorithm = $Algorithm + FilePath = $FilePath + Arguments = $Arguments + OutputFormat = $OutputFormat + Version = $Version + } +} + +function New-CandidatesForAlgorithm { + param( + [Parameter(Mandatory = $true)] + [string]$Algorithm, + [Parameter(Mandatory = $true)] + [string]$InputPath, + [Parameter(Mandatory = $true)] + [string]$DigestExe, + [Parameter(Mandatory = $true)] + [hashtable]$Tools, + [Parameter(Mandatory = $true)] + [string[]]$DigestFormats, + [switch]$NoExternal + ) + + $candidates = [System.Collections.Generic.List[object]]::new() + foreach ($format in $DigestFormats) { + $candidates.Add((New-Candidate -Tool 'mercury-mhash:auto' -Algorithm $Algorithm -FilePath $DigestExe -Arguments @('--algorithm', $Algorithm, '--format', $format, $InputPath) -OutputFormat $format -Version $Tools.mhash_version)) + $candidates.Add((New-Candidate -Tool 'mercury-mhash:read' -Algorithm $Algorithm -FilePath $DigestExe -Arguments @('--algorithm', $Algorithm, '--format', $format, '--io', 'read', $InputPath) -OutputFormat $format -Version $Tools.mhash_version)) + } + + if (-not $NoExternal) { + if ($Tools.pwsh) { + $psAlgorithm = @{ + md5 = 'MD5' + sha1 = 'SHA1' + sha256 = 'SHA256' + sha512 = 'SHA512' + }[$Algorithm] + if ($psAlgorithm) { + $escapedInputPath = $InputPath.Replace("'", "''") + $script = "Get-FileHash -LiteralPath '$escapedInputPath' -Algorithm $psAlgorithm | Select-Object -ExpandProperty Hash" + $candidates.Add((New-Candidate -Tool 'powershell:Get-FileHash' -Algorithm $Algorithm -FilePath $Tools.pwsh -Arguments @('-NoProfile', '-Command', $script) -Version $Tools.pwsh_version)) + } + } + + if ($Tools.certutil) { + $certAlgorithm = @{ + md5 = 'MD5' + sha1 = 'SHA1' + sha256 = 'SHA256' + sha512 = 'SHA512' + }[$Algorithm] + if ($certAlgorithm) { + $candidates.Add((New-Candidate -Tool 'windows:certutil' -Algorithm $Algorithm -FilePath $Tools.certutil -Arguments @('-hashfile', $InputPath, $certAlgorithm) -Version $Tools.certutil_version)) + } + } + + $sumPath = $Tools["${Algorithm}sum"] + if ($sumPath) { + $candidates.Add((New-Candidate -Tool "coreutils:${Algorithm}sum" -Algorithm $Algorithm -FilePath $sumPath -Arguments @($InputPath) -Version $Tools["${Algorithm}sum_version"])) + } + + if ($Tools.openssl) { + $opensslAlgorithm = @{ + md5 = 'md5' + sha1 = 'sha1' + sha256 = 'sha256' + sha512 = 'sha512' + }[$Algorithm] + if ($opensslAlgorithm) { + $candidates.Add((New-Candidate -Tool 'openssl:dgst' -Algorithm $Algorithm -FilePath $Tools.openssl -Arguments @('dgst', "-$opensslAlgorithm", $InputPath) -Version $Tools.openssl_version)) + } + } + } + + return $candidates +} + +function Invoke-MeasuredProcess { + param( + [Parameter(Mandatory = $true)] + [object]$Candidate + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Candidate.FilePath + foreach ($argument in $Candidate.Arguments) { + [void]$startInfo.ArgumentList.Add($argument) + } + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.CreateNoWindow = $true + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $started = $process.Start() + if (-not $started) { + throw "Failed to start $($Candidate.Tool)." + } + + $peakWorkingSet = 0L + try { + $process.Refresh() + $peakWorkingSet = [Math]::Max($peakWorkingSet, [int64]$process.WorkingSet64) + } + catch { + } + while (-not $process.WaitForExit(5)) { + try { + $process.Refresh() + $peakWorkingSet = [Math]::Max($peakWorkingSet, [int64]$process.WorkingSet64) + } + catch { + } + } + $process.WaitForExit() + $timer.Stop() + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + try { + $process.Refresh() + $peakWorkingSet = [Math]::Max($peakWorkingSet, [int64]$process.PeakWorkingSet64) + } + catch { + } + + $elapsedMs = $timer.Elapsed.TotalMilliseconds + $cpuMs = $process.TotalProcessorTime.TotalMilliseconds + $exitCode = $process.ExitCode + $process.Dispose() + + $cpuOneCorePercent = if ($elapsedMs -gt 0) { ($cpuMs / $elapsedMs) * 100.0 } else { 0.0 } + $cpuMachinePercent = if ([Environment]::ProcessorCount -gt 0) { $cpuOneCorePercent / [Environment]::ProcessorCount } else { 0.0 } + + [pscustomobject]@{ + elapsed_ms = $elapsedMs + cpu_ms = $cpuMs + cpu_one_core_percent = $cpuOneCorePercent + cpu_machine_percent = $cpuMachinePercent + peak_working_set_bytes = $peakWorkingSet + exit_code = $exitCode + stdout = $stdout.Trim() + stderr = $stderr.Trim() + } +} + +function Invoke-HyperfineSuite { + param( + [Parameter(Mandatory = $true)] + [string]$HyperfinePath, + [Parameter(Mandatory = $true)] + [object]$File, + [Parameter(Mandatory = $true)] + [string]$Algorithm, + [Parameter(Mandatory = $true)] + [object[]]$Candidates, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [Parameter(Mandatory = $true)] + [int]$Runs, + [Parameter(Mandatory = $true)] + [int]$Warmup + ) + + $arguments = @( + '--style', 'basic', + '--shell', 'none', + '--output', 'pipe', + '--warmup', [string]$Warmup, + '--runs', [string]$Runs, + '--export-json', $OutputPath + ) + foreach ($candidate in $Candidates) { + $name = '{0}/{1}/{2}/{3}' -f $candidate.Tool, $candidate.OutputFormat, $Algorithm, $File.label + $commandLine = ConvertTo-NativeCommandLine -FilePath $candidate.FilePath -ArgumentList $candidate.Arguments + $arguments += @('--command-name', $name, $commandLine) + } + + $started = (Get-Date).ToUniversalTime().ToString('o') + $output = @(& $HyperfinePath @arguments 2>&1) + $exitCode = $LASTEXITCODE + $finished = (Get-Date).ToUniversalTime().ToString('o') + $parsed = $null + if (Test-Path -LiteralPath $OutputPath -PathType Leaf) { + try { + $parsed = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + } + catch { + Write-Warning "Failed to parse hyperfine JSON ${OutputPath}: $($_.Exception.Message)" + } + } + + [pscustomobject]@{ + timestamp_utc = $started + finished_utc = $finished + size_label = $File.label + bytes = $File.bytes + algorithm = $Algorithm + runs = $Runs + warmup = $Warmup + export_path = $OutputPath + exit_code = $exitCode + stdout_sample = (($output | Select-Object -First 20) -join [Environment]::NewLine) + stderr_sample = '' + results = if ($null -ne $parsed) { $parsed.results } else { @() } + } +} + +function ConvertTo-Summary { + param( + [object[]]$Records = @() + ) + + $successful = @($Records | Where-Object exit_code -eq 0) + $successful | + Group-Object size_label, algorithm, tool, output_format | + ForEach-Object { + $rows = @($_.Group) + $first = $rows[0] + $elapsed = @($rows | ForEach-Object { [double]$_.elapsed_ms }) + $cpuMs = @($rows | ForEach-Object { [double]$_.cpu_ms }) + $cpu = @($rows | ForEach-Object { [double]$_.cpu_one_core_percent }) + $memory = @($rows | ForEach-Object { [double]$_.peak_working_set_bytes }) + $bytes = [double]$first.bytes + $meanElapsed = ($elapsed | Measure-Object -Average).Average + $minElapsed = ($elapsed | Measure-Object -Minimum).Minimum + $meanCpuMs = ($cpuMs | Measure-Object -Average).Average + $maxMemory = ($memory | Measure-Object -Maximum).Maximum + $meanCpu = ($cpu | Measure-Object -Average).Average + $throughput = if ($meanElapsed -gt 0) { ($bytes / 1MB) / ($meanElapsed / 1000.0) } else { 0.0 } + $binarySizeBytes = if ($null -ne $first.binary_size_bytes) { [int64]$first.binary_size_bytes } else { 0L } + [pscustomobject]@{ + size_label = $first.size_label + bytes = [int64]$first.bytes + algorithm = $first.algorithm + tool = $first.tool + output_format = $first.output_format + runs = $rows.Count + mean_ms = [Math]::Round($meanElapsed, 3) + min_ms = [Math]::Round($minElapsed, 3) + mean_cpu_ms = [Math]::Round($meanCpuMs, 3) + throughput_mib_s = [Math]::Round($throughput, 2) + peak_working_set_mib = [Math]::Round($maxMemory / 1MB, 2) + mean_cpu_one_core_percent = [Math]::Round($meanCpu, 1) + binary_size_bytes = $binarySizeBytes + binary_size_label = if ($binarySizeBytes -gt 0) { Format-ByteSize -Bytes $binarySizeBytes } else { '' } + version = $first.version + } + } | + Sort-Object algorithm, bytes, output_format, mean_ms, tool +} + +function Write-MarkdownReport { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [object[]]$Summary = @(), + [Parameter(Mandatory = $true)] + [object]$Environment, + [object[]]$Artifacts = @(), + [object[]]$HyperfineRows = @() + ) + + $builder = [System.Text.StringBuilder]::new() + [void]$builder.AppendLine('# Digest Benchmark Report') + [void]$builder.AppendLine() + [void]$builder.AppendLine('This report is produced by `scripts/benchmark-mhash.ps1`. It measures warm-cache CLI hashing throughput and process telemetry. Windows does not provide a safe non-admin cache-drop primitive, so disk-cold numbers are intentionally out of scope.') + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Environment') + [void]$builder.AppendLine() + [void]$builder.AppendLine(('- Mode: `{0}`' -f $Environment.mode)) + [void]$builder.AppendLine(('- Timestamp: `{0}`' -f $Environment.timestamp_utc)) + [void]$builder.AppendLine(('- Machine: `{0}`' -f $Environment.machine_name)) + [void]$builder.AppendLine(('- OS: `{0}`' -f $Environment.os)) + [void]$builder.AppendLine(('- Process architecture: `{0}`' -f $Environment.process_architecture)) + [void]$builder.AppendLine(('- Processor count: `{0}`' -f $Environment.processor_count)) + [void]$builder.AppendLine(('- PowerShell: `{0}`' -f $Environment.powershell_version)) + [void]$builder.AppendLine(('- Git: `{0}` `{1}`' -f $Environment.git.branch, $Environment.git.commit)) + [void]$builder.AppendLine(('- Git status entries: `{0}`' -f @($Environment.git.status_short).Count)) + [void]$builder.AppendLine(('- Sizes: `{0}`' -f ($Environment.sizes -join ', '))) + [void]$builder.AppendLine(('- Algorithms: `{0}`' -f ($Environment.algorithms -join ', '))) + [void]$builder.AppendLine(('- Digest formats: `{0}`' -f ($Environment.mhash_formats -join ', '))) + [void]$builder.AppendLine(('- Repeat: `{0}`' -f $Environment.repeat)) + [void]$builder.AppendLine(('- Warmup: `{0}`' -f $Environment.warmup)) + [void]$builder.AppendLine(('- External tools: `{0}`' -f (-not $Environment.no_external))) + [void]$builder.AppendLine(('- Hyperfine requested: `{0}`' -f $Environment.hyperfine_requested)) + [void]$builder.AppendLine(('- Data root: `{0}`' -f $Environment.data_root)) + [void]$builder.AppendLine(('- Output root: `{0}`' -f $Environment.output_root)) + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Tool Artifacts') + [void]$builder.AppendLine() + if ($Artifacts.Count -eq 0) { + [void]$builder.AppendLine('No tool artifacts were discovered.') + } + else { + [void]$builder.AppendLine('| Tool | Size | SHA-256 | Path | Version |') + [void]$builder.AppendLine('|---|---:|---|---|---|') + foreach ($artifact in ($Artifacts | Sort-Object tool, path)) { + $sha = if (-not [string]::IsNullOrWhiteSpace($artifact.sha256)) { $artifact.sha256.Substring(0, [Math]::Min(16, $artifact.sha256.Length)) } else { '' } + [void]$builder.AppendLine(('| {0} | {1} | `{2}` | `{3}` | {4} |' -f $artifact.tool, $artifact.size_label, $sha, $artifact.path, $artifact.version)) + } + } + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Summary') + [void]$builder.AppendLine() + if ($Summary.Count -eq 0) { + [void]$builder.AppendLine('No process benchmark records were emitted. This is expected for `-SizeOnly` runs.') + } + else { + [void]$builder.AppendLine('| Algorithm | Size | Tool | Format | Mean ms | Min ms | CPU ms | MiB/s | Peak WS MiB | CPU % of one core | Binary |') + [void]$builder.AppendLine('|---|---:|---|---|---:|---:|---:|---:|---:|---:|---:|') + foreach ($row in $Summary) { + [void]$builder.AppendLine(('| {0} | {1} | {2} | {3} | {4:N3} | {5:N3} | {6:N3} | {7:N2} | {8:N2} | {9:N1} | {10} |' -f $row.algorithm, $row.size_label, $row.tool, $row.output_format, $row.mean_ms, $row.min_ms, $row.mean_cpu_ms, $row.throughput_mib_s, $row.peak_working_set_mib, $row.mean_cpu_one_core_percent, $row.binary_size_label)) + } + } + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Hyperfine') + [void]$builder.AppendLine() + if ($HyperfineRows.Count -eq 0) { + if ($Environment.hyperfine_requested) { + [void]$builder.AppendLine('Hyperfine was requested, but no hyperfine summary rows were produced. Check the raw hyperfine output for command failures.') + } + else { + [void]$builder.AppendLine('Hyperfine was not requested. Pass `-Hyperfine` to add hyperfine timing exports when `hyperfine` is installed.') + } + } + else { + [void]$builder.AppendLine('| Algorithm | Size | Command | Mean ms | Stddev ms | Min ms | Max ms | Runs |') + [void]$builder.AppendLine('|---|---:|---|---:|---:|---:|---:|---:|') + foreach ($row in $HyperfineRows) { + [void]$builder.AppendLine(('| {0} | {1} | {2} | {3:N3} | {4:N3} | {5:N3} | {6:N3} | {7} |' -f $row.algorithm, $row.size_label, $row.command, $row.mean_ms, $row.stddev_ms, $row.min_ms, $row.max_ms, $row.runs)) + } + } + [void]$builder.AppendLine() + [void]$builder.AppendLine('Raw JSON, JSONL, CSV, artifact, and optional hyperfine files are emitted beside this report for deeper analysis.') + + Set-Content -LiteralPath $Path -Value $builder.ToString() -Encoding utf8NoBOM +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$outputRootFull = [System.IO.Path]::GetFullPath($OutputRoot) +$dataRootFull = [System.IO.Path]::GetFullPath($DataRoot) +New-Item -ItemType Directory -Force -Path $outputRootFull, $dataRootFull | Out-Null +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$mode = if ($SizeOnly) { 'size-only' } elseif ($Extended) { 'extended' } else { 'default' } + +if ([string]::IsNullOrWhiteSpace($DigestPath)) { + $DigestPath = Join-Path $workspaceRoot 'target\release-fast\mhash.exe' +} +$DigestPath = [System.IO.Path]::GetFullPath($DigestPath) + +if (-not $SkipBuild) { + Invoke-StrictNative -FilePath 'cargo' -ArgumentList @('build', '--profile', 'release-fast', '-p', 'mercury-mhash', '--bin', 'mhash') +} +if (-not (Test-Path -LiteralPath $DigestPath -PathType Leaf)) { + throw "Digest binary not found: $DigestPath" +} + +$tools = @{ + mhash = $DigestPath + mhash_version = Get-ToolVersion -Name 'mhash' -Path $DigestPath + pwsh = Resolve-CommandPath -Name 'pwsh' + certutil = Resolve-CommandPath -Name 'certutil' + openssl = Resolve-CommandPath -Name 'openssl' + hyperfine = Resolve-CommandPath -Name 'hyperfine' + cargo = Resolve-CommandPath -Name 'cargo' + rustc = Resolve-CommandPath -Name 'rustc' +} +if ($tools.pwsh) { + $tools.pwsh_version = Get-ToolVersion -Name 'pwsh' -Path $tools.pwsh -VersionArguments @('-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()') +} +if ($tools.certutil) { + $tools.certutil_version = Get-ToolVersion -Name 'certutil' -Path $tools.certutil -VersionArguments @('-?') +} +if ($tools.openssl) { + $tools.openssl_version = Get-ToolVersion -Name 'openssl' -Path $tools.openssl -VersionArguments @('version') +} +if ($tools.hyperfine) { + $tools.hyperfine_version = Get-ToolVersion -Name 'hyperfine' -Path $tools.hyperfine +} +if ($tools.cargo) { + $tools.cargo_version = Get-ToolVersion -Name 'cargo' -Path $tools.cargo +} +if ($tools.rustc) { + $tools.rustc_version = Get-ToolVersion -Name 'rustc' -Path $tools.rustc +} +foreach ($algorithm in @('md5', 'sha1', 'sha256', 'sha512')) { + $name = "${algorithm}sum" + $path = Resolve-CoreutilsCommandPath -Name $name + $tools[$name] = $path + if ($path) { + $tools["${name}_version"] = Get-ToolVersion -Name $name -Path $path + } +} + +$artifactSpecs = [System.Collections.Generic.List[object]]::new() +$artifactSpecs.Add([pscustomobject]@{ tool = 'mercury-mhash'; path = $tools.mhash; version = $tools.mhash_version }) +foreach ($spec in @( + [pscustomobject]@{ tool = 'powershell:pwsh'; path = $tools.pwsh; version = if ($tools.ContainsKey('pwsh_version')) { $tools.pwsh_version } else { '' } }, + [pscustomobject]@{ tool = 'windows:certutil'; path = $tools.certutil; version = if ($tools.ContainsKey('certutil_version')) { $tools.certutil_version } else { '' } }, + [pscustomobject]@{ tool = 'openssl:dgst'; path = $tools.openssl; version = if ($tools.ContainsKey('openssl_version')) { $tools.openssl_version } else { '' } }, + [pscustomobject]@{ tool = 'hyperfine'; path = $tools.hyperfine; version = if ($tools.ContainsKey('hyperfine_version')) { $tools.hyperfine_version } else { '' } } + )) { + if (-not [string]::IsNullOrWhiteSpace($spec.path)) { + $artifactSpecs.Add($spec) + } +} +foreach ($algorithm in @('md5', 'sha1', 'sha256', 'sha512')) { + $name = "${algorithm}sum" + if (-not [string]::IsNullOrWhiteSpace($tools[$name])) { + $artifactSpecs.Add([pscustomobject]@{ + tool = "coreutils:$name" + path = $tools[$name] + version = if ($tools.ContainsKey("${name}_version")) { $tools["${name}_version"] } else { '' } + }) + } +} + +$toolArtifacts = @($artifactSpecs | ForEach-Object { + Get-BinaryArtifactInfo -Tool $_.tool -Path $_.path -Version $_.version + }) +$artifactByPath = @{} +foreach ($artifact in $toolArtifacts) { + if ($artifact.exists) { + $artifactByPath[$artifact.path] = $artifact + } +} + +$files = @() +if (-not $SizeOnly) { + $files = @(foreach ($size in $Sizes) { + $bytes = ConvertTo-ByteCount -Value $size + $label = Format-ByteSize -Bytes $bytes + $path = Join-Path $dataRootFull "mhash-$label.bin" + Write-Host "Preparing $label fixture: $path" + New-DeterministicFile -Path $path -Bytes $bytes + [pscustomobject]@{ + label = $label + bytes = $bytes + path = $path + sha256 = ConvertTo-FileSha256 -Path $path + } + }) +} + +$records = [System.Collections.Generic.List[object]]::new() +$matrix = [System.Collections.Generic.List[object]]::new() +if (-not $SizeOnly) { + foreach ($file in $files) { + foreach ($algorithm in $Algorithms) { + $candidates = @(New-CandidatesForAlgorithm -Algorithm $algorithm -InputPath $file.path -DigestExe $DigestPath -Tools $tools -DigestFormats $DigestFormats -NoExternal:$NoExternal) + foreach ($candidate in $candidates) { + $matrix.Add([pscustomobject]@{ + file = $file + algorithm = $algorithm + candidate = $candidate + }) + } + } + } +} + +if ($SizeOnly) { + Write-Host 'Size-only mode: skipping fixture generation and process benchmarks.' +} +else { + $totalRuns = ($Repeat + $Warmup) * $matrix.Count + Write-Host "Benchmark matrix: sizes=$($files.Count), algorithms=$($Algorithms.Count), mhash_formats=$($DigestFormats.Count), cases=$($matrix.Count), process_runs=$totalRuns, repeat=$Repeat, warmup=$Warmup" + foreach ($case in $matrix) { + $file = $case.file + $algorithm = $case.algorithm + $candidate = $case.candidate + $candidatePath = [System.IO.Path]::GetFullPath($candidate.FilePath) + $artifact = if ($artifactByPath.ContainsKey($candidatePath)) { $artifactByPath[$candidatePath] } else { $null } + $binarySizeBytes = if (($null -ne $artifact) -and ($artifact.exists)) { [int64]$artifact.size_bytes } else { 0L } + for ($iteration = -$Warmup; $iteration -lt $Repeat; $iteration++) { + $isWarmup = $iteration -lt 0 + $label = if ($isWarmup) { 'warmup' } else { "run $($iteration + 1)" } + Write-Host ("[{0} {1} {2}/{3}] {4}" -f $file.label, $algorithm, $candidate.Tool, $candidate.OutputFormat, $label) + $measurement = Invoke-MeasuredProcess -Candidate $candidate + if ($measurement.exit_code -ne 0) { + Write-Warning "$($candidate.Tool) $algorithm failed with exit code $($measurement.exit_code): $($measurement.stderr)" + } + if (-not $isWarmup) { + $records.Add([pscustomobject]@{ + timestamp_utc = (Get-Date).ToUniversalTime().ToString('o') + size_label = $file.label + bytes = $file.bytes + fixture_sha256 = $file.sha256 + algorithm = $algorithm + tool = $candidate.Tool + output_format = $candidate.OutputFormat + version = $candidate.Version + binary_path = $candidatePath + binary_size_bytes = $binarySizeBytes + binary_size_label = if ($binarySizeBytes -gt 0) { Format-ByteSize -Bytes $binarySizeBytes } else { '' } + repeat = $iteration + 1 + elapsed_ms = [Math]::Round($measurement.elapsed_ms, 3) + cpu_ms = [Math]::Round($measurement.cpu_ms, 3) + cpu_one_core_percent = [Math]::Round($measurement.cpu_one_core_percent, 3) + cpu_machine_percent = [Math]::Round($measurement.cpu_machine_percent, 3) + peak_working_set_bytes = $measurement.peak_working_set_bytes + exit_code = $measurement.exit_code + stdout_sample = if ($measurement.stdout.Length -gt 160) { $measurement.stdout.Substring(0, 160) } else { $measurement.stdout } + stderr_sample = if ($measurement.stderr.Length -gt 160) { $measurement.stderr.Substring(0, 160) } else { $measurement.stderr } + }) + } + } + } +} + +$hyperfineSuites = [System.Collections.Generic.List[object]]::new() +if ($Hyperfine -and (-not $SizeOnly)) { + if ([string]::IsNullOrWhiteSpace($tools.hyperfine)) { + Write-Warning 'Hyperfine was requested but hyperfine was not found on PATH. Skipping hyperfine integration.' + } + else { + foreach ($file in $files) { + foreach ($algorithm in $Algorithms) { + $suiteCandidates = @($matrix | + Where-Object { ($_.file.path -eq $file.path) -and ($_.algorithm -eq $algorithm) } | + ForEach-Object { $_.candidate }) + if ($suiteCandidates.Count -eq 0) { + continue + } + $hyperfineSuitePath = Join-Path $outputRootFull ("mhash-benchmark-{0}.hyperfine.{1}.{2}.json" -f $timestamp, $algorithm, $file.label) + Write-Host ("[hyperfine {0} {1}] commands={2}, runs={3}, warmup={4}" -f $file.label, $algorithm, $suiteCandidates.Count, $HyperfineRuns, $HyperfineWarmup) + $suite = Invoke-HyperfineSuite -HyperfinePath $tools.hyperfine -File $file -Algorithm $algorithm -Candidates $suiteCandidates -OutputPath $hyperfineSuitePath -Runs $HyperfineRuns -Warmup $HyperfineWarmup + if ($suite.exit_code -ne 0) { + Write-Warning "hyperfine $algorithm $($file.label) failed with exit code $($suite.exit_code)." + } + $hyperfineSuites.Add($suite) + } + } + } +} + +$hyperfineRows = @(foreach ($suite in $hyperfineSuites) { + foreach ($result in @($suite.results)) { + $times = @($result.times | ForEach-Object { [double]$_ }) + $min = if ($null -ne $result.min) { [double]$result.min } elseif ($times.Count -gt 0) { ($times | Measure-Object -Minimum).Minimum } else { 0.0 } + $max = if ($null -ne $result.max) { [double]$result.max } elseif ($times.Count -gt 0) { ($times | Measure-Object -Maximum).Maximum } else { 0.0 } + [pscustomobject]@{ + size_label = $suite.size_label + bytes = $suite.bytes + algorithm = $suite.algorithm + command = $result.command + runs = $times.Count + mean_ms = [Math]::Round(([double]$result.mean) * 1000.0, 3) + stddev_ms = [Math]::Round(([double]$result.stddev) * 1000.0, 3) + min_ms = [Math]::Round($min * 1000.0, 3) + max_ms = [Math]::Round($max * 1000.0, 3) + } + } + }) + +$rawPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.raw.json" +$recordsJsonlPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.records.jsonl" +$summaryPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.summary.json" +$summaryJsonlPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.summary.jsonl" +$csvPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.summary.csv" +$artifactPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.artifacts.json" +$artifactCsvPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.artifacts.csv" +$hyperfinePath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.hyperfine.json" +$hyperfineCsvPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.hyperfine.csv" +$markdownPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.md" + +$environment = [pscustomobject]@{ + mode = $mode + timestamp_utc = (Get-Date).ToUniversalTime().ToString('o') + invocation = $MyInvocation.Line + script_path = $PSCommandPath + workspace_root = [System.IO.Path]::GetFullPath($workspaceRoot) + machine_name = $env:COMPUTERNAME + os = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + os_architecture = [string][System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + process_architecture = [string][System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + processor_count = [Environment]::ProcessorCount + powershell_version = $PSVersionTable.PSVersion.ToString() + git = Get-GitMetadata -Root $workspaceRoot + repeat = $Repeat + warmup = $Warmup + sizes = $Sizes + algorithms = $Algorithms + mhash_formats = $DigestFormats + no_external = [bool]$NoExternal + extended = [bool]$Extended + size_only = [bool]$SizeOnly + hyperfine_requested = [bool]$Hyperfine + hyperfine_available = -not [string]::IsNullOrWhiteSpace($tools.hyperfine) + hyperfine_runs = $HyperfineRuns + hyperfine_warmup = $HyperfineWarmup + data_root = $dataRootFull + output_root = $outputRootFull + tools = $tools + tool_artifacts = $toolArtifacts + fixtures = $files +} + +$summary = @(ConvertTo-Summary -Records @($records)) +[pscustomobject]@{ + environment = $environment + records = $records + summary = $summary + tool_artifacts = $toolArtifacts + hyperfine_suites = $hyperfineSuites + hyperfine_summary = $hyperfineRows +} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $rawPath -Encoding utf8NoBOM + +Write-JsonLines -Path $recordsJsonlPath -InputObject @($records) -Depth 8 +ConvertTo-Json -InputObject $summary -Depth 7 | Set-Content -LiteralPath $summaryPath -Encoding utf8NoBOM +Write-JsonLines -Path $summaryJsonlPath -InputObject $summary -Depth 7 +if ($summary.Count -gt 0) { + $summary | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding utf8NoBOM +} +else { + [System.IO.File]::WriteAllText($csvPath, '', [System.Text.UTF8Encoding]::new($false)) +} +ConvertTo-Json -InputObject $toolArtifacts -Depth 7 | Set-Content -LiteralPath $artifactPath -Encoding utf8NoBOM +$toolArtifacts | Export-Csv -LiteralPath $artifactCsvPath -NoTypeInformation -Encoding utf8NoBOM +ConvertTo-Json -InputObject @($hyperfineSuites) -Depth 12 | Set-Content -LiteralPath $hyperfinePath -Encoding utf8NoBOM +if ($hyperfineRows.Count -gt 0) { + $hyperfineRows | Export-Csv -LiteralPath $hyperfineCsvPath -NoTypeInformation -Encoding utf8NoBOM +} +else { + [System.IO.File]::WriteAllText($hyperfineCsvPath, '', [System.Text.UTF8Encoding]::new($false)) +} +Write-MarkdownReport -Path $markdownPath -Summary $summary -Environment $environment -Artifacts $toolArtifacts -HyperfineRows $hyperfineRows + +Write-Host "Raw results: $rawPath" +Write-Host "Records JSONL: $recordsJsonlPath" +Write-Host "Summary JSON: $summaryPath" +Write-Host "Summary JSONL: $summaryJsonlPath" +Write-Host "Summary CSV: $csvPath" +Write-Host "Artifact JSON: $artifactPath" +Write-Host "Artifact CSV: $artifactCsvPath" +if ($Hyperfine) { + Write-Host "Hyperfine JSON: $hyperfinePath" + Write-Host "Hyperfine CSV: $hyperfineCsvPath" +} +Write-Host "Markdown report: $markdownPath" diff --git a/scripts/cargo-flamegraph-windows.ps1 b/scripts/cargo-flamegraph-windows.ps1 new file mode 100644 index 0000000..40c0b1f --- /dev/null +++ b/scripts/cargo-flamegraph-windows.ps1 @@ -0,0 +1,294 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Package, + [string]$Binary, + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$BuildProfile = 'Release', + [string]$Output, + [string]$LogPath, + [string]$WorkspaceRoot = (Split-Path -Parent $PSScriptRoot), + [switch]$NoElevate, + [switch]$Describe, + [string]$TargetArgumentJson, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$TargetArgument = @() +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Convert-ToSingleQuotedLiteral { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Value + ) + + return "'{0}'" -f $Value.Replace("'", "''") +} + +function Convert-ToEncodedCommand { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Command + ) + + return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) +} + +function Test-IsAdministrator { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() + $currentPrincipal = [Security.Principal.WindowsPrincipal]::new($currentIdentity) + return $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Write-RelayLog { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if ([string]::IsNullOrWhiteSpace($LogPath)) { + return + } + + $logDirectory = Split-Path -Parent $LogPath + if (-not [string]::IsNullOrWhiteSpace($logDirectory)) { + New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null + } + + $timestamp = Get-Date -Format 'O' + $line = "[$timestamp] $Message{0}" -f [Environment]::NewLine + $utf8NoBom = [Text.UTF8Encoding]::new($false) + [System.IO.File]::AppendAllText($LogPath, $line, $utf8NoBom) +} + +function Get-ElevationHostPath { + $currentProcess = Get-Process -Id $PID -ErrorAction SilentlyContinue + if ($null -ne $currentProcess -and -not [string]::IsNullOrWhiteSpace($currentProcess.Path)) { + return $currentProcess.Path + } + + $pwshCommand = Get-Command -Name 'pwsh.exe' -ErrorAction SilentlyContinue + if ($null -ne $pwshCommand) { + return $pwshCommand.Source + } + + return 'powershell.exe' +} + +function Get-TranscriptPath { + if ([string]::IsNullOrWhiteSpace($LogPath)) { + return $null + } + + return '{0}.transcript.txt' -f $LogPath +} + +function Get-DTracePath { + if (-not [string]::IsNullOrWhiteSpace($env:DTRACE)) { + if (Test-Path -LiteralPath $env:DTRACE) { + return (Resolve-Path -LiteralPath $env:DTRACE).Path + } + + $resolvedOverride = Get-Command -Name $env:DTRACE -ErrorAction SilentlyContinue + if ($null -ne $resolvedOverride) { + return $resolvedOverride.Source + } + } + + $dtraceCommand = Get-Command -Name 'dtrace' -ErrorAction SilentlyContinue + if ($null -eq $dtraceCommand) { + return $null + } + + return $dtraceCommand.Source +} + +function Get-ProfileArgumentList { + param( + [Parameter(Mandatory = $true)] + [string]$Profile + ) + + switch ($Profile) { + 'Debug' { return @('--dev') } + 'Release' { return @('--release') } + 'ReleaseFast' { return @('--profile', 'release-fast') } + 'ReleaseSize' { return @('--profile', 'release-size') } + default { throw "Unsupported build profile: $Profile" } + } +} + +function New-FlamegraphArgumentList { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedBinary, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$ResolvedTargetArgument + ) + + $arguments = @('flamegraph') + (Get-ProfileArgumentList -Profile $BuildProfile) + @( + '-p', + $Package, + '--bin', + $ResolvedBinary + ) + + if (-not [string]::IsNullOrWhiteSpace($Output)) { + $arguments += @('-o', $Output) + } + + if ($ResolvedTargetArgument.Count -gt 0) { + $arguments += '--' + $arguments += $ResolvedTargetArgument + } + + return $arguments +} + +function New-RelayCommand { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedBinary, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$ResolvedTargetArgument + ) + + $relayTokens = @( + '&', + (Convert-ToSingleQuotedLiteral -Value $PSCommandPath), + '-Package', + (Convert-ToSingleQuotedLiteral -Value $Package), + '-Binary', + (Convert-ToSingleQuotedLiteral -Value $ResolvedBinary), + '-BuildProfile', + (Convert-ToSingleQuotedLiteral -Value $BuildProfile), + '-WorkspaceRoot', + (Convert-ToSingleQuotedLiteral -Value $WorkspaceRoot), + '-NoElevate' + ) + + if (-not [string]::IsNullOrWhiteSpace($Output)) { + $relayTokens += @('-Output', (Convert-ToSingleQuotedLiteral -Value $Output)) + } + + if (-not [string]::IsNullOrWhiteSpace($LogPath)) { + $relayTokens += @('-LogPath', (Convert-ToSingleQuotedLiteral -Value $LogPath)) + } + + if ($Describe) { + $relayTokens += '-Describe' + } + + if ($ResolvedTargetArgument.Count -gt 0) { + $targetArgumentJson = $ResolvedTargetArgument | ConvertTo-Json -Compress + $relayTokens += @('-TargetArgumentJson', (Convert-ToSingleQuotedLiteral -Value $targetArgumentJson)) + } + + $commandParts = @( + "Set-Location -LiteralPath $(Convert-ToSingleQuotedLiteral -Value $WorkspaceRoot)", + ($relayTokens -join ' ') + ) + + return ($commandParts -join '; ') +} + +$resolvedWorkspaceRoot = [System.IO.Path]::GetFullPath($WorkspaceRoot) +$resolvedBinary = if ([string]::IsNullOrWhiteSpace($Binary)) { $Package } else { $Binary } +$dtracePath = Get-DTracePath +$transcriptPath = Get-TranscriptPath +$elevationHostPath = Get-ElevationHostPath +$requiresElevation = [string]::IsNullOrWhiteSpace($dtracePath) -and -not (Test-IsAdministrator) +$resolvedTargetArgument = @() +if (-not [string]::IsNullOrWhiteSpace($TargetArgumentJson)) { + if ($TargetArgument.Count -gt 0) { + throw 'Pass either -TargetArgumentJson or trailing -TargetArgument values, not both.' + } + + $decodedArgument = ConvertFrom-Json -InputObject $TargetArgumentJson + foreach ($argument in @($decodedArgument)) { + $resolvedTargetArgument += [string]$argument + } +} +else { + $resolvedTargetArgument = $TargetArgument +} + +$flamegraphArguments = New-FlamegraphArgumentList -ResolvedBinary $resolvedBinary -ResolvedTargetArgument $resolvedTargetArgument +$relayCommand = New-RelayCommand -ResolvedBinary $resolvedBinary -ResolvedTargetArgument $resolvedTargetArgument + +if ($Describe) { + [pscustomobject]@{ + workspace_root = $resolvedWorkspaceRoot + package = $Package + binary = $resolvedBinary + build_profile = $BuildProfile + output = $Output + log_path = $LogPath + transcript_path = $transcriptPath + dtrace_path = $dtracePath + elevation_host_path = $elevationHostPath + requires_elevation = $requiresElevation + target_arguments = $resolvedTargetArgument + relay_command = $relayCommand + cargo_arguments = $flamegraphArguments + } | ConvertTo-Json -Depth 4 + exit 0 +} + +if ($requiresElevation) { + if ($NoElevate) { + Write-RelayLog 'Refusing to continue without elevation because no DTrace executable was detected.' + Write-Error 'cargo flamegraph on Windows needs an elevated session here because no DTrace executable was detected and blondie would fail with NotAnAdmin.' + } + + $encodedRelayCommand = Convert-ToEncodedCommand -Command $relayCommand + Write-RelayLog "Requesting elevation with encoded relay command for package '$Package'." + try { + Start-Process -FilePath $elevationHostPath -Verb RunAs -WorkingDirectory $resolvedWorkspaceRoot -ArgumentList @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + $encodedRelayCommand + ) | Out-Null + Write-RelayLog 'Elevation request handed off to a new PowerShell window.' + Write-Host 'Elevation requested in a new PowerShell window. After UAC approval, cargo flamegraph will run there.' + } + catch { + Write-RelayLog "Elevation request failed: $($_.Exception.Message)" + throw + } + exit 0 +} + +Write-RelayLog "Running cargo flamegraph in elevated/session-ready mode from '$resolvedWorkspaceRoot'." +$transcriptStarted = $false +if (-not [string]::IsNullOrWhiteSpace($LogPath)) { + $transcriptPath = Get-TranscriptPath + $logDirectory = Split-Path -Parent $transcriptPath + if (-not [string]::IsNullOrWhiteSpace($logDirectory)) { + New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null + } + Start-Transcript -LiteralPath $transcriptPath -Append | Out-Null + $transcriptStarted = $true +} + +Push-Location $resolvedWorkspaceRoot +try { + Write-RelayLog "Invoking cargo $($flamegraphArguments -join ' ')" + cargo @flamegraphArguments + Write-RelayLog "cargo flamegraph finished with exit code $LASTEXITCODE" +} +finally { + Pop-Location + if ($transcriptStarted) { + Stop-Transcript | Out-Null + } +} diff --git a/scripts/check-ai-prompt.ps1 b/scripts/check-ai-prompt.ps1 new file mode 100644 index 0000000..c0d34ab --- /dev/null +++ b/scripts/check-ai-prompt.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$generatorScriptName = 'generate-ai-prompt.ps1' +$assetLabel = 'AI prompt' + +function Invoke-GeneratorCheck { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [hashtable]$InvokeArgs + ) + + try { + & $ScriptPath @InvokeArgs + if ($LASTEXITCODE -ne 0) { + throw "$generatorScriptName exited with $LASTEXITCODE" + } + return $true + } + catch { + Write-Host "$assetLabel check found drift: $($_.Exception.Message)" + return $false + } +} + +function Invoke-GeneratorWrite { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [hashtable]$InvokeArgs + ) + + $writeArgs = @{} + foreach ($key in $InvokeArgs.Keys) { + if ($key -ne 'Check') { + $writeArgs[$key] = $InvokeArgs[$key] + } + } + + & $ScriptPath @writeArgs + if ($LASTEXITCODE -ne 0) { + throw "$generatorScriptName exited with $LASTEXITCODE while regenerating $assetLabel" + } +} + +$scriptPath = Join-Path $PSScriptRoot $generatorScriptName +$invokeArgs = @{ + Configuration = $Configuration + Check = $true +} +if ($SkipBuild) { + $invokeArgs.SkipBuild = $true +} + +if (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs) { + return +} + +Write-Host "Regenerating $assetLabel before rechecking." +Invoke-GeneratorWrite -ScriptPath $scriptPath -InvokeArgs $invokeArgs + +if (-not (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs)) { + throw "Generated $assetLabel is still out of date after regeneration; manual intervention is required." +} diff --git a/scripts/check-ai-skill.ps1 b/scripts/check-ai-skill.ps1 new file mode 100644 index 0000000..8410647 --- /dev/null +++ b/scripts/check-ai-skill.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$generatorScriptName = 'generate-ai-skill.ps1' +$assetLabel = 'Mercury Toolbox skill' + +function Invoke-GeneratorCheck { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [hashtable]$InvokeArgs + ) + + try { + & $ScriptPath @InvokeArgs + if ($LASTEXITCODE -ne 0) { + throw "$generatorScriptName exited with $LASTEXITCODE" + } + return $true + } + catch { + Write-Host "$assetLabel check found drift: $($_.Exception.Message)" + return $false + } +} + +function Invoke-GeneratorWrite { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [hashtable]$InvokeArgs + ) + + $writeArgs = @{} + foreach ($key in $InvokeArgs.Keys) { + if ($key -ne 'Check') { + $writeArgs[$key] = $InvokeArgs[$key] + } + } + + & $ScriptPath @writeArgs + if ($LASTEXITCODE -ne 0) { + throw "$generatorScriptName exited with $LASTEXITCODE while regenerating $assetLabel" + } +} + +$scriptPath = Join-Path $PSScriptRoot $generatorScriptName +$invokeArgs = @{ + Configuration = $Configuration + Check = $true +} +if ($SkipBuild) { + $invokeArgs.SkipBuild = $true +} + +if (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs) { + return +} + +Write-Host "Regenerating $assetLabel before rechecking." +Invoke-GeneratorWrite -ScriptPath $scriptPath -InvokeArgs $invokeArgs + +if (-not (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs)) { + throw "Generated $assetLabel is still out of date after regeneration; manual intervention is required." +} diff --git a/scripts/check-conventional-commits.ps1 b/scripts/check-conventional-commits.ps1 new file mode 100644 index 0000000..9ec5d0b --- /dev/null +++ b/scripts/check-conventional-commits.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding()] +param( + [string]$Range = 'HEAD^..HEAD' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$pattern = '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9][a-z0-9._/-]*\))?!?: .+$' +$lines = @(& git log --no-merges '--format=%H%x09%s' $Range) +if ($LASTEXITCODE -ne 0) { + throw "Could not read commits for range '$Range'." +} + +foreach ($line in $lines) { + $fields = ([string]$line) -split "`t", 2 + if ($fields.Count -ne 2 -or -not [regex]::IsMatch($fields[1], $pattern)) { + throw "Commit must use Conventional Commits: $line" + } +} + +Write-Host "Conventional Commit check passed for $($lines.Count) commit(s)." diff --git a/scripts/check-ecosystem.ps1 b/scripts/check-ecosystem.ps1 new file mode 100644 index 0000000..ce5b020 --- /dev/null +++ b/scripts/check-ecosystem.ps1 @@ -0,0 +1,1669 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [switch]$SkipBuild, + [switch]$SkipPromptGeneration +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-RequiredCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found on PATH: $Name" + } + + return $command.Source +} + +function Assert-Condition { + param( + [Parameter(Mandatory = $true)] + [bool]$Condition, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Get-ToolboxBinaryPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $path = Join-Path $script:BinaryRoot "$Name.exe" + if (-not (Test-Path -LiteralPath $path)) { + throw "Expected toolbox binary not found: $path" + } + + return $path +} + +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } + finally { + $listener.Stop() + } +} + +function Start-LoopbackHttpServer { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $readyFile = Join-Path ([System.IO.Path]::GetTempPath()) ("mercury-loopback-ready-" + [System.Guid]::NewGuid()) + $job = Start-Job -ScriptBlock { + param($JobPort, $JobReadyFile) + + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$JobPort/") + $listener.Start() + New-Item -ItemType File -Force -Path $JobReadyFile | Out-Null + + try { + $context = $listener.GetContext() + $payload = [System.Text.Encoding]::UTF8.GetBytes('{"ok":true}') + $response = $context.Response + $response.StatusCode = 200 + $response.ContentType = 'application/json' + $response.ContentLength64 = $payload.Length + $response.OutputStream.Write($payload, 0, $payload.Length) + $response.OutputStream.Close() + } + finally { + Remove-Item -LiteralPath $JobReadyFile -Force -ErrorAction SilentlyContinue + $listener.Stop() + $listener.Close() + } + } -ArgumentList $Port, $readyFile + + Wait-Until -Description "loopback HTTP server on port $Port" -TimeoutMilliseconds 3000 -Probe { + if (Test-Path -LiteralPath $readyFile) { + return $true + } + if ($job.State -in @('Completed', 'Failed', 'Stopped')) { + return $true + } + return $false + } + if (-not (Test-Path -LiteralPath $readyFile)) { + Stop-JobSafe -Job $job + throw "loopback HTTP server on port $Port exited before becoming ready" + } + + return [pscustomobject]@{ + Job = $job + ReadyFile = $readyFile + } +} + +function Stop-JobSafe { + param( + [Parameter(Mandatory = $true)] + [System.Management.Automation.Job]$Job + ) + + if ($Job.State -eq 'Running') { + Stop-Job -Job $Job | Out-Null + } + Receive-Job -Job $Job -ErrorAction SilentlyContinue | Out-Null + Remove-Job -Job $Job -Force -ErrorAction SilentlyContinue | Out-Null +} + +function Stop-LoopbackHttpServer { + param( + [Parameter(Mandatory = $true)] + [psobject]$Server + ) + + if ($null -ne $Server.Job) { + Stop-JobSafe -Job $Server.Job + } + if ($Server.PSObject.Properties.Name -contains 'ReadyFile') { + Remove-Item -LiteralPath $Server.ReadyFile -Force -ErrorAction SilentlyContinue + } +} + +function Wait-Until { + param( + [Parameter(Mandatory = $true)] + [string]$Description, + [Parameter(Mandatory = $true)] + [scriptblock]$Probe, + [int]$TimeoutMilliseconds = 3000, + [int]$IntervalMilliseconds = 50 + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.ElapsedMilliseconds -lt $TimeoutMilliseconds) { + if (& $Probe) { + return + } + Start-Sleep -Milliseconds $IntervalMilliseconds + } + + throw "timed out waiting for $Description" +} + +function Test-TcpPortListening { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $client = [System.Net.Sockets.TcpClient]::new() + try { + $connect = $client.ConnectAsync([System.Net.IPAddress]::Loopback, $Port) + if (-not $connect.Wait(150)) { + return $false + } + return $client.Connected + } + catch { + return $false + } + finally { + $client.Dispose() + } +} + +function Test-ExclusiveFileLock { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $stream = $null + try { + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + return $false + } + catch [System.IO.IOException] { + return $true + } + finally { + if ($null -ne $stream) { + $stream.Dispose() + } + } +} + +function Invoke-Check { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [scriptblock]$Script + ) + + try { + $detail = & $Script + $script:Results.Add([pscustomobject]@{ + Check = $Name + Status = 'PASS' + Detail = [string]$detail + }) + } + catch { + $script:Results.Add([pscustomobject]@{ + Check = $Name + Status = 'FAIL' + Detail = $_.Exception.Message + }) + $script:Failed = $true + } +} + +function Invoke-PwshFile { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$ArgumentList = @() + ) + + $output = & pwsh -NoProfile -ExecutionPolicy Bypass -File $FilePath @ArgumentList 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = (($output | ForEach-Object { [string]$_ }) -join "`n") + } +} + +function Invoke-NativeCapture { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$ArgumentList = @(), + [int]$TimeoutMilliseconds = 5000 + ) + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $FilePath + foreach ($argument in $ArgumentList) { + [void]$psi.ArgumentList.Add($argument) + } + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $psi + [void]$process.Start() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + try { + $process.Kill($true) + } + catch { + $process.Kill() + } + throw "timed out after ${TimeoutMilliseconds}ms: $FilePath $($ArgumentList -join ' ')" + } + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $exitCode = $process.ExitCode + $process.Dispose() + + return [pscustomobject]@{ + ExitCode = $exitCode + Stdout = $stdout + Stderr = $stderr + Output = ($stdout + $stderr) + } +} + +function Test-ToolboxBinaryVersions { + foreach ($commandName in Get-ToolboxCommandNames) { + $binary = Get-ToolboxBinaryPath -Name $commandName + $result = Invoke-NativeCapture -FilePath $binary -ArgumentList @('--version') + Assert-Condition ($result.ExitCode -eq 0) "$commandName --version failed with exit code $($result.ExitCode): $($result.Output)" + Assert-Condition ($result.Output -match [regex]::Escape($commandName)) "$commandName --version did not include command name: $($result.Output)" + } +} + +function Test-ToolboxNoArgsContracts { + $stdinWaitingCommands = @( + 'ison', + 'isonl', + 'zon', + 'tonl', + 'jsonlgrep', + 'ctxpack', + 'diagpick', + 'hitsnip', + 'logshape', + 'recent', + 'codeshape', + 'snip', + 'sysshape', + 'toon' + ) + foreach ($commandName in Get-ToolboxCommandNames) { + if ($commandName -in $stdinWaitingCommands) { + continue + } + $binary = Get-ToolboxBinaryPath -Name $commandName + $result = Invoke-NativeCapture -FilePath $binary + Assert-Condition ($result.Output.Length -lt 20000) "$commandName no-args output was unexpectedly large" + Assert-Condition ($result.Output -notmatch '(?i)panic|backtrace') "$commandName no-args output contained panic/backtrace text" + } +} + +function Test-ToolboxInvalidFlagContracts { + foreach ($commandName in Get-ToolboxCommandNames) { + $binary = Get-ToolboxBinaryPath -Name $commandName + $result = Invoke-NativeCapture -FilePath $binary -ArgumentList @('--mercury-invalid-flag') + Assert-Condition ($result.ExitCode -ne 0) "$commandName accepted an invalid flag" + Assert-Condition ($result.Output.Length -gt 0) "$commandName invalid-flag diagnostic was empty" + Assert-Condition ($result.Output -notmatch '(?i)panic|backtrace') "$commandName invalid-flag output contained panic/backtrace text" + } +} + +function Test-ToolboxStructuredOutputHelpContracts { + foreach ($commandName in Get-ToolboxCommandNames) { + $binary = Get-ToolboxBinaryPath -Name $commandName + $result = Invoke-NativeCapture -FilePath $binary -ArgumentList @('--help') + Assert-Condition ($result.ExitCode -eq 0) "$commandName --help failed with exit code $($result.ExitCode)" + Assert-Condition ($result.Output -match '--json|--format|--toon|JSON|TOON') "$commandName --help does not mention structured output controls" + } +} + +function Test-ToolboxMalformedJsonlStdinContracts { + foreach ($probe in @( + @{ Name = 'cjson'; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'jsonshape'; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'toon'; Args = @('--input-format', 'jsonl') } + )) { + $binary = Get-ToolboxBinaryPath -Name $probe.Name + $result = "'{not-json}'" | & $binary @($probe.Args) 2>&1 + $exitCode = $LASTEXITCODE + $text = (($result | ForEach-Object { [string]$_ }) -join "`n") + Assert-Condition ($exitCode -ne 0) "$($probe.Name) accepted malformed JSONL stdin" + Assert-Condition ($text.Length -gt 0) "$($probe.Name) malformed JSONL diagnostic was empty" + Assert-Condition ($text -notmatch '(?i)panic|backtrace') "$($probe.Name) malformed JSONL diagnostic contained panic/backtrace text" + } +} + +function Write-TestPackageSha256Sums { + param( + [Parameter(Mandatory = $true)] + [string]$PackageRoot, + [switch]$UseWindowsSeparators + ) + + $hashPath = Join-Path $PackageRoot 'SHA256SUMS.txt' + $lines = [System.Collections.Generic.List[string]]::new() + foreach ($file in (Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | Sort-Object FullName)) { + if ($file.FullName -eq $hashPath) { + continue + } + + $relativePath = [System.IO.Path]::GetRelativePath($PackageRoot, $file.FullName) + if (-not $UseWindowsSeparators) { + $relativePath = $relativePath.Replace('\', '/') + } + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $lines.Add("$hash $relativePath") + } + + Set-Content -LiteralPath $hashPath -Value $lines -Encoding utf8NoBOM +} + +function New-TestPortablePackage { + param( + [Parameter(Mandatory = $true)] + [string]$PackageRoot, + [switch]$UseWindowsSeparators + ) + + $scriptsRoot = Join-Path $PackageRoot 'scripts' + $binRoot = Join-Path $PackageRoot 'bin' + $docsRoot = Join-Path $PackageRoot 'docs\ai' + $skillRoot = Join-Path $PackageRoot 'skills\mercury-toolbox' + $catalogRoot = Join-Path $skillRoot 'references' + New-Item -ItemType Directory -Force -Path $scriptsRoot, $binRoot, $docsRoot, $catalogRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install-package-toolbox.ps1') -Destination (Join-Path $scriptsRoot 'install-package-toolbox.ps1') -Force + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'toolbox-commands.ps1') -Destination (Join-Path $scriptsRoot 'toolbox-commands.ps1') -Force + + foreach ($commandName in Get-ToolboxCommandNames) { + Set-Content -LiteralPath (Join-Path $binRoot "$commandName.exe") -Value "fake $commandName" -Encoding utf8NoBOM + } + Set-Content -LiteralPath (Join-Path $docsRoot 'mercury-toolbox-ai-prompt.md') -Value 'test prompt' -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $skillRoot 'SKILL.md') -Value 'test skill' -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $catalogRoot 'command-catalog.md') -Value 'test catalog' -Encoding utf8NoBOM + [ordered]@{ + name = 'Mercury Toolbox' + commands = @(Get-ToolboxCommandNames) + } | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $PackageRoot 'mercury-toolbox-package.json') -Encoding utf8NoBOM + + Write-TestPackageSha256Sums -PackageRoot $PackageRoot -UseWindowsSeparators:$UseWindowsSeparators +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$targetProfile = Resolve-ToolboxProfileName -Configuration $Configuration +$script:BinaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $targetProfile) +$dependencyRoot = Join-Path $script:BinaryRoot 'deps' +if (Test-Path -LiteralPath $dependencyRoot -PathType Container) { + $env:PATH = "$dependencyRoot$([System.IO.Path]::PathSeparator)$env:PATH" +} +$fixtureRoot = Join-Path $workspaceRoot 'fixtures' +$cargoPath = Get-RequiredCommandPath -Name 'cargo' +$batPath = Get-RequiredCommandPath -Name 'bat' +$fdPath = Get-RequiredCommandPath -Name 'fd' +$rgPath = Get-RequiredCommandPath -Name 'rg' +$jqPath = Get-RequiredCommandPath -Name 'jq' +$script:Results = [System.Collections.Generic.List[object]]::new() +$script:Failed = $false + +Write-Host "Mercury Toolbox ecosystem check" +Write-Host "workspace: $workspaceRoot" +Write-Host "configuration: $Configuration" +Write-Host "binary root: $script:BinaryRoot" + +if (-not $SkipBuild) { + Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration +} + +$jsonlgrep = Get-ToolboxBinaryPath -Name 'jsonlgrep' +$jsonshape = Get-ToolboxBinaryPath -Name 'jsonshape' +$cjson = Get-ToolboxBinaryPath -Name 'cjson' +$recent = Get-ToolboxBinaryPath -Name 'recent' +$pathshadow = Get-ToolboxBinaryPath -Name 'pathshadow' +$portping = Get-ToolboxBinaryPath -Name 'portping' +$portunlock = Get-ToolboxBinaryPath -Name 'portunlock' +$msudo = Get-ToolboxBinaryPath -Name 'msudo' +$asmtype = Get-ToolboxBinaryPath -Name 'asmtype' +$asmmember = Get-ToolboxBinaryPath -Name 'asmmember' +$asmref = Get-ToolboxBinaryPath -Name 'asmref' +$binmeta = Get-ToolboxBinaryPath -Name 'binmeta' +$fileprobe = Get-ToolboxBinaryPath -Name 'fileprobe' +$outline = Get-ToolboxBinaryPath -Name 'outline' +$codeshape = Get-ToolboxBinaryPath -Name 'codeshape' +$snip = Get-ToolboxBinaryPath -Name 'snip' +$defsnip = Get-ToolboxBinaryPath -Name 'defsnip' +$chunkcat = Get-ToolboxBinaryPath -Name 'chunkcat' +$hitsnip = Get-ToolboxBinaryPath -Name 'hitsnip' +$diagpick = Get-ToolboxBinaryPath -Name 'diagpick' +$logshape = Get-ToolboxBinaryPath -Name 'logshape' +$stringscan = Get-ToolboxBinaryPath -Name 'stringscan' +$toon = Get-ToolboxBinaryPath -Name 'toon' +$csvshape = Get-ToolboxBinaryPath -Name 'csvshape' +$sqliteshape = Get-ToolboxBinaryPath -Name 'sqliteshape' +$sqlshape = Get-ToolboxBinaryPath -Name 'sqlshape' +$envdiff = Get-ToolboxBinaryPath -Name 'envdiff' +$proctree = Get-ToolboxBinaryPath -Name 'proctree' +$sysshape = Get-ToolboxBinaryPath -Name 'sysshape' +$unlock = Get-ToolboxBinaryPath -Name 'unlock' + +Invoke-Check -Name 'toolbox:all-binaries-report-version' -Script { + Test-ToolboxBinaryVersions + "verified --version output for $((Get-ToolboxCommandNames).Count) toolbox binaries" +} + +Invoke-Check -Name 'toolbox:all-binaries-no-args-contract' -Script { + Test-ToolboxNoArgsContracts + "verified bounded no-args behavior for non-stdin-waiting toolbox binaries" +} + +Invoke-Check -Name 'toolbox:all-binaries-invalid-flag-contract' -Script { + Test-ToolboxInvalidFlagContracts + "verified invalid-flag diagnostics for $((Get-ToolboxCommandNames).Count) toolbox binaries" +} + +Invoke-Check -Name 'toolbox:all-binaries-structured-output-help' -Script { + Test-ToolboxStructuredOutputHelpContracts + "verified structured-output help surface for $((Get-ToolboxCommandNames).Count) toolbox binaries" +} + +Invoke-Check -Name 'toolbox:malformed-jsonl-stdin-contract' -Script { + Test-ToolboxMalformedJsonlStdinContracts + 'verified malformed JSONL stdin fails closed for representative input-format commands' +} + +Invoke-Check -Name 'toolbox:functional-toon-smokes' -Script { + $outputs = @( + (& $fileprobe (Join-Path $fixtureRoot 'reading\sample.rs') --toon), + (& $jsonshape (Join-Path $fixtureRoot 'jsonshape\events.jsonl') --input-format jsonl --toon), + (& $csvshape (Join-Path $fixtureRoot 'csvshape\sample.csv') --toon) + ) + foreach ($output in $outputs) { + $text = (($output | ForEach-Object { [string]$_ }) -join "`n") + Assert-Condition ($text.Trim().Length -gt 0) 'expected non-empty representative TOON smoke output' + Assert-Condition ($text -notmatch '(?i)panic|backtrace') "TOON smoke output contained panic/backtrace text: $text" + } + + 'verified representative functional TOON smokes across file, JSONL, and CSV families' +} + +Invoke-Check -Name 'cjson:stdin-jsonl-wrapper' -Script { + $wrapper = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'cjson\records.jsonl') | + & $cjson --input-format jsonl --sort-keys --json | + ConvertFrom-Json + + Assert-Condition ($wrapper.format -eq 'jsonl') "expected cjson wrapper format jsonl, got $($wrapper.format)" + Assert-Condition ($wrapper.documents -eq 2) "expected 2 compacted documents, got $($wrapper.documents)" + Assert-Condition ($wrapper.text -match '"event":"login","ok":true') 'expected compact wrapper text to contain a sorted login record' + "wrapped $($wrapper.documents) compacted JSONL document(s) for PowerShell consumers" +} + +Invoke-Check -Name 'jsonlgrep:stdin-json' -Script { + $matchRows = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'jsonl\events.jsonl') | + & $jsonlgrep 'level=error' --pick ts,msg --json | + ConvertFrom-Json + + Assert-Condition (@($matchRows).Count -eq 2) "expected 2 error rows, got $(@($matchRows).Count)" + Assert-Condition ((@($matchRows)[0].msg) -eq 'failed login') 'expected first error message to be "failed login"' + "matched $(@($matchRows).Count) error rows through stdin + ConvertFrom-Json" +} + +Invoke-Check -Name 'jsonshape:stdin-jq' -Script { + $summaryJson = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'jsonshape\events.jsonl') | + & $jsonshape --input-format jsonl --json + + $documents = [int]($summaryJson | & $jqPath '-r' '.documents') + $pathCount = [int]($summaryJson | & $jqPath '-r' '.paths | length') + + Assert-Condition ($documents -eq 2) "expected 2 JSON documents, got $documents" + Assert-Condition ($pathCount -ge 3) "expected at least 3 summarized paths, got $pathCount" + "summarized $documents documents and $pathCount paths through jq" +} + +Invoke-Check -Name 'recent:json-pipeline' -Script { + $recentJson = & $recent --root $fixtureRoot --ext rs --limit 3 --json + $firstPath = $recentJson | & $jqPath '-r' '.[0].path' + $count = [int]($recentJson | & $jqPath '-r' 'length') + + Assert-Condition ($count -ge 1) "expected recent to return at least one Rust file, got $count" + Assert-Condition ($firstPath.EndsWith('.rs')) "expected first recent path to end with .rs, got $firstPath" + "returned $count recent Rust paths and projected the first path with jq" +} + +Invoke-Check -Name 'pathshadow:piped-command' -Script { + $pathMatches = 'cargo' | & $pathshadow --json | ConvertFrom-Json + + Assert-Condition (@($pathMatches).Count -ge 1) 'expected at least one cargo path match' + Assert-Condition ((@($pathMatches)[0].command) -eq 'cargo') "expected first pathshadow command to be cargo" + "resolved $(@($pathMatches).Count) PATH candidates for cargo from piped input" +} + +Invoke-Check -Name 'pathshadow:summary-shell-json' -Script { + $summary = & $pathshadow cargo --shell powershell --summary --json | ConvertFrom-Json + $first = @($summary)[0] + + Assert-Condition (@($summary).Count -ge 1) 'expected at least one pathshadow summary row' + Assert-Condition ($first.command -eq 'cargo') "expected cargo summary row, got $($first.command)" + Assert-Condition ($first.total_matches -ge 1) "expected at least one cargo match, got $($first.total_matches)" + Assert-Condition ($first.shell_mode -eq 'powershell') "expected powershell shell mode, got $($first.shell_mode)" + "summarized cargo resolution with shell-aware metadata" +} + +Invoke-Check -Name 'portping:loopback-http' -Script { + $port = Get-FreeTcpPort + $server = Start-LoopbackHttpServer -Port $port + + try { + $target = "http://127.0.0.1:$port/health" + $results = $target | & $portping --method GET --expect-status 200 --json | ConvertFrom-Json + $probe = @($results)[0] + + Assert-Condition ($probe.ok) 'expected loopback HTTP probe to succeed' + Assert-Condition ($probe.status_code -eq 200) "expected HTTP 200, got $($probe.status_code)" + "probed $target successfully in $($probe.total_ms) ms from piped target input" + } + finally { + Stop-LoopbackHttpServer -Server $server + } +} + +Invoke-Check -Name 'portunlock:who-and-free' -Script { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("portunlock-check-" + [System.Guid]::NewGuid()) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + $listener = $null + try { + $port = Get-FreeTcpPort + $listenerScript = Join-Path $tempDir 'listen.ps1' + Set-Content -LiteralPath $listenerScript -Encoding utf8NoBOM -Value @' +param([int]$Port) +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) +$listener.Start() +try { + Start-Sleep -Seconds 30 +} +finally { + $listener.Stop() +} +'@ + + $listener = Start-Process -FilePath pwsh -ArgumentList @('-NoProfile', '-File', $listenerScript, $port) -PassThru -WindowStyle Hidden + Wait-Until -Description "TCP listener on port $port" -TimeoutMilliseconds 3000 -Probe { + Test-TcpPortListening -Port $port + } + + $who = & $portunlock who --json --protocol tcp $port | ConvertFrom-Json + $free = & $portunlock free --json --force --protocol tcp $port | ConvertFrom-Json + $whoResult = @($who)[0] + $freeResult = @($free)[0] + + Assert-Condition ($whoResult.port -eq $port) "expected who result for port $port, got $($whoResult.port)" + Assert-Condition (@($whoResult.initial_owners).Count -ge 1) 'expected portunlock who to surface at least one owner' + Assert-Condition ($freeResult.ok) 'expected portunlock free --force to succeed' + Assert-Condition (@($freeResult.final_owners).Count -eq 0) 'expected portunlock free to leave no remaining owners' + "identified and freed a loopback TCP owner on port $port" + } + finally { + if ($null -ne $listener -and -not $listener.HasExited) { + Stop-Process -Id $listener.Id -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'msudo:status-json-stable-fields' -Script { + $status = & $msudo status --json | ConvertFrom-Json + $propertyNames = @($status.PSObject.Properties.Name) + $stableFields = @('ok', 'host', 'supports_runas', 'is_elevated', 'session_id', 'active_session_id', 'can_current_user') + $shellPresets = @($status.shells | ForEach-Object { [string]$_.preset }) + + Assert-Condition ($propertyNames.Count -ge 4) "expected msudo status to expose multiple stable fields, got $($propertyNames.Count)" + foreach ($stableField in $stableFields) { + Assert-Condition ($propertyNames -contains $stableField) "expected msudo status JSON to expose stable field $stableField" + } + Assert-Condition ($status.ok -is [bool]) "expected msudo status ok to be boolean, got $($status.ok.GetType().FullName)" + Assert-Condition ($status.is_elevated -is [bool]) "expected msudo status is_elevated to be boolean, got $($status.is_elevated.GetType().FullName)" + Assert-Condition ($status.supports_runas -is [bool]) "expected msudo status supports_runas to be boolean, got $($status.supports_runas.GetType().FullName)" + Assert-Condition ($status.can_current_user -is [bool]) "expected msudo status can_current_user to be boolean, got $($status.can_current_user.GetType().FullName)" + Assert-Condition (-not [string]::IsNullOrWhiteSpace([string]$status.host)) 'expected msudo status host to be a non-empty string' + Assert-Condition ($shellPresets -contains 'powershell') "expected msudo status to expose the CLI shell preset name 'powershell', got: $($shellPresets -join ', ')" + Assert-Condition ($shellPresets -contains 'git-bash') "expected msudo status to expose the CLI shell preset name 'git-bash', got: $($shellPresets -join ', ')" + "reported stable msudo discovery fields ok=$($status.ok) host=$($status.host) supports_runas=$($status.supports_runas) is_elevated=$($status.is_elevated) session=$($status.session_id) active_session=$($status.active_session_id) without launching a privileged child process" +} + +Invoke-Check -Name 'msudo:help-surfaces-same-console-honestly' -Script { + $helpText = (& $msudo --help 2>&1 | Out-String) + Assert-Condition ($LASTEXITCODE -eq 0) 'expected msudo --help to succeed' + Assert-Condition ( + $helpText -match '--same-console' + ) "expected msudo help to expose the same-console flag, got: $helpText" + Assert-Condition ( + $helpText -match 'Reuse the current console' + ) "expected msudo help to describe same-console foreground behavior, got: $helpText" + 'reported msudo help with the explicit same-console surface' +} + +Invoke-Check -Name 'msudo:current-user-native-cli' -Script { + $tempDir = Join-Path $env:TEMP ("msudo-current-user-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + try { + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + $currentProcessPath = Join-Path $tempDir 'current-process.json' + $currentUserPath = Join-Path $tempDir 'current-user.json' + + function New-EncodedPayload([string]$outputPath, [bool]$includeGroups) { + $escapedPath = $outputPath.Replace("'", "''") + $groupsExpr = if ($includeGroups) { "(whoami /groups | Out-String)" } else { '$null' } + $script = @' +$result = @{ + user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + session = (Get-Process -Id $PID).SessionId + groups = __GROUPS__ +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText('__OUTPUT_PATH__', $result) +'@ + $script = $script.Replace('__GROUPS__', $groupsExpr).Replace('__OUTPUT_PATH__', $escapedPath) + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) + } + + & $msudo --user current-process --wait -- powershell.exe -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $currentProcessPath -includeGroups:$false) + Assert-Condition ($LASTEXITCODE -eq 0) "expected current-process launch to succeed, got exit code $LASTEXITCODE" + + & $msudo --user current-user --wait -- powershell.exe -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $currentUserPath -includeGroups:$false) + Assert-Condition ($LASTEXITCODE -eq 0) "expected current-user launch to succeed, got exit code $LASTEXITCODE" + + $currentProcess = Get-Content $currentProcessPath -Raw | ConvertFrom-Json + $currentUserResult = Get-Content $currentUserPath -Raw | ConvertFrom-Json + + Assert-Condition ($currentProcess.user -eq $currentUser) "expected current-process user $currentUser, got $($currentProcess.user)" + Assert-Condition ($currentUserResult.user -eq $currentUser) "expected current-user user $currentUser, got $($currentUserResult.user)" + + "verified native current-process/current-user identities" + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'msudo:admin-system-ti-current-session' -Script { + $tempDir = Join-Path $env:TEMP ("msudo-ecosystem-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + try { + $currentSession = (Get-Process -Id $PID).SessionId + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + + $adminPath = Join-Path $tempDir 'admin.json' + $systemPath = Join-Path $tempDir 'system.json' + $tiPath = Join-Path $tempDir 'ti.json' + + function New-EncodedPayload([string]$outputPath, [bool]$includeGroups) { + $escapedPath = $outputPath.Replace("'", "''") + $groupsExpr = if ($includeGroups) { "(whoami /groups | Out-String)" } else { '$null' } + $script = @' +$result = @{ + user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + session = (Get-Process -Id $PID).SessionId + groups = __GROUPS__ +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText('__OUTPUT_PATH__', $result) +'@ + $script = $script.Replace('__GROUPS__', $groupsExpr).Replace('__OUTPUT_PATH__', $escapedPath) + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) + } + + & $msudo --user admin --wait -- powershell -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $adminPath -includeGroups:$false) + Assert-Condition ($LASTEXITCODE -eq 0) "expected admin msudo one-shot to succeed, got exit code $LASTEXITCODE" + + & $msudo --user system --dangerous --wait -- powershell -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $systemPath -includeGroups:$true) + Assert-Condition ($LASTEXITCODE -eq 0) "expected system msudo one-shot to succeed, got exit code $LASTEXITCODE" + + & $msudo --user trustedinstaller --dangerous --wait -- powershell -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $tiPath -includeGroups:$true) + Assert-Condition ($LASTEXITCODE -eq 0) "expected trustedinstaller msudo one-shot to succeed, got exit code $LASTEXITCODE" + + $admin = Get-Content $adminPath -Raw | ConvertFrom-Json + $system = Get-Content $systemPath -Raw | ConvertFrom-Json + $ti = Get-Content $tiPath -Raw | ConvertFrom-Json + + Assert-Condition ($admin.session -eq $currentSession) "expected admin session $currentSession, got $($admin.session)" + Assert-Condition ($system.session -eq $currentSession) "expected system session $currentSession, got $($system.session)" + Assert-Condition ($ti.session -eq $currentSession) "expected trustedinstaller session $currentSession, got $($ti.session)" + Assert-Condition ($admin.user -eq $currentUser) "expected admin user $currentUser, got $($admin.user)" + Assert-Condition ($system.user -eq 'NT AUTHORITY\SYSTEM') "expected system user NT AUTHORITY\\SYSTEM, got $($system.user)" + Assert-Condition ($ti.groups -match 'NT SERVICE\\TrustedInstaller') "expected trustedinstaller token groups to include TrustedInstaller SID, got: $($ti.groups)" + + "verified admin/system/trustedinstaller one-shot launches in session $currentSession with SYSTEM user and TrustedInstaller service SID evidence" + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'msudo:admin-integrity-and-privileges' -Script { + $tempDir = Join-Path $env:TEMP ("msudo-shape-" + [guid]::NewGuid().ToString('N')) + try { + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + $disableAllPath = Join-Path $tempDir 'disable-all.json' + $enableAllPath = Join-Path $tempDir 'enable-all.json' + $escapedDisableAllPath = $disableAllPath.Replace("'", "''") + $disableAllPayload = @' +$result = @{ + groups = (whoami /groups | Out-String) + privileges = (whoami /priv | Out-String) +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText('__OUTPUT_PATH__', $result) +'@ + $disableAllPayload = $disableAllPayload.Replace('__OUTPUT_PATH__', $escapedDisableAllPath) + $disableAllEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($disableAllPayload)) + + & $msudo --user admin --wait --integrity medium --privileges disable-all -- powershell.exe -NoProfile -EncodedCommand $disableAllEncoded + Assert-Condition ($LASTEXITCODE -eq 0) "expected admin integrity/privilege shaping launch to succeed, got exit code $LASTEXITCODE" + Assert-Condition (Test-Path -LiteralPath $disableAllPath) "expected admin integrity/privilege shaping launch to write $disableAllPath" + + $disableAll = Get-Content $disableAllPath -Raw | ConvertFrom-Json + Assert-Condition ($disableAll.groups -match 'S-1-16-8192') "expected shaped admin token to include medium integrity SID S-1-16-8192, got: $($disableAll.groups)" + Assert-Condition ($disableAll.privileges -notmatch '(?m)(Enabled|已启用)\s*$') "expected shaped admin token privileges to be fully disabled, got: $($disableAll.privileges)" + + $escapedEnableAllPath = $enableAllPath.Replace("'", "''") + $enableAllPayload = @' +$result = @{ + privileges = (whoami /priv | Out-String) +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText('__OUTPUT_PATH__', $result) +'@ + $enableAllPayload = $enableAllPayload.Replace('__OUTPUT_PATH__', $escapedEnableAllPath) + $enableAllEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($enableAllPayload)) + + & $msudo --user admin --wait --privileges enable-all -- powershell.exe -NoProfile -EncodedCommand $enableAllEncoded + Assert-Condition ($LASTEXITCODE -eq 0) "expected admin enable-all privilege launch to succeed, got exit code $LASTEXITCODE" + Assert-Condition (Test-Path -LiteralPath $enableAllPath) "expected admin enable-all privilege launch to write $enableAllPath" + + $enableAll = Get-Content $enableAllPath -Raw | ConvertFrom-Json + Assert-Condition ($enableAll.privileges -match '(?m)(Enabled|已启用)\s*$') "expected enable-all privilege shaping to leave at least one enabled privilege, got: $($enableAll.privileges)" + + 'verified admin token shaping for medium integrity plus real enable-all and disable-all privilege modes' + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'msudo:shell-wrapped-system-and-ti' -Script { + $status = & $msudo status --json | ConvertFrom-Json + $availableShells = @{} + foreach ($shell in @($status.shells)) { + $availableShells[[string]$shell.preset] = [bool]$shell.available + } + + $tempDir = Join-Path $env:TEMP ("msudo-shells-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + + function New-EncodedPayload([string]$outputPath, [bool]$includeGroups) { + $escapedPath = $outputPath.Replace("'", "''") + $groupsExpr = if ($includeGroups) { "(whoami /groups | Out-String)" } else { '$null' } + $script = @' +$result = @{ + user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + session = (Get-Process -Id $PID).SessionId + groups = __GROUPS__ +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText('__OUTPUT_PATH__', $result) +'@ + $script = $script.Replace('__GROUPS__', $groupsExpr).Replace('__OUTPUT_PATH__', $escapedPath) + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) + } + + $systemCases = @( + @{ preset = 'cmd' } + @{ preset = 'powershell' } + @{ preset = 'pwsh' } + @{ preset = 'git-bash' } + ) + + $verified = [System.Collections.Generic.List[string]]::new() + try { + foreach ($case in $systemCases) { + if (-not $availableShells.ContainsKey($case.preset) -or -not $availableShells[$case.preset]) { + continue + } + + $outputPath = Join-Path $tempDir ("system-" + $case.preset + '.json') + $argv = @( + '--user', 'system', + '--dangerous', + '--wait', + '--shell', $case.preset, + '--', + 'powershell.exe', + '-NoProfile', + '-EncodedCommand', + (New-EncodedPayload -outputPath $outputPath -includeGroups:$true) + ) + $output = (& $msudo @argv 2>&1 | Out-String) + $exitCode = $LASTEXITCODE + + Assert-Condition ($exitCode -eq 0) "expected system shell-wrapped launch via $($case.preset) to succeed, got exit code $exitCode with output: $output" + Assert-Condition (Test-Path -LiteralPath $outputPath) "expected system shell-wrapped launch via $($case.preset) to write $outputPath" + + $result = Get-Content $outputPath -Raw | ConvertFrom-Json + Assert-Condition ($result.user -eq 'NT AUTHORITY\SYSTEM') "expected system shell-wrapped launch via $($case.preset) to run as SYSTEM, got $($result.user)" + [void]$verified.Add($case.preset) + } + + Assert-Condition ($verified.Count -ge 3) "expected to verify at least 3 available system shell presets, got $($verified.Count): $($verified -join ', ')" + + $tiPreset = @('powershell', 'pwsh', 'cmd', 'git-bash') | Where-Object { + $availableShells.ContainsKey($_) -and $availableShells[$_] + } | Select-Object -First 1 + Assert-Condition ($null -ne $tiPreset) 'expected at least one available shell preset to verify trustedinstaller shell wrapping' + + $tiPath = Join-Path $tempDir ("trustedinstaller-" + $tiPreset + '.json') + $tiArgv = @( + '--user', 'trustedinstaller', + '--dangerous', + '--wait', + '--shell', $tiPreset, + '--', + 'powershell.exe', + '-NoProfile', + '-EncodedCommand', + (New-EncodedPayload -outputPath $tiPath -includeGroups:$true) + ) + $tiOutput = (& $msudo @tiArgv 2>&1 | Out-String) + $tiExitCode = $LASTEXITCODE + + Assert-Condition ($tiExitCode -eq 0) "expected trustedinstaller shell-wrapped launch via $tiPreset to succeed, got exit code $tiExitCode with output: $tiOutput" + Assert-Condition (Test-Path -LiteralPath $tiPath) "expected trustedinstaller shell-wrapped launch via $tiPreset to write $tiPath" + + $tiResult = Get-Content $tiPath -Raw | ConvertFrom-Json + Assert-Condition ($tiResult.user -eq 'NT AUTHORITY\SYSTEM') "expected trustedinstaller shell-wrapped launch via $tiPreset to report SYSTEM user, got $($tiResult.user)" + Assert-Condition ($tiResult.groups -match 'NT SERVICE\\TrustedInstaller') "expected trustedinstaller shell-wrapped launch via $tiPreset to include TrustedInstaller group evidence, got: $($tiResult.groups)" + + "verified shell-wrapped system launches through $($verified -join ', ') and trustedinstaller group evidence through $tiPreset" + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'msudo:same-console-system-and-ti' -Script { + $status = & $msudo status --json | ConvertFrom-Json + $availableShells = @{ } + foreach ($shell in @($status.shells)) { + $availableShells[[string]$shell.preset] = [bool]$shell.available + } + + $tempDir = Join-Path $env:TEMP ("msudo-same-console-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + + function New-EncodedPayload([string]$outputPath, [bool]$includeGroups) { + $escapedPath = $outputPath.Replace("'", "''") + $groupsExpr = if ($includeGroups) { "(whoami /groups | Out-String)" } else { '$null' } + $script = @' +$result = @{ + user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + session = (Get-Process -Id $PID).SessionId + groups = __GROUPS__ +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText('__OUTPUT_PATH__', $result) +'@ + $script = $script.Replace('__GROUPS__', $groupsExpr).Replace('__OUTPUT_PATH__', $escapedPath) + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) + } + + try { + $systemPath = Join-Path $tempDir 'system-direct.json' + & $msudo --same-console --user system --dangerous -- powershell.exe -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $systemPath -includeGroups:$true) + Assert-Condition ($LASTEXITCODE -eq 0) "expected same-console system launch to succeed, got exit code $LASTEXITCODE" + Assert-Condition (Test-Path -LiteralPath $systemPath) "expected same-console system launch to write $systemPath" + $system = Get-Content $systemPath -Raw | ConvertFrom-Json + Assert-Condition ($system.user -eq 'NT AUTHORITY\SYSTEM') "expected same-console system launch to run as SYSTEM, got $($system.user)" + + $tiPath = Join-Path $tempDir 'trustedinstaller-direct.json' + & $msudo --same-console --user trustedinstaller --dangerous -- powershell.exe -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $tiPath -includeGroups:$true) + Assert-Condition ($LASTEXITCODE -eq 0) "expected same-console trustedinstaller launch to succeed, got exit code $LASTEXITCODE" + Assert-Condition (Test-Path -LiteralPath $tiPath) "expected same-console trustedinstaller launch to write $tiPath" + $ti = Get-Content $tiPath -Raw | ConvertFrom-Json + Assert-Condition ($ti.groups -match 'NT SERVICE\\TrustedInstaller') "expected same-console trustedinstaller launch to include TrustedInstaller group evidence, got: $($ti.groups)" + + $sameConsolePreset = @('powershell', 'pwsh', 'cmd', 'git-bash') | Where-Object { + $availableShells.ContainsKey($_) -and $availableShells[$_] + } | Select-Object -First 1 + Assert-Condition ($null -ne $sameConsolePreset) 'expected at least one available preset for same-console shell wrapping' + + $wrappedPath = Join-Path $tempDir ("system-wrapped-" + $sameConsolePreset + '.json') + & $msudo --same-console --user system --dangerous --shell $sameConsolePreset -- powershell.exe -NoProfile -EncodedCommand (New-EncodedPayload -outputPath $wrappedPath -includeGroups:$true) + Assert-Condition ($LASTEXITCODE -eq 0) "expected same-console system shell wrapping via $sameConsolePreset to succeed, got exit code $LASTEXITCODE" + Assert-Condition (Test-Path -LiteralPath $wrappedPath) "expected same-console system shell wrapping via $sameConsolePreset to write $wrappedPath" + $wrapped = Get-Content $wrappedPath -Raw | ConvertFrom-Json + Assert-Condition ($wrapped.user -eq 'NT AUTHORITY\SYSTEM') "expected same-console system shell wrapping via $sameConsolePreset to run as SYSTEM, got $($wrapped.user)" + + "verified same-console direct SYSTEM/TI launches and shell wrapping through $sameConsolePreset" + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'msudo:privileged-pipeline-passthrough' -Script { + $systemOutput = (& $msudo --same-console --user system --dangerous --wait -- cmd /d /c whoami | Out-String) + $systemExit = $LASTEXITCODE + Assert-Condition ($systemExit -eq 0) "expected piped same-console SYSTEM launch to exit 0, got $systemExit" + Assert-Condition ($systemOutput -match 'nt authority\\system') "expected piped same-console SYSTEM stdout to include identity, got: $systemOutput" + + $tiOutput = (& $msudo --same-console --user trustedinstaller --dangerous --wait -- cmd /d /c whoami /groups | Out-String) + $tiExit = $LASTEXITCODE + Assert-Condition ($tiExit -eq 0) "expected piped same-console TrustedInstaller launch to exit 0, got $tiExit" + Assert-Condition ($tiOutput -match 'NT SERVICE\\TrustedInstaller') "expected piped same-console TrustedInstaller stdout to include service SID, got: $tiOutput" + + $stderrOutput = (& $msudo --same-console --user system --dangerous --wait -- cmd /d /c 'echo msudo-stderr 1>&2' 2>&1 | Out-String) + $stderrExit = $LASTEXITCODE + Assert-Condition ($stderrExit -eq 0) "expected piped same-console SYSTEM stderr launch to exit 0, got $stderrExit" + Assert-Condition ($stderrOutput -match 'msudo-stderr') "expected piped same-console SYSTEM stderr to be replayed, got: $stderrOutput" + + 'verified privileged stdout/stderr passthrough through non-elevated relay pipelines' +} + +Invoke-Check -Name 'asmtype:managed-json' -Script { + $types = & $asmtype (Join-Path $fixtureRoot 'managed\bin\GameAssembly.dll') --with-member-match 'StartProject|QueueVehicle' --json | + ConvertFrom-Json + + Assert-Condition (@($types).Count -ge 2) "expected at least 2 managed types, got $(@($types).Count)" + Assert-Condition ((@($types) | Where-Object full_name -eq 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow').Count -ge 1) 'expected SpaceCraftConstructionWindow in asmtype output' + Assert-Condition ((@($types) | Where-Object full_name -eq 'Game.UI.Windows.Windows.SpaceCraftProjectWindow').Count -ge 1) 'expected SpaceCraftProjectWindow in asmtype output' + "listed $(@($types).Count) matching managed types" +} + +Invoke-Check -Name 'asmmember:jsonl-types' -Script { + $members = & $asmtype (Join-Path $fixtureRoot 'managed\bin\GameAssembly.dll') --intent unity-spacecraft-workflow --json | + & $asmmember --assembly (Join-Path $fixtureRoot 'managed\bin\GameAssembly.dll') --input-format jsonl --intent unity-spacecraft-workflow --json | + ConvertFrom-Json + + Assert-Condition ((@($members) | Where-Object kind -eq 'method' | Where-Object name -eq 'StartProject').Count -ge 1) 'expected StartProject method in asmmember output' + Assert-Condition ((@($members) | Where-Object kind -eq 'method' | Where-Object name -eq 'FinishProject').Count -ge 1) 'expected FinishProject method in asmmember output' + Assert-Condition ((@($members) | Where-Object kind -eq 'property' | Where-Object name -eq 'ProjectName').Count -ge 1) 'expected ProjectName property in asmmember output' + "listed $(@($members).Count) managed members from JSONL type handoff" +} + +Invoke-Check -Name 'asmref:resolved-reference' -Script { + $report = & $asmref (Join-Path $fixtureRoot 'managed\bin\GameAssembly.dll') --resolve-dir (Join-Path $fixtureRoot 'managed\bin') --json | + ConvertFrom-Json + + $fixtureSupport = @($report.references) | Where-Object name -eq 'FixtureSupport' | Select-Object -First 1 + Assert-Condition ($null -ne $fixtureSupport) 'expected FixtureSupport reference in asmref output' + Assert-Condition ($fixtureSupport.resolved) 'expected FixtureSupport reference to resolve' + "resolved managed references for $($report.assembly.assembly_name)" +} + +Invoke-Check -Name 'binmeta:fd-path-stream' -Script { + $reports = & $fdPath '-a' '--max-depth' '1' '^jsonlgrep\.exe$' $script:BinaryRoot | + & $binmeta --input-format lines --json | + ConvertFrom-Json + + $report = @($reports)[0] + Assert-Condition (@($reports).Count -eq 1) "expected one binmeta report, got $(@($reports).Count)" + Assert-Condition ($report.path.EndsWith('jsonlgrep.exe')) "expected binmeta path to end with jsonlgrep.exe, got $($report.path)" + "inspected $($report.path) from fd path output" +} + +Invoke-Check -Name 'fileprobe:fd-path-stream' -Script { + $reports = & $fdPath '-a' '^sample\.rs$' (Join-Path $fixtureRoot 'reading') | + & $fileprobe --input-format lines --json | + ConvertFrom-Json + + $report = @($reports)[0] + Assert-Condition (@($reports).Count -eq 1) "expected one fileprobe report, got $(@($reports).Count)" + Assert-Condition ($report.language_hint -eq 'rust') "expected fileprobe language hint rust, got $($report.language_hint)" + "classified $($report.path) as $($report.family) with language hint $($report.language_hint)" +} + +Invoke-Check -Name 'outline:recent-path-chain' -Script { + $reports = & $recent --root $fixtureRoot --ext rs --limit 1 --json | + ConvertFrom-Json | + Select-Object -ExpandProperty path | + & $outline --input-format lines --json | + ConvertFrom-Json + + $report = @($reports)[0] + Assert-Condition (@($reports).Count -eq 1) "expected one outline report, got $(@($reports).Count)" + Assert-Condition (@($report.items).Count -ge 3) "expected outline to report at least 3 items, got $(@($report.items).Count)" + "outlined $($report.path) with $(@($report.items).Count) structural items" +} + +Invoke-Check -Name 'codeshape:polyglot-json' -Script { + $report = & $codeshape (Join-Path $fixtureRoot 'polyglot\repo') --json | ConvertFrom-Json + $appTs = @($report.files) | Where-Object path -like '*web\app.ts' | Select-Object -First 1 + + Assert-Condition (@($report.files).Count -ge 6) "expected at least 6 indexed files, got $(@($report.files).Count)" + Assert-Condition ($null -ne $appTs) 'expected codeshape to include web\app.ts' + Assert-Condition ((@($appTs.items) | Where-Object name -eq 'helper').Count -ge 1) 'expected codeshape to capture helper in app.ts' + "indexed $(@($report.files).Count) files and surfaced AST-backed declarations" +} + +Invoke-Check -Name 'snip:stdin-text' -Script { + $snippet = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'reading\sample.rs') | + & $snip --around helper --context 0 + + Assert-Condition (($snippet -join "`n") -match 'helper') 'expected snip output to include helper' + "extracted helper-focused context from piped text" +} + +Invoke-Check -Name 'defsnip:polyglot-json' -Script { + $symbolMatches = & $defsnip helper (Join-Path $fixtureRoot 'polyglot\repo') --json | ConvertFrom-Json + + Assert-Condition (@($symbolMatches).Count -ge 4) "expected at least 4 helper definitions, got $(@($symbolMatches).Count)" + Assert-Condition ((@($symbolMatches) | Where-Object path -like '*web\app.ts').Count -ge 1) 'expected defsnip to include the TypeScript helper' + Assert-Condition ((@($symbolMatches) | Where-Object text -match 'helper').Count -ge 4) 'expected emitted definition text to contain helper bodies' + "extracted $(@($symbolMatches).Count) full definition block(s) by exact symbol name" +} + +Invoke-Check -Name 'chunkcat:recent-path-chain' -Script { + $report = & $recent --root $fixtureRoot --ext rs --limit 1 --json | + ConvertFrom-Json | + Select-Object -ExpandProperty path | + & $chunkcat --input-format lines --max-lines 8 --chunk 0 --json | + ConvertFrom-Json + + Assert-Condition ($report.chunk_count -ge 1) "expected at least one chunk, got $($report.chunk_count)" + Assert-Condition ($null -ne $report.selected_chunk) 'expected chunkcat to materialize the selected chunk' + "selected chunk 0 from $($report.path) with $($report.selected_chunk.line_count) lines" +} + +Invoke-Check -Name 'hitsnip:rg-context' -Script { + $snippets = & $rgPath '-n' '-H' 'Mode::' (Join-Path $fixtureRoot 'reading\sample.rs') | + & $hitsnip --context 1 --json | + ConvertFrom-Json + + Assert-Condition (@($snippets).Count -ge 1) 'expected at least one hitsnip snippet' + Assert-Condition ((@($snippets)[0].path) -like '*sample.rs') "expected hitsnip path to end with sample.rs, got $((@($snippets)[0].path))" + "expanded rg hits into $(@($snippets).Count) merged snippet(s)" +} + +Invoke-Check -Name 'diagpick:stdin-log' -Script { + $diagnostics = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'diag\rust-errors.txt') | + & $diagpick --with-source --json | + ConvertFrom-Json + + $diagnostic = @($diagnostics)[0] + Assert-Condition (@($diagnostics).Count -ge 1) 'expected at least one diagnostic' + Assert-Condition ($diagnostic.severity -eq 'error') "expected first diagnostic severity error, got $($diagnostic.severity)" + "picked $(@($diagnostics).Count) diagnostic(s) with source context" +} + +Invoke-Check -Name 'logshape:stdin-log' -Script { + $report = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'logs\repetitive.log') | + & $logshape --json | + ConvertFrom-Json + + Assert-Condition ($report.summary.group_count -ge 2) "expected at least 2 grouped patterns, got $($report.summary.group_count)" + Assert-Condition ((@($report.groups)[0].count) -ge 2) "expected top log group count >= 2, got $((@($report.groups)[0].count))" + "grouped repetitive logs into $($report.summary.group_count) templates" +} + +Invoke-Check -Name 'stringscan:fd-path-stream' -Script { + $reports = & $fdPath '-a' '^stringscan-sample\.bin$' (Join-Path $fixtureRoot 'binaries') | + & $stringscan --input-format lines --json | + ConvertFrom-Json + + $report = @($reports)[0] + Assert-Condition (@($reports).Count -eq 1) "expected one stringscan report, got $(@($reports).Count)" + Assert-Condition ((@($report.categories) -contains 'url')) 'expected stringscan categories to include url' + Assert-Condition ((@($report.categories) -contains 'bepinex')) 'expected stringscan categories to include bepinex' + "extracted $($report.match_count) classified strings from $($report.path)" +} + +Invoke-Check -Name 'toon:json-wrapper-and-jsonl' -Script { + $wrapper = & $toon (Join-Path $fixtureRoot 'toon\config.json') --json | ConvertFrom-Json + $text = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'toon\records.jsonl') | + & $toon + + Assert-Condition ($wrapper.format -eq 'toon') "expected toon JSON wrapper format to be toon, got $($wrapper.format)" + Assert-Condition (($text -join "`n") -match 'event') 'expected TOON text converted from JSONL to include the event field' + "rendered a TOON JSON wrapper and converted JSONL stdin to TOON text" +} + +Invoke-Check -Name 'csvshape:stdin-tsv' -Script { + $summary = & $batPath '--style=plain' '--paging=never' (Join-Path $fixtureRoot 'csvshape\sample.tsv') | + & $csvshape --delimiter tab --json | + ConvertFrom-Json + + Assert-Condition ($summary.delimiter -eq 'tab') "expected detected delimiter tab, got $($summary.delimiter)" + Assert-Condition ($summary.column_count -eq 4) "expected 4 columns, got $($summary.column_count)" + Assert-Condition ((@($summary.columns) | Where-Object name -eq 'score').Count -eq 1) 'expected score column in csvshape output' + "summarized TSV schema with $($summary.column_count) columns and delimiter $($summary.delimiter)" +} + +Invoke-Check -Name 'sqliteshape:fixture-db' -Script { + $summary = & $sqliteshape (Join-Path $fixtureRoot 'sqliteshape\sample.db') --include-indexes --json | + ConvertFrom-Json + + Assert-Condition ((@($summary.tables) | Where-Object name -eq 'users').Count -eq 1) 'expected users table in sqliteshape output' + Assert-Condition ((@($summary.tables) | Where-Object name -eq 'events').Count -eq 1) 'expected events table in sqliteshape output' + Assert-Condition ((@($summary.tables)[0].columns).Count -ge 2) 'expected at least two columns in first table summary' + "inspected SQLite fixture with $(@($summary.tables).Count) tables" +} + +Invoke-Check -Name 'sqlshape:sqlite-fixture-db' -Script { + $summary = & $sqlshape --engine sqlite --url (Join-Path $fixtureRoot 'sqliteshape\sample.db') --json | + ConvertFrom-Json + + Assert-Condition ($summary.engine -eq 'sqlite') "expected sqlite engine, got $($summary.engine)" + Assert-Condition (@($summary.schemas | Where-Object { $_ -eq 'main' }).Count -eq 1) 'expected main schema in sqlshape output' + Assert-Condition (@($summary.tables | Where-Object name -eq 'users').Count -eq 1) 'expected users table in sqlshape output' + Assert-Condition (@($summary.tables | Where-Object name -eq 'events').Count -eq 1) 'expected events table in sqlshape output' + "inspected normalized SQLite fixture with $(@($summary.tables).Count) tables" +} + +Invoke-Check -Name 'envdiff:cmd-wrapper-json' -Script { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("envdiff-check-" + [System.Guid]::NewGuid()) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + try { + $scriptPath = Join-Path $tempDir 'mutate-env.cmd' + Set-Content -LiteralPath $scriptPath -Encoding ascii -Value @' +@echo off +set TOOLBOX_STAGE=after +set TOOLBOX_REMOVE= +set PATH=%PATH%;C:\MercuryToolbox\TestBin +'@ + $report = & $envdiff run --json --shell cmd -- $scriptPath | ConvertFrom-Json + + Assert-Condition ((@($report.added) | Where-Object name -eq 'TOOLBOX_STAGE').Count -eq 1) 'expected TOOLBOX_STAGE in added variables' + Assert-Condition ((@($report.path_like_changes) | Where-Object name -eq 'Path' | Where-Object { @($_.added_segments) -contains 'C:\MercuryToolbox\TestBin' }).Count -eq 1) 'expected PATH segment delta in envdiff output' + "captured environment mutations and PATH segment deltas from cmd wrapper" + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'proctree:run-json' -Script { + $report = & $proctree run --json -- pwsh -NoProfile -Command "Start-Process -FilePath pwsh -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 2' -WindowStyle Hidden; Start-Sleep -Milliseconds 400" | + ConvertFrom-Json + + Assert-Condition ($report.root_pid -gt 0) "expected rooted report pid, got $($report.root_pid)" + Assert-Condition (@($report.nodes).Count -ge 1) 'expected at least one process node from proctree run' + Assert-Condition ((@($report.nodes) | Where-Object pid -eq $report.root_pid).Count -eq 1) 'expected proctree report to include the root node' + "captured process tree with $(@($report.nodes).Count) node(s)" +} + +Invoke-Check -Name 'sysshape:shell-json' -Script { + $report = & $sysshape --json --env none --group shell | ConvertFrom-Json + + Assert-Condition (-not [string]::IsNullOrWhiteSpace($report.system.architecture)) 'expected sysshape to report a machine architecture' + Assert-Condition (-not [string]::IsNullOrWhiteSpace($report.system.default_shell)) 'expected sysshape to report a default shell hint' + Assert-Condition (@($report.tools).Count -ge 1) 'expected sysshape to detect at least one shell tool' + Assert-Condition ( + ((@($report.tools) | Where-Object name -eq 'pwsh').Count -ge 1) -or + ((@($report.tools) | Where-Object name -eq 'powershell').Count -ge 1) + ) 'expected sysshape shell inventory to include pwsh or powershell' + Assert-Condition ((@($report.tools) | Where-Object probe_status -eq 'ok').Count -ge 1) 'expected sysshape shell inventory to report probe_status' + "captured shell inventory with $(@($report.tools).Count) detected tool(s)" +} + +Invoke-Check -Name 'unlock:who-and-delete' -Script { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("unlock-check-" + [System.Guid]::NewGuid()) + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + $locker = $null + try { + $lockedPath = Join-Path $tempDir 'locked.txt' + $lockerScript = Join-Path $tempDir 'locker.ps1' + Set-Content -LiteralPath $lockedPath -Encoding utf8NoBOM -Value 'busy' + Set-Content -LiteralPath $lockerScript -Encoding utf8NoBOM -Value @' +$path = '__LOCKED_PATH__' +$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) +try { + Start-Sleep -Seconds 30 +} +finally { + $stream.Dispose() +} +'@.Replace('__LOCKED_PATH__', $lockedPath.Replace("'", "''")) + + $locker = Start-Process -FilePath pwsh -ArgumentList @('-NoProfile', '-File', $lockerScript) -PassThru -WindowStyle Hidden + Wait-Until -Description "exclusive lock on $lockedPath" -TimeoutMilliseconds 3000 -Probe { + Test-ExclusiveFileLock -Path $lockedPath + } + + $who = & $unlock who --json $lockedPath | ConvertFrom-Json + $delete = & $unlock delete --json --force $lockedPath | ConvertFrom-Json + $whoResult = @($who.results)[0] + $deleteResult = @($delete.results)[0] + + Assert-Condition ($who.summary.results -eq 1) "expected single unlock who result, got $($who.summary.results)" + Assert-Condition (@($whoResult.initial_blockers).Count -ge 1 -or @($whoResult.final_blockers).Count -ge 1) 'expected unlock who to surface at least one blocker' + Assert-Condition ($delete.summary.results -eq 1) "expected single unlock delete result, got $($delete.summary.results)" + Assert-Condition ($deleteResult.ok) 'expected unlock delete --force to succeed' + Assert-Condition (-not (Test-Path -LiteralPath $lockedPath)) 'expected locked file to be deleted' + "identified blockers and deleted a locked file with staged escalation" + } + finally { + if ($null -ne $locker -and -not $locker.HasExited) { + Stop-Process -Id $locker.Id -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Check -Name 'toolbox:valid-jsonl-path-stream-smokes' -Script { + 'covered by per-command path-stream:* JSONL path-record checks below' +} + +function Invoke-JsonlPathRecordSmoke { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$InputPath, + [string[]]$ArgumentList = @(), + [int[]]$AcceptExitCodes = @(0) + ) + + Assert-Condition (Test-Path -LiteralPath $InputPath) "expected JSONL path-record smoke input to exist for ${Name}: $InputPath" + $commandPath = Get-ToolboxBinaryPath -Name $Name + $jsonl = @{ path = $InputPath } | ConvertTo-Json -Compress + $output = $jsonl | & $commandPath @ArgumentList 2>&1 + $exitCode = $LASTEXITCODE + $text = (($output | ForEach-Object { [string]$_ }) -join "`n") + + Assert-Condition (@($AcceptExitCodes) -contains $exitCode) "expected ${Name} JSONL path-record exit code in $($AcceptExitCodes -join ', '), got $exitCode with output: $text" + Assert-Condition (-not [string]::IsNullOrWhiteSpace($text)) "expected ${Name} JSONL path-record smoke to emit output" + Assert-Condition ($text -notmatch "(?i)thread '.*' panicked|stack backtrace|RUST_BACKTRACE|panic at") "expected ${Name} JSONL path-record smoke to avoid panic/backtrace text, got: $text" + + "verified ${Name} JSONL path-record stdin using $InputPath" +} + +$sampleRust = Join-Path $fixtureRoot 'reading\sample.rs' +$readingRoot = Join-Path $fixtureRoot 'reading' +$polyglotRoot = Join-Path $fixtureRoot 'polyglot\repo' +$managedBinRoot = Join-Path $fixtureRoot 'managed\bin' +$managedAssembly = Join-Path $managedBinRoot 'GameAssembly.dll' +$csvSample = Join-Path $fixtureRoot 'csvshape\sample.csv' +$sqliteSample = Join-Path $fixtureRoot 'sqliteshape\sample.db' +$stringSample = Join-Path $fixtureRoot 'binaries\stringscan-sample.bin' +$peSample = Join-Path $script:BinaryRoot 'portping.exe' +$kernel32 = Join-Path $env:WINDIR 'System32\kernel32.dll' +$driverRoot = Join-Path $env:WINDIR 'System32\drivers' +$driverSmokeSample = @( + 'ndis.sys', + 'disk.sys', + 'afd.sys', + 'tcpip.sys', + 'afunix.sys' +) | + ForEach-Object { Join-Path $driverRoot $_ } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($driverSmokeSample) -and (Test-Path -LiteralPath $driverRoot -PathType Container)) { + $driverSmokeSample = Get-ChildItem -LiteralPath $driverRoot -Filter '*.sys' -File | + Sort-Object Name | + Select-Object -First 1 -ExpandProperty FullName +} +Assert-Condition (-not [string]::IsNullOrWhiteSpace($driverSmokeSample)) "expected at least one system driver sample under $driverRoot for path-stream smoke coverage" + +$pathStreamSmokes = @( + @{ Name = 'asmflow'; Path = $managedAssembly; Args = @('--input-format', 'jsonl', '--json', 'find', '--match', 'StartProject') }, + @{ Name = 'asmref'; Path = $managedAssembly; Args = @('--input-format', 'jsonl', '--resolve-dir', $managedBinRoot, '--json') }, + @{ Name = 'asmtype'; Path = $managedAssembly; Args = @('--input-format', 'jsonl', '--match', 'SpaceCraft', '--json') }, + @{ Name = 'binmeta'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'chunkcat'; Path = $sampleRust; Args = @('--input-format', 'jsonl', '--max-lines', '8', '--chunk', '0', '--json') }, + @{ Name = 'codeshape'; Path = $polyglotRoot; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'csvshape'; Path = $csvSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'defsnip'; Path = $polyglotRoot; Args = @('helper', '--input-format', 'jsonl', '--json') }, + @{ Name = 'mhash'; Path = $sampleRust; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'drvshape'; Path = $driverSmokeSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'fileprobe'; Path = $sampleRust; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'ioctlscan'; Path = $driverSmokeSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'llvmnm'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'llvmobjdump'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json', '--raw-output-limit', '65536') }, + @{ Name = 'llvmreadobj'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'outline'; Path = $sampleRust; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'pecalls'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json', '--api', 'GetProcAddress'); AcceptExitCodes = @(0, 1) }, + @{ Name = 'peexports'; Path = $kernel32; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'peimports'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'pesig'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json', '--min-confidence', 'low') }, + @{ Name = 'pestrrefs'; Path = $peSample; Args = @('--input-format', 'jsonl', '--json', '--contains', 'json') }, + @{ Name = 'recent'; Path = $readingRoot; Args = @('--input-format', 'jsonl', '--json', '--limit', '1') }, + @{ Name = 'refs'; Path = $polyglotRoot; Args = @('helper', '--input-format', 'jsonl', '--json') }, + @{ Name = 'sqliteshape'; Path = $sqliteSample; Args = @('--input-format', 'jsonl', '--include-indexes', '--json') }, + @{ Name = 'stringscan'; Path = $stringSample; Args = @('--input-format', 'jsonl', '--json') }, + @{ Name = 'unityasset'; Path = $readingRoot; Args = @('--input-format', 'jsonl', '--json', 'index', '--allow-empty') }, + @{ Name = 'unlock'; Path = $sampleRust; Args = @('--input-format', 'jsonl', '--json', 'who') } +) + +foreach ($smoke in $pathStreamSmokes) { + Invoke-Check -Name "path-stream:$($smoke.Name):jsonl-path-record" -Script { + $acceptExitCodes = if ($smoke.ContainsKey('AcceptExitCodes')) { $smoke.AcceptExitCodes } else { @(0) } + Invoke-JsonlPathRecordSmoke -Name $smoke.Name -InputPath $smoke.Path -ArgumentList $smoke.Args -AcceptExitCodes $acceptExitCodes + }.GetNewClosure() +} +Invoke-Check -Name 'installer:temp-root-roundtrip' -Script { + $installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolbox-" + [System.Guid]::NewGuid()) + $codexHome = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxCodex-" + [System.Guid]::NewGuid()) + $activeBinDir = Join-Path $installRoot 'current\bin' + try { + & (Join-Path $PSScriptRoot 'install-toolbox.ps1') ` + -Configuration $Configuration ` + -InstallRoot $installRoot ` + -CodexHome $codexHome ` + -NoPathUpdate ` + -SkipBuild + if ($LASTEXITCODE -ne 0) { + throw "install-toolbox.ps1 exited with $LASTEXITCODE" + } + + foreach ($commandName in Get-ToolboxCommandNames) { + $commandPath = Join-Path $activeBinDir "$commandName.exe" + Assert-Condition (Test-Path -LiteralPath $commandPath) "expected installed binary not found: $commandPath" + } + Assert-Condition (Test-Path -LiteralPath (Join-Path $activeBinDir 'duckdb.dll')) 'expected installed DuckDB runtime' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox\docs\ai\mercury-toolbox-ai-prompt.md') + ) 'expected installed AI prompt in share directory' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox\skills\mercury-toolbox\SKILL.md') + ) 'expected installed shared skill copy' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $codexHome 'skills\mercury-toolbox\SKILL.md') + ) 'expected installed Codex skill copy' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $codexHome 'skills\mercury-toolbox\references\command-catalog.md') + ) 'expected installed Codex command catalog' + + & (Join-Path $PSScriptRoot 'uninstall-toolbox.ps1') -InstallRoot $installRoot -CodexHome $codexHome + if ($LASTEXITCODE -ne 0) { + throw "uninstall-toolbox.ps1 exited with $LASTEXITCODE" + } + + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'current'))) 'expected uninstall to remove the current install junction' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'versions'))) 'expected uninstall to remove staged versions' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox'))) 'expected uninstall to remove the share directory' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $codexHome 'skills\mercury-toolbox'))) 'expected uninstall to remove the Codex skill' + "installed and removed $((Get-ToolboxCommandNames).Count) commands in a temp root" + } + finally { + foreach ($path in @($installRoot, $codexHome)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } + } +} + +Invoke-Check -Name 'installer:refuses-unmanaged-codex-skill' -Script { + $installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxGuard-" + [System.Guid]::NewGuid()) + $codexHome = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxGuardCodex-" + [System.Guid]::NewGuid()) + $skillRoot = Join-Path $codexHome 'skills\mercury-toolbox' + $sentinel = Join-Path $skillRoot 'USER-SKILL.txt' + try { + New-Item -ItemType Directory -Force -Path $skillRoot | Out-Null + Set-Content -LiteralPath $sentinel -Value 'user managed' -Encoding utf8NoBOM + + $result = Invoke-PwshFile -FilePath (Join-Path $PSScriptRoot 'install-toolbox.ps1') -ArgumentList @( + '-Configuration', $Configuration, + '-InstallRoot', $installRoot, + '-CodexHome', $codexHome, + '-NoPathUpdate', + '-SkipBuild' + ) + + Assert-Condition ($result.ExitCode -ne 0) 'expected workspace installer to refuse unmanaged Codex skill directory' + Assert-Condition (Test-Path -LiteralPath $sentinel) 'expected unmanaged Codex skill sentinel to be preserved' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $skillRoot '.mercury-toolbox-owner.json'))) 'expected unmanaged skill to remain unmarked' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'current'))) 'expected direct installer to fail before activating an install' + 'refused unmanaged direct-install Codex skill directory without overwriting it' + } + finally { + foreach ($path in @($installRoot, $codexHome)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } + } +} + +Invoke-Check -Name 'package:portable-roundtrip' -Script { + $packageOutputRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxPkg-" + [System.Guid]::NewGuid()) + $extractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxExtract-" + [System.Guid]::NewGuid()) + $installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxPortable-" + [System.Guid]::NewGuid()) + $codexHome = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxPortableCodex-" + [System.Guid]::NewGuid()) + $activeBinDir = Join-Path $installRoot 'current\bin' + try { + if ($SkipPromptGeneration) { + & (Join-Path $PSScriptRoot 'package-toolbox.ps1') ` + -Configuration $Configuration ` + -OutputRoot $packageOutputRoot ` + -SkipBuild ` + -SkipPromptGeneration + } else { + & (Join-Path $PSScriptRoot 'package-toolbox.ps1') ` + -Configuration $Configuration ` + -OutputRoot $packageOutputRoot ` + -SkipBuild + } + if ($LASTEXITCODE -ne 0) { + throw "package-toolbox.ps1 exited with $LASTEXITCODE" + } + + $zip = @(Get-ChildItem -LiteralPath $packageOutputRoot -Filter '*.zip' -File) + Assert-Condition ($zip.Count -eq 1) 'expected exactly one portable package archive' + + Expand-Archive -LiteralPath $zip[0].FullName -DestinationPath $extractRoot -Force + $packageDirs = @(Get-ChildItem -LiteralPath $extractRoot -Directory) + Assert-Condition ($packageDirs.Count -eq 1) 'expected extracted archive to contain one top-level package directory' + + $packageRoot = $packageDirs[0].FullName + $installScript = Join-Path $packageRoot 'scripts\install-package-toolbox.ps1' + $uninstallScript = Join-Path $packageRoot 'scripts\uninstall-package-toolbox.ps1' + Assert-Condition (Test-Path -LiteralPath $installScript) "expected package install script at $installScript" + Assert-Condition (Test-Path -LiteralPath $uninstallScript) "expected package uninstall script at $uninstallScript" + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'docs\ai\mercury-toolbox-ai-prompt.md')) 'expected packaged AI prompt' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'skills\mercury-toolbox\SKILL.md')) 'expected packaged Codex skill' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'skills\mercury-toolbox\references\command-catalog.md')) 'expected packaged command catalog' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'mercury-toolbox-package.json')) 'expected package manifest' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'SHA256SUMS.txt')) 'expected package checksums' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'START-HERE.txt')) 'expected portable package quickstart' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'LICENSE')) 'expected packaged license' + Assert-Condition (Test-Path -LiteralPath (Join-Path $packageRoot 'bin\duckdb.dll')) 'expected packaged DuckDB runtime' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'README.md'))) 'expected portable package to omit README.md' + + & $installScript -InstallRoot $installRoot -CodexHome $codexHome -NoPathUpdate + if ($LASTEXITCODE -ne 0) { + throw "install-package-toolbox.ps1 exited with $LASTEXITCODE" + } + + foreach ($commandName in Get-ToolboxCommandNames) { + $commandPath = Join-Path $activeBinDir "$commandName.exe" + Assert-Condition (Test-Path -LiteralPath $commandPath) "expected packaged install binary not found: $commandPath" + } + Assert-Condition (Test-Path -LiteralPath (Join-Path $activeBinDir 'duckdb.dll')) 'expected packaged install DuckDB runtime' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox\docs\ai\mercury-toolbox-ai-prompt.md') + ) 'expected installed AI prompt in portable share directory' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox\skills\mercury-toolbox\SKILL.md') + ) 'expected installed shared skill in portable share directory' + Assert-Condition ( + -not (Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox\README.md')) + ) 'expected portable share directory to omit README.md' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $codexHome 'skills\mercury-toolbox\SKILL.md') + ) 'expected installed portable Codex skill' + Assert-Condition ( + Test-Path -LiteralPath (Join-Path $codexHome 'skills\mercury-toolbox\references\command-catalog.md') + ) 'expected installed portable Codex command catalog' + + & $uninstallScript -InstallRoot $installRoot -CodexHome $codexHome + if ($LASTEXITCODE -ne 0) { + throw "uninstall-package-toolbox.ps1 exited with $LASTEXITCODE" + } + + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'current'))) 'expected portable uninstall to remove the current install junction' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'versions'))) 'expected portable uninstall to remove staged versions' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $installRoot 'share\mercury-toolbox'))) 'expected portable uninstall to remove the share directory' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $codexHome 'skills\mercury-toolbox'))) 'expected portable uninstall to remove the Codex skill' + 'packaged, extracted, installed, and removed the portable toolbox bundle' + } + finally { + foreach ($path in @($packageOutputRoot, $extractRoot, $installRoot, $codexHome)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } + } +} + +Invoke-Check -Name 'package:refuses-traversal-package-name' -Script { + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxTraversal-" + [System.Guid]::NewGuid()) + $outputRoot = Join-Path $tempRoot 'out' + $escapeRoot = Join-Path $tempRoot 'escape' + $sentinel = Join-Path $escapeRoot 'sentinel.txt' + try { + New-Item -ItemType Directory -Force -Path $outputRoot, $escapeRoot | Out-Null + Set-Content -LiteralPath $sentinel -Value 'do not delete' -Encoding utf8NoBOM + + $result = Invoke-PwshFile -FilePath (Join-Path $PSScriptRoot 'package-toolbox.ps1') -ArgumentList @( + '-Configuration', $Configuration, + '-OutputRoot', $outputRoot, + '-PackageName', '..\escape', + '-SkipBuild', + '-SkipPromptGeneration' + ) + + Assert-Condition ($result.ExitCode -ne 0) 'expected packager to reject a package name containing a path separator' + Assert-Condition (Test-Path -LiteralPath $sentinel) 'expected path outside OutputRoot to be preserved' + 'rejected traversal package name without deleting outside OutputRoot' + } + finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } +} + +Invoke-Check -Name 'package-installer:verifies-sha256-before-install' -Script { + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxHash-" + [System.Guid]::NewGuid()) + $packageRoot = Join-Path $tempRoot 'package' + $installRoot = Join-Path $tempRoot 'install' + $codexHome = Join-Path $tempRoot 'codex' + try { + New-TestPortablePackage -PackageRoot $packageRoot -UseWindowsSeparators + Set-Content -LiteralPath (Join-Path $packageRoot 'docs\ai\mercury-toolbox-ai-prompt.md') -Value 'tampered prompt' -Encoding utf8NoBOM + + $result = Invoke-PwshFile -FilePath (Join-Path $packageRoot 'scripts\install-package-toolbox.ps1') -ArgumentList @( + '-InstallRoot', $installRoot, + '-CodexHome', $codexHome, + '-NoPathUpdate' + ) + + Assert-Condition ($result.ExitCode -ne 0) 'expected portable installer to reject a mismatched SHA256SUMS entry' + Assert-Condition (-not (Test-Path -LiteralPath $installRoot)) 'expected hash failure before writing install root' + Assert-Condition (-not (Test-Path -LiteralPath $codexHome)) 'expected hash failure before writing Codex skill root' + 'rejected tampered package content before installing' + } + finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } +} + +Invoke-Check -Name 'package-installer:requires-listed-sha256-file' -Script { + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxHashMissing-" + [System.Guid]::NewGuid()) + $packageRoot = Join-Path $tempRoot 'package' + $installRoot = Join-Path $tempRoot 'install' + try { + New-TestPortablePackage -PackageRoot $packageRoot -UseWindowsSeparators + Add-Content -LiteralPath (Join-Path $packageRoot 'SHA256SUMS.txt') -Value ('0' * 64 + ' docs\ai\missing.txt') + + $result = Invoke-PwshFile -FilePath (Join-Path $packageRoot 'scripts\install-package-toolbox.ps1') -ArgumentList @( + '-InstallRoot', $installRoot, + '-NoCodexSkillInstall', + '-NoPathUpdate' + ) + + Assert-Condition ($result.ExitCode -ne 0) 'expected portable installer to reject a missing SHA256SUMS entry' + Assert-Condition (-not (Test-Path -LiteralPath $installRoot)) 'expected missing-file failure before writing install root' + 'rejected package with a missing listed checksum file before installing' + } + finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } +} + +Invoke-Check -Name 'package-installer:refuses-unmanaged-codex-skill' -Script { + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("MercuryToolboxPackageGuard-" + [System.Guid]::NewGuid()) + $packageRoot = Join-Path $tempRoot 'package' + $installRoot = Join-Path $tempRoot 'install' + $codexHome = Join-Path $tempRoot 'codex' + $skillRoot = Join-Path $codexHome 'skills\mercury-toolbox' + $sentinel = Join-Path $skillRoot 'USER-SKILL.txt' + try { + New-TestPortablePackage -PackageRoot $packageRoot -UseWindowsSeparators + New-Item -ItemType Directory -Force -Path $skillRoot | Out-Null + Set-Content -LiteralPath $sentinel -Value 'user managed' -Encoding utf8NoBOM + + $result = Invoke-PwshFile -FilePath (Join-Path $packageRoot 'scripts\install-package-toolbox.ps1') -ArgumentList @( + '-InstallRoot', $installRoot, + '-CodexHome', $codexHome, + '-NoPathUpdate' + ) + + Assert-Condition ($result.ExitCode -ne 0) 'expected portable installer to refuse unmanaged Codex skill directory' + Assert-Condition (Test-Path -LiteralPath $sentinel) 'expected unmanaged portable Codex skill sentinel to be preserved' + Assert-Condition (-not (Test-Path -LiteralPath (Join-Path $skillRoot '.mercury-toolbox-owner.json'))) 'expected unmanaged portable skill to remain unmarked' + Assert-Condition (-not (Test-Path -LiteralPath $installRoot)) 'expected portable installer to fail before writing install root' + 'refused unmanaged portable Codex skill directory without overwriting it' + } + finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } +} + +Write-Host '' +$script:Results | Format-Table -AutoSize + +if ($script:Failed) { + throw 'One or more ecosystem checks failed.' +} diff --git a/scripts/check-gitea-ci.ps1 b/scripts/check-gitea-ci.ps1 new file mode 100644 index 0000000..5491120 --- /dev/null +++ b/scripts/check-gitea-ci.ps1 @@ -0,0 +1,365 @@ +[CmdletBinding()] +param( + [string]$Remote = 'origin', + [string]$BaseUrl, + [string]$Owner, + [string]$Repo, + [string]$ApiToken, + [string]$Ref = 'main', + [string]$WorkflowId = 'ci.yml', + [string]$RequiredLabel, + [switch]$DispatchIfMissing, + [switch]$Wait, + [ValidateRange(30, 21600)] + [int]$TimeoutSeconds = 1800, + [ValidateRange(5, 300)] + [int]$PollSeconds = 15 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-GitCapture { + param( + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $output = & git @ArgumentList 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")" + } + + return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() +} + +function Resolve-GiteaRepositoryContext { + param( + [string]$RemoteName, + [string]$RemoteUrl + ) + + if ([string]::IsNullOrWhiteSpace($RemoteUrl)) { + $RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', $RemoteName) + } + + $normalized = $RemoteUrl.Trim() + if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') { + return [pscustomobject]@{ + BaseUrl = $Matches[1] + Owner = $Matches[2] + Repo = $Matches[3] + } + } + + if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') { + $giteaHost = $Matches[1] + $port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" } + return [pscustomobject]@{ + BaseUrl = "https://$giteaHost$port" + Owner = $Matches[3] + Repo = $Matches[4] + } + } + + throw "Could not parse Gitea remote URL: $normalized" +} + +function Resolve-ApiToken { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedBaseUrl, + [Parameter(Mandatory = $true)] + [string]$RepoOwner, + [Parameter(Mandatory = $true)] + [string]$RepoName, + [string]$ExplicitToken + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) { + return $ExplicitToken + } + + foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + + $uri = [Uri]$ResolvedBaseUrl + $lines = @( + 'protocol=https' + "host=$($uri.Authority)" + "path=$RepoOwner/$RepoName.git" + '' + ) | git credential fill + $passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1 + if ($null -eq $passwordLine) { + throw 'Could not resolve a Gitea API token from the current environment or git credential manager.' + } + + return $passwordLine.Substring(9) +} + +function Invoke-GiteaApi { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('GET', 'POST')] + [string]$Method, + [Parameter(Mandatory = $true)] + [string]$Uri, + [Parameter(Mandatory = $true)] + [string]$Token, + [object]$Body + ) + + $invokeArgs = @{ + Method = $Method + Uri = $Uri + Headers = @{ Authorization = "token $Token" } + ErrorAction = 'Stop' + } + if ($PSBoundParameters.ContainsKey('Body')) { + $invokeArgs.ContentType = 'application/json' + $invokeArgs.Body = $Body | ConvertTo-Json -Depth 8 + } + + try { + return Invoke-RestMethod @invokeArgs + } + catch { + $response = $_.Exception.Response + if ($null -eq $response) { + throw + } + + $statusCode = [int]$response.StatusCode + $payload = '' + if ($response -is [System.Net.Http.HttpResponseMessage]) { + if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) { + $payload = $_.ErrorDetails.Message + } + elseif ($null -ne $response.Content) { + try { + $payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } + catch { + $payload = '' + } + } + } + elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') { + $stream = $response.GetResponseStream() + if ($null -ne $stream) { + $reader = [System.IO.StreamReader]::new($stream) + try { + $payload = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + $stream.Dispose() + } + } + } + + throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload" + } +} + +function Get-OptionalProperty { + param( + [Parameter(Mandatory = $true)] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + return $property.Value +} + +function Resolve-WorkflowRunnerLabel { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + [Parameter(Mandatory = $true)] + [string]$WorkflowFileName, + [string]$ExplicitLabel + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitLabel)) { + return $ExplicitLabel + } + + $workflowPath = Join-Path $WorkspaceRoot (Join-Path '.gitea\workflows' $WorkflowFileName) + if (-not (Test-Path -LiteralPath $workflowPath -PathType Leaf)) { + throw "Workflow file not found: $workflowPath" + } + + $content = Get-Content -Raw -LiteralPath $workflowPath + $match = [regex]::Match($content, '(?m)^\s*runs-on:\s*[''"]?([^''"\r\n\[\], ]+)') + if (-not $match.Success) { + throw "Could not resolve runs-on label from $workflowPath" + } + + return $match.Groups[1].Value +} + +function Find-HeadRun { + param( + [Parameter(Mandatory = $true)] + [object]$Runs, + [Parameter(Mandatory = $true)] + [string]$HeadSha, + [Parameter(Mandatory = $true)] + [string]$ExpectedWorkflowId + ) + + $matching = @( + $Runs.workflow_runs | + Where-Object { $_.head_sha -eq $HeadSha } | + Where-Object { + $workflowId = '' + $path = '' + if ($_.PSObject.Properties.Name -contains 'workflow_id') { + $workflowId = [string]$_.workflow_id + } + if ($_.PSObject.Properties.Name -contains 'path') { + $path = [string]$_.path + } + + ($workflowId -eq $ExpectedWorkflowId) -or + ($path -like "$ExpectedWorkflowId@*") -or + [string]::IsNullOrWhiteSpace($workflowId) + } | + Sort-Object id -Descending + ) + + if ($matching.Count -eq 0) { + return $null + } + + return $matching[0] +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $workspaceRoot + +try { + $context = Resolve-GiteaRepositoryContext -RemoteName $Remote + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = $context.BaseUrl + } + if ([string]::IsNullOrWhiteSpace($Owner)) { + $Owner = $context.Owner + } + if ([string]::IsNullOrWhiteSpace($Repo)) { + $Repo = $context.Repo + } + + $ApiToken = Resolve-ApiToken -ResolvedBaseUrl $BaseUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken + $requiredRunnerLabel = Resolve-WorkflowRunnerLabel -WorkspaceRoot $workspaceRoot -WorkflowFileName $WorkflowId -ExplicitLabel $RequiredLabel + $apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo" + $workflow = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/workflows/$WorkflowId" -Token $ApiToken + if ($workflow.state -ne 'active') { + throw "Workflow $WorkflowId is not active; current state is $($workflow.state)." + } + + $branch = Invoke-GiteaApi -Method GET -Uri "$apiBase/branches/$Ref" -Token $ApiToken + $headSha = [string]$branch.commit.id + if ([string]::IsNullOrWhiteSpace($headSha)) { + throw "Could not resolve head SHA for ref $Ref." + } + + $runners = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runners" -Token $ApiToken + $matchingRunners = @( + $runners.runners | + Where-Object { $_.status -eq 'online' } | + Where-Object { + $labelNames = @($_.labels | ForEach-Object { $_.name }) + $labelNames -contains $requiredRunnerLabel + } + ) + if ($matchingRunners.Count -eq 0) { + throw "No online Gitea runner exposes required label '$requiredRunnerLabel'." + } + + $runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken + $run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId + $dispatched = $false + if ($null -eq $run -and $DispatchIfMissing) { + Invoke-GiteaApi -Method POST -Uri "$apiBase/actions/workflows/$WorkflowId/dispatches" -Token $ApiToken -Body @{ ref = $Ref } | Out-Null + $dispatched = $true + } + elseif ($null -eq $run) { + throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha). Pass -DispatchIfMissing to trigger one." + } + + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) + while ($Wait -and ($null -eq $run -or $run.status -ne 'completed')) { + if ([DateTimeOffset]::UtcNow -ge $deadline) { + $status = if ($null -eq $run) { 'missing' } else { [string]$run.status } + throw "Timed out waiting for $WorkflowId at $headSha; last status was $status." + } + + Start-Sleep -Seconds $PollSeconds + $runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken + $run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId + } + + if ($null -eq $run) { + throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha)." + } + + $jobs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs/$($run.id)/jobs" -Token $ApiToken + $jobSummaries = @( + $jobs.jobs | ForEach-Object { + [pscustomobject]@{ + id = $_.id + name = $_.name + status = $_.status + conclusion = Get-OptionalProperty -InputObject $_ -Name 'conclusion' + runner_name = Get-OptionalProperty -InputObject $_ -Name 'runner_name' + labels = @($_.labels) + } + } + ) + + $runConclusion = Get-OptionalProperty -InputObject $run -Name 'conclusion' + if ($run.status -eq 'completed' -and $runConclusion -ne 'success') { + throw "Gitea Actions run $($run.id) completed with conclusion '$runConclusion'." + } + + [pscustomobject]@{ + ok = ($run.status -eq 'completed' -and $runConclusion -eq 'success') + base_url = $BaseUrl + owner = $Owner + repo = $Repo + ref = $Ref + head_sha = $headSha + workflow_id = $WorkflowId + workflow_state = $workflow.state + required_runner_label = $requiredRunnerLabel + online_runner_count = $matchingRunners.Count + dispatched = $dispatched + run = [pscustomobject]@{ + id = $run.id + run_number = $run.run_number + event = $run.event + status = $run.status + conclusion = $runConclusion + head_sha = $run.head_sha + head_branch = $run.head_branch + display_title = $run.display_title + } + jobs = $jobSummaries + } | ConvertTo-Json -Depth 8 +} +finally { + Pop-Location +} diff --git a/scripts/check-jade-hardening.ps1 b/scripts/check-jade-hardening.ps1 new file mode 100644 index 0000000..0f77e29 --- /dev/null +++ b/scripts/check-jade-hardening.ps1 @@ -0,0 +1,321 @@ +[CmdletBinding()] +param( + [switch]$ExemptMiri, + [string]$MiriExemptionReason, + [switch]$ExemptFuzz, + [string]$FuzzExemptionReason, + [switch]$ExemptSanitizers, + [string]$SanitizersExemptionReason, + [switch]$ExemptNoPanic, + [string]$NoPanicExemptionReason, + [switch]$ExemptLoom, + [string]$LoomExemptionReason, + [string[]]$MiriPackages = @('common'), + [string[]]$SanitizerPackages = @('common'), + [ValidateSet('All', 'Miri', 'Fuzz', 'Sanitizers', 'NoPanic', 'Loom')] + [string]$Only = 'All', + [string]$NightlyToolchain = 'nightly', + [ValidateRange(1, 3600)] + [int]$FuzzSeconds = 10 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$ProgressPreference = 'SilentlyContinue' + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$fuzzManifest = Join-Path $workspaceRoot 'fuzz/Cargo.toml' +$fuzzArtifactRoot = Join-Path $workspaceRoot 'target/jade-fuzz/artifacts' +Push-Location -LiteralPath $workspaceRoot + +$script:StepResults = [System.Collections.Generic.List[object]]::new() + +function Add-StepResult { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [double]$DurationSeconds, + [Parameter(Mandatory = $true)] + [string]$Mode + ) + + $script:StepResults.Add([pscustomobject]@{ + Step = $Name + Seconds = [math]::Round($DurationSeconds, 2) + Mode = $Mode + }) +} + +function Test-NativeCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue) +} + +function Resolve-AsanRuntimeDirectory { + if (-not $IsWindows) { + return $null + } + + $targetLibdir = & cargo "+$NightlyToolchain" rustc --print target-libdir 2>$null + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($targetLibdir)) { + foreach ($name in @('clang_rt.asan_dynamic-x86_64.dll', 'libclang_rt.asan_dynamic-x86_64.dll')) { + $candidate = Join-Path $targetLibdir $name + if (Test-Path -LiteralPath $candidate) { + return (Split-Path -Parent $candidate) + } + } + } + + $roots = @( + ${env:ProgramFiles(x86)}, + $env:ProgramFiles, + (Join-Path $env:USERPROFILE 'scoop'), + (Join-Path $env:USERPROFILE '.rustup') + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and (Test-Path -LiteralPath $_) } + + foreach ($root in $roots) { + $candidate = Get-ChildItem -LiteralPath $root -Recurse -File -Filter 'clang_rt.asan_dynamic-x86_64.dll' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $candidate) { + return $candidate.DirectoryName + } + + $mingwCandidate = Get-ChildItem -LiteralPath $root -Recurse -File -Filter 'libclang_rt.asan_dynamic-x86_64.dll' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $mingwCandidate) { + return $mingwCandidate.DirectoryName + } + } + + return $null +} + +function Add-AsanRuntimeToPath { + if (-not $IsWindows) { + return + } + + $runtimeDirectory = Resolve-AsanRuntimeDirectory + if ([string]::IsNullOrWhiteSpace($runtimeDirectory)) { + Deny-MissingGate -Name 'Sanitizers' -Reason 'Windows ASan runtime DLL was not found in the active nightly target-libdir, PATH, or known compiler install roots' + } + + $pathParts = $env:PATH -split ';' + if ($pathParts -notcontains $runtimeDirectory) { + $env:PATH = "${runtimeDirectory};$env:PATH" + } +} + +function Confirm-Exemption { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [bool]$Requested, + [string]$Reason + ) + + if (-not $Requested) { + return $false + } + + if ([string]::IsNullOrWhiteSpace($Reason)) { + throw "${Name} exemption requires a non-empty reason." + } + + Write-Warning "${Name} explicitly exempted: ${Reason}" + Add-StepResult -Name $Name -DurationSeconds 0 -Mode 'exempted' + return $true +} + +function Invoke-TimedNative { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & $FilePath @ArgumentList + $exitCode = $LASTEXITCODE + $stopwatch.Stop() + Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'hardening' + + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Invoke-TimedNativeNoStdout { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & $FilePath @ArgumentList > $null + $exitCode = $LASTEXITCODE + $stopwatch.Stop() + Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'hardening' + + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Deny-MissingGate { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Reason + ) + + throw "${Name} gate is required by Jade and did not run: ${Reason}. First run 'just jade-tools' or 'pwsh -NoProfile -File .\scripts\install-jade-tooling.ps1' and install the missing platform prerequisite. Use -Exempt${Name} with a reason only for a reviewed, narrow exemption." +} + +function Invoke-MiriGate { + if (Confirm-Exemption -Name 'Miri' -Requested $ExemptMiri.IsPresent -Reason $MiriExemptionReason) { + return + } + if (-not (Test-NativeCommand -Name 'cargo-miri')) { + Deny-MissingGate -Name 'Miri' -Reason 'cargo-miri is not installed for the active toolchain' + return + } + + Invoke-TimedNative -Name 'cargo miri setup' -FilePath 'cargo' -ArgumentList @("+$NightlyToolchain", 'miri', 'setup') + foreach ($package in $MiriPackages) { + Invoke-TimedNative -Name "cargo miri ($package)" -FilePath 'cargo' -ArgumentList @( + "+$NightlyToolchain", + 'miri', + 'test', + '-p', + $package, + '--test', + 'miri_json_family' + ) + } +} + +function Invoke-FuzzGate { + if (Confirm-Exemption -Name 'Fuzz' -Requested $ExemptFuzz.IsPresent -Reason $FuzzExemptionReason) { + return + } + if (-not (Test-NativeCommand -Name 'cargo-fuzz')) { + Deny-MissingGate -Name 'Fuzz' -Reason 'cargo-fuzz is not installed' + return + } + if (-not (Test-Path -LiteralPath $fuzzManifest)) { + Deny-MissingGate -Name 'Fuzz' -Reason 'fuzz harness manifest is missing' + return + } + Invoke-TimedNativeNoStdout -Name 'cargo fuzz metadata lock' -FilePath 'cargo' -ArgumentList @( + "+$NightlyToolchain", + 'metadata', + '--manifest-path', + $fuzzManifest, + '--locked', + '--format-version', + '1' + ) + Add-AsanRuntimeToPath + New-Item -ItemType Directory -Force -Path $fuzzArtifactRoot | Out-Null + + Invoke-TimedNative -Name 'cargo fuzz json_family_decode' -FilePath 'cargo' -ArgumentList @( + "+$NightlyToolchain", + 'fuzz', + 'run', + 'json_family_decode', + '--', + "-artifact_prefix=$fuzzArtifactRoot/", + "-max_total_time=$FuzzSeconds" + ) +} + +function Invoke-SanitizerGate { + if (Confirm-Exemption -Name 'Sanitizers' -Requested $ExemptSanitizers.IsPresent -Reason $SanitizersExemptionReason) { + return + } + + $previousRustFlags = $env:RUSTFLAGS + try { + Add-AsanRuntimeToPath + $env:RUSTFLAGS = '-Zsanitizer=address' + foreach ($package in $SanitizerPackages) { + Invoke-TimedNative -Name "address sanitizer ($package)" -FilePath 'cargo' -ArgumentList @( + "+$NightlyToolchain", + 'test', + '-p', + $package, + '--test', + 'miri_json_family' + ) + } + } + finally { + $env:RUSTFLAGS = $previousRustFlags + } +} + +function Invoke-NoPanicGate { + if (Confirm-Exemption -Name 'NoPanic' -Requested $ExemptNoPanic.IsPresent -Reason $NoPanicExemptionReason) { + return + } + + Invoke-TimedNative -Name 'no-panic source scan' -FilePath 'pwsh' -ArgumentList @( + '-NoProfile', + '-File', + (Join-Path $PSScriptRoot 'check-no-panic.ps1') + ) +} + +function Invoke-LoomGate { + if (Confirm-Exemption -Name 'Loom' -Requested $ExemptLoom.IsPresent -Reason $LoomExemptionReason) { + return + } + + Invoke-TimedNative -Name 'loom runtime capture model' -FilePath 'cargo' -ArgumentList @( + 'test', + '-p', + 'runtimekit', + '--test', + 'loom_capture' + ) +} + +try { + switch ($Only) { + 'All' { + Invoke-NoPanicGate + Invoke-MiriGate + Invoke-LoomGate + Invoke-FuzzGate + Invoke-SanitizerGate + } + 'Miri' { Invoke-MiriGate } + 'Fuzz' { Invoke-FuzzGate } + 'Sanitizers' { Invoke-SanitizerGate } + 'NoPanic' { Invoke-NoPanicGate } + 'Loom' { Invoke-LoomGate } + } + + Write-Host '' + $script:StepResults | + Sort-Object Step | + Format-Table -AutoSize +} +finally { + Pop-Location +} diff --git a/scripts/check-jade.ps1 b/scripts/check-jade.ps1 new file mode 100644 index 0000000..44315e2 --- /dev/null +++ b/scripts/check-jade.ps1 @@ -0,0 +1,424 @@ +[CmdletBinding()] +param( + [switch]$SkipCoverage, + [switch]$ExemptMiri, + [string]$MiriExemptionReason, + [switch]$ExemptFuzz, + [string]$FuzzExemptionReason, + [switch]$ExemptSanitizers, + [string]$SanitizersExemptionReason, + [switch]$ExemptNoPanic, + [string]$NoPanicExemptionReason, + [switch]$ExemptLoom, + [string]$LoomExemptionReason, + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$VerificationConfiguration = 'ReleaseFast' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$ProgressPreference = 'SilentlyContinue' + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $workspaceRoot + +$script:StepResults = [System.Collections.Generic.List[object]]::new() + +function Resolve-CoverageTargetRoot { + $override = [Environment]::GetEnvironmentVariable('MERCURY_JADE_COVERAGE_ROOT') + if (-not [string]::IsNullOrWhiteSpace($override)) { + return $override + } + + $shortScratch = 'C:\tmp' + if (Test-Path -LiteralPath $shortScratch -PathType Container) { + return (Join-Path $shortScratch 'mtcov') + } + + return (Join-Path ([System.IO.Path]::GetTempPath()) 'mtcov') +} + +$coverageTargetRoot = Resolve-CoverageTargetRoot +$coverageTargetDir = $null + +function Add-StepResult { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [double]$DurationSeconds, + [Parameter(Mandatory = $true)] + [string]$Mode + ) + + $script:StepResults.Add([pscustomobject]@{ + Step = $Name + Seconds = [math]::Round($DurationSeconds, 2) + Mode = $Mode + }) +} + +function Resolve-BuildArguments { + param( + [Parameter(Mandatory = $true)] + [string]$Configuration + ) + + $arguments = @('build', '--workspace') + switch ($Configuration) { + 'Release' { + $arguments += '--release' + } + 'ReleaseFast' { + $arguments += @('--profile', 'release-fast') + } + 'ReleaseSize' { + $arguments += @('--profile', 'release-size') + } + } + + return $arguments +} + +function Add-ExemptionArguments { + param( + [Parameter(Mandatory = $true)] + [System.Collections.Generic.List[string]]$Arguments, + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [bool]$Requested, + [string]$Reason + ) + + if (-not $Requested) { + return + } + + if ([string]::IsNullOrWhiteSpace($Reason)) { + throw "${Name} exemption requires a non-empty reason." + } + + $Arguments.Add("-Exempt${Name}") + $Arguments.Add("-${Name}ExemptionReason") + $Arguments.Add($Reason) +} + +function Invoke-TimedNative { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & $FilePath @ArgumentList + $exitCode = $LASTEXITCODE + $stopwatch.Stop() + Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'sequential' + + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Invoke-TimedNativeWithEnvironment { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + [Parameter(Mandatory = $true)] + [hashtable]$Environment + ) + + $previous = @{} + foreach ($key in $Environment.Keys) { + $item = Get-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue + $previous[$key] = [pscustomobject]@{ + Exists = $null -ne $item + Value = if ($null -ne $item) { $item.Value } else { $null } + } + Set-Item -LiteralPath "Env:$key" -Value ([string]$Environment[$key]) + } + + try { + Invoke-TimedNative -Name $Name -FilePath $FilePath -ArgumentList $ArgumentList + } + finally { + foreach ($key in $previous.Keys) { + if ($previous[$key].Exists) { + Set-Item -LiteralPath "Env:$key" -Value $previous[$key].Value + } + else { + Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue + } + } + } +} + +function Invoke-TimedNativeToFile { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + [Parameter(Mandatory = $true)] + [string]$OutputPath + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & $FilePath @ArgumentList > $OutputPath + $exitCode = $LASTEXITCODE + $stopwatch.Stop() + Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'sequential' + + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Invoke-ParallelNativeSteps { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IEnumerable]$Steps + ) + + $jobScript = { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [int]$Order, + [string]$WorkingDirectory + ) + + $ProgressPreference = 'SilentlyContinue' + Set-Location -LiteralPath $WorkingDirectory + $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mercury-jade-" + [System.Guid]::NewGuid() + '.log') + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $exitCode = 0 + + try { + & $FilePath @ArgumentList *> $outputPath + $exitCode = $LASTEXITCODE + } + catch { + $_ | Out-String | Set-Content -LiteralPath $outputPath -Encoding utf8NoBOM + $exitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 } + } + finally { + $stopwatch.Stop() + } + + return [pscustomobject]@{ + Name = $Name + FilePath = $FilePath + Arguments = $ArgumentList -join ' ' + OutputPath = $outputPath + ExitCode = [int]$exitCode + Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 2) + Order = $Order + } + } + + $jobs = [System.Collections.Generic.List[object]]::new() + $index = 0 + foreach ($step in $Steps) { + $jobs.Add((Start-Job -ScriptBlock $jobScript -ArgumentList @( + [string]$step.Name, + [string]$step.FilePath, + [string[]]$step.ArgumentList, + $index, + $workspaceRoot + ))) + $index += 1 + } + + Wait-Job -Job $jobs | Out-Null + + $failures = [System.Collections.Generic.List[string]]::new() + foreach ($job in $jobs) { + $result = Receive-Job -Job $job + Remove-Job -Job $job -Force + + $output = '' + if (Test-Path -LiteralPath $result.OutputPath) { + $output = Get-Content -Raw -LiteralPath $result.OutputPath + Remove-Item -LiteralPath $result.OutputPath -Force -ErrorAction SilentlyContinue + } + + if (-not [string]::IsNullOrWhiteSpace($output)) { + Write-Host '' + Write-Host "[$($result.Name)]" + Write-Host $output.TrimEnd() + } + + Add-StepResult -Name $result.Name -DurationSeconds $result.Seconds -Mode 'parallel' + if ($result.ExitCode -ne 0) { + $failures.Add("$($result.Name) (exit $($result.ExitCode))") + } + } + + if ($failures.Count -gt 0) { + throw "Parallel step(s) failed: $($failures -join ', ')" + } +} + +try { + Invoke-TimedNative -Name 'cargo fmt' -FilePath 'cargo' -ArgumentList @('fmt', '--all', '--check') + Invoke-TimedNative -Name 'cargo check' -FilePath 'cargo' -ArgumentList @('check', '--all-targets', '--all-features') + + if ($SkipCoverage) { + Invoke-TimedNative -Name 'cargo nextest' -FilePath 'cargo' -ArgumentList @('nextest', 'run', '--all-features') + } else { + $coverageTargetDir = Join-Path $coverageTargetRoot ("mercury-jade-llvm-cov-" + [System.Guid]::NewGuid().ToString('N').Substring(0, 8)) + $coverageEnvironment = @{ + CARGO_INCREMENTAL = '0' + RUSTC_WRAPPER = '' + CARGO_TARGET_DIR = $coverageTargetDir + } + Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov clean' -FilePath 'cargo' -Environment $coverageEnvironment -ArgumentList @('llvm-cov', 'clean', '--workspace') + Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov nextest' -FilePath 'cargo' -Environment $coverageEnvironment -ArgumentList @( + 'llvm-cov', + '--jobs', + '1', + 'nextest', + '--all-features', + '--summary-only', + '--ignore-filename-regex', + 'cli\.rs$|main\.rs$|flow_opcode_table\.rs$', + '--fail-under-lines', + '80' + ) + } + + Invoke-TimedNative -Name 'cargo clippy' -FilePath 'cargo' -ArgumentList @( + 'clippy', + '--all-targets', + '--all-features', + '--', + '-D', + 'warnings', + '-W', + 'clippy::pedantic', + '-W', + 'clippy::nursery' + ) + Invoke-TimedNative -Name 'cargo udeps' -FilePath 'cargo' -ArgumentList @('+nightly', 'udeps', '--all-targets', '--all-features') + Invoke-TimedNative -Name 'cargo deny' -FilePath 'cargo' -ArgumentList @('deny', 'check') + + $fuzzManifest = Join-Path $workspaceRoot 'fuzz/Cargo.toml' + if (Test-Path -LiteralPath $fuzzManifest) { + Invoke-TimedNative -Name 'cargo fuzz udeps' -FilePath 'cargo' -ArgumentList @( + '+nightly', + 'udeps', + '--manifest-path', + $fuzzManifest, + '--all-targets', + '--all-features' + ) + $fuzzMetadataPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mercury-fuzz-metadata-" + [System.Guid]::NewGuid() + '.json') + try { + Invoke-TimedNativeToFile -Name 'cargo fuzz metadata' -FilePath 'cargo' -ArgumentList @( + 'metadata', + '--manifest-path', + $fuzzManifest, + '--locked', + '--format-version', + '1' + ) -OutputPath $fuzzMetadataPath + Invoke-TimedNative -Name 'cargo fuzz deny' -FilePath 'cargo' -ArgumentList @( + 'deny', + 'check', + '--config', + (Join-Path $workspaceRoot 'deny.toml'), + '--metadata-path', + $fuzzMetadataPath + ) + } + finally { + Remove-Item -LiteralPath $fuzzMetadataPath -Force -ErrorAction SilentlyContinue + } + } else { + throw "Fuzz workspace manifest is required by Jade and was not found: $fuzzManifest" + } + + $jadeHardeningArguments = [System.Collections.Generic.List[string]]::new() + $jadeHardeningArguments.Add('-NoProfile') + $jadeHardeningArguments.Add('-File') + $jadeHardeningArguments.Add((Join-Path $PSScriptRoot 'check-jade-hardening.ps1')) + Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Miri' -Requested $ExemptMiri.IsPresent -Reason $MiriExemptionReason + Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Fuzz' -Requested $ExemptFuzz.IsPresent -Reason $FuzzExemptionReason + Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Sanitizers' -Requested $ExemptSanitizers.IsPresent -Reason $SanitizersExemptionReason + Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'NoPanic' -Requested $ExemptNoPanic.IsPresent -Reason $NoPanicExemptionReason + Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Loom' -Requested $ExemptLoom.IsPresent -Reason $LoomExemptionReason + Invoke-TimedNative -Name 'jade hardening' -FilePath 'pwsh' -ArgumentList $jadeHardeningArguments.ToArray() + + Invoke-TimedNative -Name "cargo build ($VerificationConfiguration)" -FilePath 'cargo' -ArgumentList (Resolve-BuildArguments -Configuration $VerificationConfiguration) + + Invoke-ParallelNativeSteps -Steps @( + @{ + Name = 'check-powershell' + FilePath = 'pwsh' + ArgumentList = @('-NoProfile', '-File', (Join-Path $PSScriptRoot 'check-powershell.ps1')) + }, + @{ + Name = 'check-ai-prompt' + FilePath = 'pwsh' + ArgumentList = @( + '-NoProfile', + '-File', + (Join-Path $PSScriptRoot 'check-ai-prompt.ps1'), + '-Configuration', + $VerificationConfiguration, + '-SkipBuild' + ) + }, + @{ + Name = 'check-ai-skill' + FilePath = 'pwsh' + ArgumentList = @( + '-NoProfile', + '-File', + (Join-Path $PSScriptRoot 'check-ai-skill.ps1'), + '-Configuration', + $VerificationConfiguration, + '-SkipBuild' + ) + } + ) + + Invoke-TimedNative -Name 'check-ecosystem' -FilePath 'pwsh' -ArgumentList @( + '-NoProfile', + '-File', + (Join-Path $PSScriptRoot 'check-ecosystem.ps1'), + '-Configuration', + $VerificationConfiguration, + '-SkipBuild', + '-SkipPromptGeneration' + ) + + Write-Host '' + $script:StepResults | + Sort-Object Step | + Format-Table -AutoSize +} +finally { + if ($null -ne $coverageTargetDir -and (Test-Path -LiteralPath $coverageTargetDir)) { + $resolvedRoot = [System.IO.Path]::GetFullPath($coverageTargetRoot) + $resolvedTarget = [System.IO.Path]::GetFullPath($coverageTargetDir) + $rootPrefix = $resolvedRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($resolvedTarget.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $coverageTargetDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + Pop-Location +} diff --git a/scripts/check-no-panic.ps1 b/scripts/check-no-panic.ps1 new file mode 100644 index 0000000..070bdb2 --- /dev/null +++ b/scripts/check-no-panic.ps1 @@ -0,0 +1,131 @@ +[CmdletBinding()] +param( + [string[]]$Path = @( + 'crates/common/src/formats', + 'crates/toon/src', + 'crates/ison/src', + 'crates/isonl/src', + 'crates/zon/src', + 'crates/tonl/src' + ) +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $workspaceRoot + +function Get-BraceDelta { + param( + [AllowEmptyString()] + [Parameter(Mandatory = $true)] + [string]$Line + ) + + $open = ([regex]::Matches($Line, '\{')).Count + $close = ([regex]::Matches($Line, '\}')).Count + return $open - $close +} + +function Test-AllowedLine { + param( + [AllowEmptyString()] + [Parameter(Mandatory = $true)] + [string]$Line, + [AllowEmptyString()] + [string]$PreviousLine + ) + + $sameLineComment = $Line -match '(^|\s)//\s*jade:\s*allow-panic\s+because:\s+\S' + $previousLineComment = -not [string]::IsNullOrWhiteSpace($PreviousLine) -and + $PreviousLine -match '^\s*//\s*jade:\s*allow-panic\s+because:\s+\S' + + return $sameLineComment -or ( + -not [string]::IsNullOrWhiteSpace($PreviousLine) -and + $previousLineComment + ) +} + +$patterns = @( + 'panic!\s*\(', + '\.unwrap\s*\(', + '\.expect\s*\(' +) +$failures = [System.Collections.Generic.List[string]]::new() + +try { + foreach ($root in $Path) { + if (-not (Test-Path -LiteralPath $root)) { + throw "No-panic scan path does not exist: $root" + } + + $files = Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.rs' + foreach ($file in $files) { + if (($file.FullName -split '[\\/]') -contains 'tests') { + continue + } + + $lines = Get-Content -LiteralPath $file.FullName + $pendingTestModule = $false + $insideTestModule = $false + $braceDepth = 0 + $previousLine = '' + for ($index = 0; $index -lt $lines.Count; $index += 1) { + $line = [string]$lines[$index] + $trimmed = $line.TrimStart() + + if ($insideTestModule) { + $braceDepth += Get-BraceDelta -Line $line + if ($braceDepth -le 0) { + $insideTestModule = $false + $braceDepth = 0 + } + $previousLine = $line + continue + } + + if ($trimmed.StartsWith('#[cfg(test)]')) { + $pendingTestModule = $true + $previousLine = $line + continue + } + + if ($pendingTestModule -and $trimmed -match '^mod\s+tests\s*\{') { + $insideTestModule = $true + $braceDepth = Get-BraceDelta -Line $line + $pendingTestModule = $false + $previousLine = $line + continue + } + if (-not $trimmed.StartsWith('#[')) { + $pendingTestModule = $false + } + + if ($trimmed.StartsWith('//')) { + $previousLine = $line + continue + } + + foreach ($pattern in $patterns) { + if ($line -match $pattern -and -not (Test-AllowedLine -Line $line -PreviousLine $previousLine)) { + $relative = Resolve-Path -LiteralPath $file.FullName -Relative + $failures.Add("${relative}:$($index + 1): panic surface requires removal, fallible propagation, or a strict '// jade: allow-panic because: ' comment directly beside this call") + } + } + + $previousLine = $line + } + } + } + + if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Error $_ } + throw "No-panic gate found $($failures.Count) unapproved panic surface(s)." + } + + Write-Host "No-panic gate passed for $($Path.Count) root(s)." +} +finally { + Pop-Location +} diff --git a/scripts/check-powershell.ps1 b/scripts/check-powershell.ps1 new file mode 100644 index 0000000..c6e6242 --- /dev/null +++ b/scripts/check-powershell.ps1 @@ -0,0 +1,274 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-Condition { + param( + [Parameter(Mandatory = $true)] + [bool]$Condition, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Get-RequiredCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found on PATH: $Name" + } + + return $command.Source +} + +function Test-RequiredHeaderLine { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$ExpectedLine + ) + + $normalizedLines = @( + $Content -split "\r?\n" | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -ne '' } + ) + + return $ExpectedLine -in $normalizedLines +} + +function Assert-ScriptRegex { + param( + [Parameter(Mandatory = $true)] + [System.IO.FileInfo]$ScriptFile, + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$Pattern, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if (-not [regex]::IsMatch($Content, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) { + throw "$Message`: $($ScriptFile.Name)" + } +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$settingsPath = Join-Path $workspaceRoot 'PSScriptAnalyzerSettings.psd1' +$scriptFiles = @(Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1' -File | Sort-Object Name) +$requiredHeaderLines = @( + "`$ErrorActionPreference = 'Stop'", + 'Set-StrictMode -Version Latest' +) +$scriptContentByName = @{} + +Assert-Condition (Test-Path -LiteralPath $settingsPath) "Missing ScriptAnalyzer settings file: $settingsPath" +[void](Get-RequiredCommandPath -Name 'Invoke-ScriptAnalyzer') + +foreach ($scriptFile in $scriptFiles) { + $scriptContentByName[$scriptFile.Name] = @{ + File = $scriptFile + Content = Get-Content -Raw -LiteralPath $scriptFile.FullName + } +} + +foreach ($scriptFile in $scriptFiles) { + if ($scriptFile.Name -eq 'toolbox-commands.ps1') { + continue + } + + $content = $scriptContentByName[$scriptFile.Name].Content + foreach ($requiredLine in $requiredHeaderLines) { + Assert-Condition ( + Test-RequiredHeaderLine -Content $content -ExpectedLine $requiredLine + ) "PowerShell script must include required header line '$requiredLine': $($scriptFile.Name)" + } +} + +$validationMatrix = @( + @{ + Script = 'install-toolbox.ps1' + Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot' + Message = 'Installer must reject an empty InstallRoot argument during parameter binding' + }, + @{ + Script = 'install-package-toolbox.ps1' + Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot' + Message = 'Package installer must reject an empty InstallRoot argument during parameter binding' + }, + @{ + Script = 'uninstall-toolbox.ps1' + Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot' + Message = 'Uninstaller must reject an empty InstallRoot argument during parameter binding' + }, + @{ + Script = 'uninstall-package-toolbox.ps1' + Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot' + Message = 'Package uninstaller must reject an empty InstallRoot argument during parameter binding' + }, + @{ + Script = 'package-toolbox.ps1' + Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$OutputRoot' + Message = 'Packager must reject an empty OutputRoot argument during parameter binding' + }, + @{ + Script = 'package-toolbox.ps1' + Pattern = 'Assert-SingleDirectoryName[\s\S]*IsPathRooted[\s\S]*GetFileName[\s\S]*''\.\'', ''\.\.''' + Message = 'Packager must reject PackageName values that are rooted paths, nested paths, dot, or dot-dot' + }, + @{ + Script = 'package-toolbox.ps1' + Pattern = 'Resolved package root must stay inside OutputRoot' + Message = 'Packager must reject resolved package roots that escape OutputRoot' + }, + @{ + Script = 'package-toolbox.ps1' + Pattern = 'OutputRoot must not be a reparse point' + Message = 'Packager must reject reparse-point OutputRoot values before writing output' + }, + @{ + Script = 'package-toolbox.ps1' + Pattern = 'Get-RequiredCommandPath -Name ''rustc''[\s\S]*Failed to query rustc host target with command' + Message = 'Packager must resolve rustc explicitly and report the failing rustc probe command' + }, + @{ + Script = 'package-toolbox.ps1' + Pattern = 'Assert-ArchiveCreated[\s\S]*Compress-Archive did not create the expected archive[\s\S]*Compress-Archive created an empty archive' + Message = 'Packager must verify that Compress-Archive produced a non-empty archive' + }, + @{ + Script = 'install-toolbox.ps1' + Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot' + Message = 'Installer must reject reparse-point InstallRoot values before mutation' + }, + @{ + Script = 'install-package-toolbox.ps1' + Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot' + Message = 'Package installer must reject reparse-point InstallRoot values before mutation' + }, + @{ + Script = 'uninstall-toolbox.ps1' + Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot' + Message = 'Uninstaller must reject reparse-point InstallRoot values before removal' + }, + @{ + Script = 'uninstall-package-toolbox.ps1' + Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot' + Message = 'Package uninstaller must reject reparse-point InstallRoot values before removal' + }, + @{ + Script = 'install-package-toolbox.ps1' + Pattern = 'Checksum path escapes package root' + Message = 'Package installer must reject checksum entries that escape the package root' + }, + @{ + Script = 'install-package-toolbox.ps1' + Pattern = 'Duplicate checksum entry for package file' + Message = 'Package installer must reject duplicate checksum entries' + }, + @{ + Script = 'install-package-toolbox.ps1' + Pattern = 'Package contains file missing from SHA256SUMS' + Message = 'Package installer must reject package files missing from SHA256SUMS' + }, + @{ + Script = 'install-toolbox.ps1' + Pattern = 'Failed to create current install junction[\s\S]*NTFS-compatible install root[\s\S]*Current install junction was not created' + Message = 'Installer must explain Windows junction creation failures and verify the current link exists' + }, + @{ + Script = 'install-package-toolbox.ps1' + Pattern = 'Failed to create current install junction[\s\S]*NTFS-compatible install root[\s\S]*Current install junction was not created' + Message = 'Package installer must explain Windows junction creation failures and verify the current link exists' + }, + @{ + Script = 'toolbox-commands.ps1' + Pattern = 'Format-ToolboxNativeCommand[\s\S]*Command failed with exit code[\s\S]*Get-Location' + Message = 'Shared native command runner must include a quoted command line and cwd in failures' + }, + @{ + Script = 'setup-gitea-runner.ps1' + Pattern = 'HttpResponseMessage[\s\S]*ErrorDetails[\s\S]*ReadAsStringAsync[\s\S]*GetResponseStream' + Message = 'Gitea runner setup must handle PowerShell 7 and legacy HTTP error responses' + }, + @{ + Script = 'publish-gitea-release.ps1' + Pattern = 'HttpResponseMessage[\s\S]*ErrorDetails[\s\S]*ReadAsStringAsync[\s\S]*GetResponseStream' + Message = 'Gitea release publisher must handle PowerShell 7 and legacy HTTP error responses' + }, + @{ + Script = 'invoke-gitea-git.ps1' + Pattern = 'credential\.helper=[\s\S]*http\.sslBackend=openssl[\s\S]*http\.extraHeader=Authorization: Basic' + Message = 'Gitea Git helper must use transient Basic auth while disabling credential-manager lookup' + }, + @{ + Script = 'invoke-gitea-git.ps1' + Pattern = 'GIT_TERMINAL_PROMPT[\s\S]*GIT_TRACE_CURL[\s\S]*GIT_CURL_VERBOSE' + Message = 'Gitea Git helper must disable prompts and curl tracing while the transient header is in scope' + }, + @{ + Script = 'invoke-gitea-git.ps1' + Pattern = 'Write-GitOutput[\s\S]*http\\\.c:\\d\+[\s\S]*2>&1' + Message = 'Gitea Git helper must filter libcurl trace output from captured git stderr' + }, + @{ + Script = 'invoke-gitea-git.ps1' + Pattern = 'InsecureSkipTlsVerify[\s\S]*http\.sslVerify=false' + Message = 'Gitea Git helper may skip TLS verification only through an explicit opt-in switch' + }, + @{ + Script = 'invoke-gitea-git.ps1' + Pattern = 'deliberately does not call Git Credential Manager' + Message = 'Gitea Git helper must fail closed instead of falling back to Git Credential Manager' + }, + @{ + Script = 'check-gitea-ci.ps1' + Pattern = 'actions/workflows/\$WorkflowId[\s\S]*actions/runners[\s\S]*actions/runs' + Message = 'Gitea CI checker must inspect workflow, runner, and run state through the Gitea API' + }, + @{ + Script = 'check-gitea-ci.ps1' + Pattern = 'DispatchIfMissing[\s\S]*dispatches[\s\S]*Wait' + Message = 'Gitea CI checker must support dispatch fallback and wait for a conclusive run' + }, + @{ + Script = 'check-ai-prompt.ps1' + Pattern = 'Invoke-GeneratorCheck[\s\S]*Invoke-GeneratorWrite[\s\S]*Invoke-GeneratorCheck' + Message = 'AI prompt checker must regenerate once and recheck before failing generated asset drift' + }, + @{ + Script = 'check-ai-skill.ps1' + Pattern = 'Invoke-GeneratorCheck[\s\S]*Invoke-GeneratorWrite[\s\S]*Invoke-GeneratorCheck' + Message = 'AI skill checker must regenerate once and recheck before failing generated asset drift' + } +) + +foreach ($validationCase in $validationMatrix) { + Assert-Condition ($scriptContentByName.ContainsKey($validationCase.Script)) "Validation matrix references missing script: $($validationCase.Script)" + $scriptRecord = $scriptContentByName[$validationCase.Script] + Assert-ScriptRegex -ScriptFile $scriptRecord.File -Content $scriptRecord.Content -Pattern $validationCase.Pattern -Message $validationCase.Message +} + +$diagnostics = @(Invoke-ScriptAnalyzer -Path $PSScriptRoot -Recurse -Settings $settingsPath) +if ($diagnostics.Count -gt 0) { + $diagnostics | + Select-Object RuleName, Severity, ScriptName, Line, Message | + Format-Table -AutoSize | + Out-String | + Write-Output + throw "PSScriptAnalyzer reported $($diagnostics.Count) diagnostic(s)." +} + +Write-Host "PowerShell gate passed for $($scriptFiles.Count) script file(s)" diff --git a/scripts/check-release-version.ps1 b/scripts/check-release-version.ps1 new file mode 100644 index 0000000..534a762 --- /dev/null +++ b/scripts/check-release-version.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Tag +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-SemVer { + param( + [Parameter(Mandatory = $true)] + [string]$Version + ) + + $match = [regex]::Match( + $Version, + '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' + ) + if (-not $match.Success) { + return $false + } + + $preRelease = $match.Groups[4].Value + return @($preRelease -split '\.' | Where-Object { + $_ -match '^0\d+$' + }).Count -eq 0 +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$cargoToml = Get-Content -Raw -LiteralPath (Join-Path $workspaceRoot 'Cargo.toml') +$match = [regex]::Match($cargoToml, '(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"') +if (-not $match.Success) { + throw 'Could not resolve [workspace.package].version from Cargo.toml.' +} + +$version = $match.Groups[1].Value +if (-not (Test-SemVer -Version $version)) { + throw "Workspace version is not valid SemVer: $version" +} + +if ($Tag -ne "v$version") { + throw "Tag $Tag does not match workspace version v$version." +} + +Write-Host "SemVer release tag verified: $Tag" diff --git a/scripts/generate-ai-prompt.ps1 b/scripts/generate-ai-prompt.ps1 new file mode 100644 index 0000000..808ad79 --- /dev/null +++ b/scripts/generate-ai-prompt.ps1 @@ -0,0 +1,341 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [string]$OutputPath = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs') 'ai\mercury-toolbox-ai-prompt.md'), + [string]$NotesPath = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs') 'ai\toolbox-ai-prompt-notes.json'), + [switch]$Check, + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-ToolHelpSections { + param( + [Parameter(Mandatory = $true)] + [string]$HelpText + ) + + $lines = $HelpText -split "`r?`n" + $summary = ($lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim() + $usage = [System.Collections.Generic.List[string]]::new() + $examples = [System.Collections.Generic.List[string]]::new() + $section = '' + + foreach ($line in $lines) { + $trimmed = $line.Trim() + if ($trimmed -ceq 'Usage:') { + $section = 'usage' + continue + } + + if ($trimmed -ceq 'Examples:') { + $section = 'examples' + continue + } + + if ([string]::IsNullOrWhiteSpace($trimmed)) { + continue + } + + if (Test-HelpSectionHeader -Line $trimmed) { + $section = '' + continue + } + + switch ($section) { + 'usage' { $usage.Add($trimmed) } + 'examples' { $examples.Add($trimmed) } + } + } + + return @{ + Summary = $summary + Usage = @($usage) + Examples = @($examples) + } +} + +function Test-HelpSectionHeader { + param( + [Parameter(Mandatory = $true)] + [string]$Line + ) + + return $Line -cmatch '^[A-Z][A-Za-z0-9 /_-]*:$' +} + +function Append-Section { + param( + [Parameter(Mandatory = $true)] + [System.Text.StringBuilder]$Builder, + [Parameter(Mandatory = $true)] + [string]$Heading, + [Parameter(Mandatory = $true)] + [string[]]$Items + ) + + [void]$Builder.AppendLine("## $Heading") + [void]$Builder.AppendLine() + foreach ($item in $Items) { + [void]$Builder.AppendLine("- $item") + } + [void]$Builder.AppendLine() +} + +function Select-PromptExamples { + param( + [Parameter(Mandatory = $true)] + [string[]]$Examples, + [int]$Limit = 1 + ) + + $selected = [System.Collections.Generic.List[string]]::new() + if ($Examples.Count -eq 0) { + return @() + } + + foreach ($example in $Examples) { + if (-not $example.Contains('Get-Content') -and -not $example.Contains('fixtures')) { + $selected.Add($example) + break + } + } + if ($selected.Count -eq 0) { + foreach ($example in $Examples) { + if (-not $example.Contains('Get-Content')) { + $selected.Add($example) + break + } + } + } + if ($selected.Count -eq 0) { + $selected.Add($Examples[0]) + } + + foreach ($example in $Examples | Select-Object -Skip 1) { + if ( + $selected.Count -lt $Limit -and + $example -cne $selected[0] -and + -not $example.Contains('Get-Content') -and + -not $example.Contains('fixtures') -and + ($example.Contains('|') -or $example.Contains('ConvertFrom-Json')) + ) { + $selected.Add($example) + } + } + + foreach ($example in $Examples | Select-Object -Skip 1) { + if ($selected.Count -ge $Limit) { + break + } + + if ( + -not $selected.Contains($example) -and + -not $example.Contains('Get-Content') -and + -not $example.Contains('fixtures') + ) { + $selected.Add($example) + } + } + + foreach ($example in $Examples | Select-Object -Skip 1) { + if ($selected.Count -ge $Limit) { + break + } + + if (-not $selected.Contains($example)) { + $selected.Add($example) + } + } + + return @($selected) +} + +function Normalize-GeneratedExample { + param( + [Parameter(Mandatory = $true)] + [string]$Example + ) + + return ($Example -replace '(?i)(?:[A-Z]:)?[^''"\s|]*fixtures(?:[\\/][^''"\s|]+)+', '') +} + +function Test-ByteSequenceEqual { + param( + [Parameter(Mandatory = $true)] + [byte[]]$Left, + [Parameter(Mandatory = $true)] + [byte[]]$Right + ) + + if ($Left.Length -ne $Right.Length) { + return $false + } + + for ($index = 0; $index -lt $Left.Length; $index += 1) { + if ($Left[$index] -ne $Right[$index]) { + return $false + } + } + + return $true +} + +function ConvertTo-LfText { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + return $Content.Replace("`r`n", "`n").Replace("`r", "`n") +} + +function Write-Utf8FileWithRetry { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content, + [int]$Attempts = 20, + [int]$DelayMilliseconds = 100 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, (ConvertTo-LfText -Content $Content), $encoding) + return + } + catch { + if ($attempt -eq $Attempts) { + throw + } + Start-Sleep -Milliseconds $DelayMilliseconds + } + } +} + +function Select-PromptUsage { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ToolNotes, + [Parameter(Mandatory = $true)] + $Help, + [Parameter(Mandatory = $true)] + [string]$CommandName + ) + + if ($ToolNotes.ContainsKey('prompt_usage') -and -not [string]::IsNullOrWhiteSpace([string]$ToolNotes.prompt_usage)) { + return [string]$ToolNotes.prompt_usage + } + if ($Help.Usage.Count -gt 0) { + return (([string[]]$Help.Usage) -join ' | ') + } + return "$CommandName --help" +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$profileName = Resolve-ToolboxProfileName -Configuration $Configuration +$binaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $profileName) +$dependencyRoot = Join-Path $binaryRoot 'deps' +if (Test-Path -LiteralPath $dependencyRoot -PathType Container) { + $env:PATH = "$dependencyRoot$([System.IO.Path]::PathSeparator)$env:PATH" +} +$cargoPath = 'cargo' +$notes = Get-Content -Raw -LiteralPath $NotesPath | ConvertFrom-Json -AsHashtable + +$missingBinary = $false +foreach ($commandName in Get-ToolboxCommandNames) { + if (-not (Test-Path -LiteralPath (Join-Path $binaryRoot "$commandName.exe"))) { + $missingBinary = $true + break + } +} + +if ($missingBinary) { + if ($SkipBuild) { + throw "Required toolbox binaries are missing under $binaryRoot. Build profile $Configuration before running generate-ai-prompt.ps1 -SkipBuild." + } + Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration +} + +$builder = [System.Text.StringBuilder]::new() +[void]$builder.AppendLine("# $($notes.title)") +[void]$builder.AppendLine(('Available tools: {0}' -f ((Get-ToolboxCommandNames) -join ', '))) +[void]$builder.AppendLine(('Rules: {0}' -f (([string[]]$notes.selection_rules) -join ' '))) + +foreach ($commandName in Get-ToolboxCommandNames) { + $binaryPath = Join-Path $binaryRoot "$commandName.exe" + $toolNotes = $notes.tools[$commandName] + if ($null -eq $toolNotes) { + throw "Missing AI prompt notes for tool '$commandName'" + } + + $helpText = (& $binaryPath '--help' | Out-String).TrimEnd() + $help = Get-ToolHelpSections -HelpText $helpText + $usageLine = Select-PromptUsage -ToolNotes $toolNotes -Help $help -CommandName $commandName + $examples = @(Select-PromptExamples -Examples ([string[]]$help.Examples)) + $exampleLine = if ($toolNotes.ContainsKey('prompt_example') -and -not [string]::IsNullOrWhiteSpace([string]$toolNotes.prompt_example)) { + [string]$toolNotes.prompt_example + } elseif ($examples.Count -gt 0) { + $examples[0] + } else { + "$commandName --help" + } + $exampleLine = Normalize-GeneratedExample -Example $exampleLine + $guided = $toolNotes.guided_triage + if ($null -eq $guided) { + throw "Missing guided_triage notes for tool '$commandName'" + } + $nextActions = ([string[]]$guided.next_actions) -join ' | ' + + [void]$builder.AppendLine(('{0}: {1} Better than: {2} Usage: `{3}` Example: `{4}` Guided: answer={5} trust={6} next={7}' -f $commandName, $toolNotes.use_when, $toolNotes.why, $usageLine, $exampleLine, $guided.answer, $guided.trust, $nextActions)) +} + +$content = $builder.ToString() + +if ($Check) { + if (-not (Test-Path -LiteralPath $OutputPath)) { + throw "Generated AI prompt is missing: $OutputPath" + } + + $temporaryOutputPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + try { + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($temporaryOutputPath, (ConvertTo-LfText -Content $content), $encoding) + $existingBytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $OutputPath)) + $generatedBytes = [System.IO.File]::ReadAllBytes($temporaryOutputPath) + if (-not (Test-ByteSequenceEqual -Left $existingBytes -Right $generatedBytes)) { + throw "Generated AI prompt is out of date. Run pwsh -NoProfile -File .\scripts\generate-ai-prompt.ps1" + } + } + finally { + Remove-Item -LiteralPath $temporaryOutputPath -Force -ErrorAction SilentlyContinue + } + + Write-Host "AI prompt is up to date: $OutputPath" + return +} + +$outputDirectory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +Write-Utf8FileWithRetry -Path $OutputPath -Content $content +Write-Host "Generated AI prompt: $OutputPath" diff --git a/scripts/generate-ai-skill.ps1 b/scripts/generate-ai-skill.ps1 new file mode 100644 index 0000000..8ff325e --- /dev/null +++ b/scripts/generate-ai-skill.ps1 @@ -0,0 +1,468 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [string]$SkillRoot = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'skills') 'mercury-toolbox'), + [string]$NotesPath = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs') 'ai\toolbox-ai-prompt-notes.json'), + [switch]$Check, + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-ToolHelpSections { + param( + [Parameter(Mandatory = $true)] + [string]$HelpText + ) + + $lines = $HelpText -split "`r?`n" + $summary = ($lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim() + $usage = [System.Collections.Generic.List[string]]::new() + $examples = [System.Collections.Generic.List[string]]::new() + $section = '' + + foreach ($line in $lines) { + $trimmed = $line.Trim() + if ($trimmed -ceq 'Usage:') { + $section = 'usage' + continue + } + + if ($trimmed -ceq 'Examples:') { + $section = 'examples' + continue + } + + if ([string]::IsNullOrWhiteSpace($trimmed)) { + continue + } + + if (Test-HelpSectionHeader -Line $trimmed) { + $section = '' + continue + } + + switch ($section) { + 'usage' { $usage.Add($trimmed) } + 'examples' { $examples.Add($trimmed) } + } + } + + return @{ + Summary = $summary + Usage = @($usage) + Examples = @($examples) + } +} + +function Test-HelpSectionHeader { + param( + [Parameter(Mandatory = $true)] + [string]$Line + ) + + return $Line -cmatch '^[A-Z][A-Za-z0-9 /_-]*:$' +} + +function Select-CatalogExample { + param( + [Parameter(Mandatory = $true)] + [string[]]$Examples + ) + + if ($Examples.Count -eq 0) { + return $null + } + + foreach ($example in $Examples) { + if ( + -not $example.Contains('Get-Content') -and + -not $example.Contains('fixtures') -and + ($example.Contains('|') -or $example.Contains('ConvertFrom-Json')) + ) { + return $example + } + } + + foreach ($example in $Examples) { + if (-not $example.Contains('Get-Content') -and -not $example.Contains('fixtures')) { + return $example + } + } + + foreach ($example in $Examples) { + if (-not $example.Contains('Get-Content')) { + return $example + } + } + + return $Examples[0] +} + +function Normalize-GeneratedExample { + param( + [Parameter(Mandatory = $true)] + [string]$Example + ) + + return ($Example -replace '(?i)(?:[A-Z]:)?[^''"\s|]*fixtures(?:[\\/][^''"\s|]+)+', '') +} + +function Test-ByteSequenceEqual { + param( + [Parameter(Mandatory = $true)] + [byte[]]$Left, + [Parameter(Mandatory = $true)] + [byte[]]$Right + ) + + if ($Left.Length -ne $Right.Length) { + return $false + } + + for ($index = 0; $index -lt $Left.Length; $index += 1) { + if ($Left[$index] -ne $Right[$index]) { + return $false + } + } + + return $true +} + +function Get-CategorySpecs { + return @( + @{ + Title = 'Code and Context' + Commands = @('fileprobe', 'outline', 'codeshape', 'refs', 'snip', 'defsnip', 'ctxpack', 'chunkcat', 'hitsnip', 'diagpick', 'gitshape', 'reposhape', 'dotnetshape') + }, + @{ + Title = 'Data and Config' + Commands = @('cjson', 'ison', 'isonl', 'zon', 'tonl', 'jsonlgrep', 'jsonshape', 'mhash', 'toon', 'csvshape', 'sqliteshape', 'sqlshape', 'config') + }, + @{ + Title = 'Logs, Process, and Waiting' + Commands = @('logshape', 'envdiff', 'proctree', 'sysshape', 'runprobe', 'await', 'argv', 'recent', 'pathshadow') + }, + @{ + Title = 'Network and Locks' + Commands = @('portping', 'portunlock', 'msudo', 'unlock') + }, + @{ + Title = 'Managed, Unity, and Binary Inspection' + Commands = @('asmtype', 'asmmember', 'asmref', 'asmapi', 'asmflow', 'llvmobjdump', 'llvmreadobj', 'llvmnm', 'peexports', 'peimports', 'pecalls', 'pesig', 'pestrrefs', 'drvshape', 'ioctlscan', 'unityasset', 'unityprobe', 'unitydiag', 'binmeta', 'stringscan') + } + ) +} + +function Build-SkillContent { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Notes + ) + + $builder = [System.Text.StringBuilder]::new() + [void]$builder.AppendLine('---') + [void]$builder.AppendLine('name: mercury-toolbox') + [void]$builder.AppendLine("description: 'Use whenever Mercury Toolbox binaries are present and the task is local terminal inspection or triage: code and log reading, structured-data shaping, repo or runtime diagnosis, process, port, or lock troubleshooting, managed assembly or Unity analysis, or when the agent would otherwise reach for Get-Content, cat, tree, grep, findstr, netstat, tasklist, or ad-hoc PowerShell glue.'") + [void]$builder.AppendLine('---') + [void]$builder.AppendLine() + [void]$builder.AppendLine('# Mercury Toolbox') + [void]$builder.AppendLine() + [void]$builder.AppendLine('Use this skill immediately when Mercury Toolbox binaries are present and the job is local terminal inspection, shaping, or triage. Read it first, then route through Mercury before falling back to raw dumps, legacy builtins, or ad-hoc shell glue.') + [void]$builder.AppendLine() + [void]$builder.AppendLine('## First Choice Rules') + [void]$builder.AppendLine() + [void]$builder.AppendLine('- Prefer Mercury readers over `Get-Content`, `cat`, `tree`, `tasklist`, `netstat`, or whole-file dumps.') + [void]$builder.AppendLine('- For source and logs, usually start with `fileprobe`, `outline`, `snip`, or `chunkcat`, then move to `hitsnip`, `defsnip`, `refs`, `codeshape`, or `ctxpack` as needed.') + [void]$builder.AppendLine('- Prefer compact text by default, switch to `--json` when the next step parses the result, and switch to `--toon` or `--format toon` when the next consumer is an AI/model.') + [void]$builder.AppendLine('- Pipe external JSON into `toon` only when the producer is not a Mercury tool; TOON auto-detects JSON and emits dense TOON by default.') + [void]$builder.AppendLine('- Prefer stdin and pipelines over reopening the same file repeatedly.') + [void]$builder.AppendLine('- Pair Mercury with modern CLI companions instead of legacy builtins.') + [void]$builder.AppendLine('- Treat `msudo` as the top-level high-risk toolbox command: start with `msudo status --json` or `--help` before considering any privileged launch.') + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Modern Pairings') + [void]$builder.AppendLine() + [void]$builder.AppendLine('- Search and files: `rg`, `fd`, and optionally `fzf` or `PSFzf` for interactive narrowing.') + [void]$builder.AppendLine('- Viewing and editing: `bat`, `hexyl`, and `nvim` for bounded human reads instead of raw file dumps.') + [void]$builder.AppendLine('- Data and text: `jq`, `yq`, and `sd` for structured queries and small safe rewrites.') + [void]$builder.AppendLine('- Git and navigation: `git`, `lazygit`, `delta`, and `zoxide` when they answer the question faster than shell glue.') + [void]$builder.AppendLine('- Stats and diagnostics: `tokei`, `eza`, `procs`, `dust`, and `hyperfine` for fast shape or performance checks.') + [void]$builder.AppendLine('- Utilities: `xh`, `ouch`, and `tealdeer` for quick HTTP work, archives, and terse help.') + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Modern Default Replacements') + [void]$builder.AppendLine() + [void]$builder.AppendLine('- Use `rg` over recursive `grep` or broad `Select-String`.') + [void]$builder.AppendLine('- Use `fd` over `Get-ChildItem -Recurse` when you only need file discovery.') + [void]$builder.AppendLine('- Use `bat` or Mercury readers over raw `Get-Content` for bounded viewing.') + [void]$builder.AppendLine('- Use `jq` or `yq` over manual JSON or YAML parsing.') + [void]$builder.AppendLine('- Use `sd` for simple regex replacements and `xh` over `curl` for quick HTTP checks.') + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Fast Routing') + [void]$builder.AppendLine() + foreach ($category in Get-CategorySpecs) { + $toolList = ($category.Commands | ForEach-Object { ('`{0}`' -f $_) }) -join ', ' + [void]$builder.AppendLine("- $($category.Title): $toolList") + } + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Job Routing') + [void]$builder.AppendLine() + [void]$builder.AppendLine('- Unknown file: start with `fileprobe `, then choose `binmeta`, `stringscan`, `snip`, or `chunkcat` from the detected shape.') + [void]$builder.AppendLine('- Repository map: start with `reposhape . --json`, then use `codeshape`, `gitshape`, `dotnetshape`, `sqlshape`, or `ctxpack`.') + [void]$builder.AppendLine('- Symbol or source context: start with `defsnip .` or `refs .`, then pack evidence with `hitsnip` or `ctxpack`.') + [void]$builder.AppendLine('- Logs and failures: start with `diagpick `, then use `logshape`, `snip --match`, or `runprobe`.') + [void]$builder.AppendLine('- Managed or Unity DLL: start with `asmref diagnose --resolve-dir `, then use `asmtype`, `asmmember`, `asmflow`, or `asmapi diff`.') + [void]$builder.AppendLine('- Windows EXE/DLL: start with `peimports `; every catalog entry includes guided answer/trust/next actions, and PE deep tools also expose runtime `report_quality` and `next_actions` in `--json`/`--toon`.') + [void]$builder.AppendLine('- Windows driver: start with `drvshape `, then follow `ioctlscan`, `peimports --category device_io`, `pecalls --category device_io`, or `pestrrefs`.') + [void]$builder.AppendLine() + [void]$builder.AppendLine('## Read Next') + [void]$builder.AppendLine() + [void]$builder.AppendLine('- Read `references/command-catalog.md` beside this skill when you need command-by-command routing, usage, and examples.') + [void]$builder.AppendLine('- In packaged installs, the skill lives under `share\\mercury-toolbox\\skills\\mercury-toolbox` and the generated prompt lives under `share\\mercury-toolbox\\docs\\ai`.') + + return $builder.ToString() +} + +function Convert-ToonExample { + param( + [Parameter(Mandatory = $true)] + [string]$Example, + [Parameter(Mandatory = $true)] + [string]$CommandName, + [bool]$SupportsJson + ) + + if (-not $SupportsJson -or $CommandName -eq 'toon') { + return '' + } + + $prefix = ($Example -split '\|\s*ConvertFrom-Json', 2)[0].TrimEnd() + if ($prefix -match '(?i)\b--format\s+toon\b') { + return ([regex]::Replace($prefix, '(?i)\s+--format\s+toon\b', ' --toon')).TrimEnd() + } + if ($prefix.Contains('--json')) { + $lastJson = $prefix.LastIndexOf('--json') + return ($prefix.Remove($lastJson, 6).Insert($lastJson, '--toon')).TrimEnd() + } + else { + $escapedCommand = [regex]::Escape($CommandName) + if ($CommandName -eq 'tonl') { + $withToon = [regex]::Replace($prefix, "(^|\|\s*)($escapedCommand\s+\S+)(\s|$)", '$1$2 --toon$3', 1) + if ($withToon -cne $prefix) { + return $withToon + } + } + $withToon = [regex]::Replace($prefix, "(^|\|\s*)($escapedCommand)(\s|$)", '$1$2 --toon$3', 1) + if ($withToon -ceq $prefix) { + return '' + } + return $withToon + } +} + +function Build-CatalogContent { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Notes, + [Parameter(Mandatory = $true)] + [string]$BinaryRoot + ) + + $builder = [System.Text.StringBuilder]::new() + [void]$builder.AppendLine('# Mercury Toolbox Command Catalog') + [void]$builder.AppendLine('Use this catalog when choosing among nearby Mercury commands. Keep output compact, prefer `--json` for machine handoff, prefer `--toon` for model-facing structured output, and use `toon` for external JSON producers.') + + foreach ($category in Get-CategorySpecs) { + [void]$builder.AppendLine("## $($category.Title)") + foreach ($commandName in $category.Commands) { + $toolNotes = $Notes.tools[$commandName] + if ($null -eq $toolNotes) { + throw "Missing AI skill notes for tool '$commandName'" + } + + $helpText = (& (Join-Path $BinaryRoot "$commandName.exe") '--help' | Out-String).TrimEnd() + $help = Get-ToolHelpSections -HelpText $helpText + $usageLine = if ($toolNotes.ContainsKey('prompt_usage') -and -not [string]::IsNullOrWhiteSpace([string]$toolNotes.prompt_usage)) { + [string]$toolNotes.prompt_usage + } elseif ($help.Usage.Count -gt 0) { + (([string[]]$help.Usage) -join ' | ') + } else { + "$commandName --help" + } + $exampleLine = if ($toolNotes.ContainsKey('prompt_example') -and -not [string]::IsNullOrWhiteSpace([string]$toolNotes.prompt_example)) { + [string]$toolNotes.prompt_example + } else { + Select-CatalogExample -Examples ([string[]]$help.Examples) + } + if ([string]::IsNullOrWhiteSpace($exampleLine)) { + $exampleLine = "$commandName --help" + } else { + $exampleLine = Normalize-GeneratedExample -Example $exampleLine + } + $supportsJson = $helpText.Contains('--json') + $guided = $toolNotes.guided_triage + if ($null -eq $guided) { + throw "Missing guided_triage notes for tool '$commandName'" + } + $nextActions = ([string[]]$guided.next_actions) -join ' | ' + + [void]$builder.AppendLine(('### `{0}`' -f $commandName)) + [void]$builder.AppendLine(('Use: {0} Better than: {1}' -f $toolNotes.use_when, $toolNotes.why)) + [void]$builder.AppendLine(('Usage: `{0}`' -f $usageLine)) + [void]$builder.AppendLine(('Example: `{0}`' -f $exampleLine)) + [void]$builder.AppendLine(('Guided answer: {0}' -f $guided.answer)) + [void]$builder.AppendLine(('Trust basis: {0}' -f $guided.trust)) + [void]$builder.AppendLine(('Next actions: {0}' -f $nextActions)) + $toonExample = Convert-ToonExample -Example $exampleLine -CommandName $commandName -SupportsJson $supportsJson + if (-not [string]::IsNullOrWhiteSpace($toonExample)) { + [void]$builder.AppendLine(('TOON example: `{0}`' -f $toonExample)) + } + } + } + + return $builder.ToString() +} + +function Build-OpenAiYamlContent { + return @' +interface: + display_name: "Mercury Toolbox" + short_description: "Route proactive local CLI triage through Mercury Toolbox" + icon_small: "./assets/logo.png" + icon_large: "./assets/logo.png" + brand_color: "#35C2FF" + default_prompt: "Use $mercury-toolbox first for local code, log, data, repo, process, port, lock, managed, or Unity triage, and pair it with modern CLI tools before falling back to raw dumps or legacy builtins." + +policy: + allow_implicit_invocation: true +'@ +} + +function Assert-GeneratedFileMatches { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Generated $Label is missing: $Path" + } + + $temporaryPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + try { + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($temporaryPath, (ConvertTo-LfText -Content $Content), $encoding) + $existingBytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path)) + $generatedBytes = [System.IO.File]::ReadAllBytes($temporaryPath) + if (-not (Test-ByteSequenceEqual -Left $existingBytes -Right $generatedBytes)) { + throw "Generated $Label is out of date. Run pwsh -NoProfile -File .\scripts\generate-ai-skill.ps1" + } + } + finally { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } +} + +function ConvertTo-LfText { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + return $Content -replace "`r`n", "`n" -replace "`r", "`n" +} + +function Write-Utf8FileWithRetry { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content, + [int]$Attempts = 20, + [int]$DelayMilliseconds = 100 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, (ConvertTo-LfText -Content $Content), $encoding) + return + } + catch { + if ($attempt -eq $Attempts) { + throw + } + Start-Sleep -Milliseconds $DelayMilliseconds + } + } +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$profileName = Resolve-ToolboxProfileName -Configuration $Configuration +$binaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $profileName) +$dependencyRoot = Join-Path $binaryRoot 'deps' +if (Test-Path -LiteralPath $dependencyRoot -PathType Container) { + $env:PATH = "$dependencyRoot$([System.IO.Path]::PathSeparator)$env:PATH" +} +$cargoPath = 'cargo' +$notes = Get-Content -Raw -LiteralPath $NotesPath | ConvertFrom-Json -AsHashtable + +$missingBinary = $false +foreach ($commandName in Get-ToolboxCommandNames) { + if (-not (Test-Path -LiteralPath (Join-Path $binaryRoot "$commandName.exe"))) { + $missingBinary = $true + break + } +} + +if ($missingBinary) { + if ($SkipBuild) { + throw "Required toolbox binaries are missing under $binaryRoot. Build profile $Configuration before running generate-ai-skill.ps1 -SkipBuild." + } + Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration +} + +$referencesRoot = Join-Path $SkillRoot 'references' +$agentsRoot = Join-Path $SkillRoot 'agents' +$skillPath = Join-Path $SkillRoot 'SKILL.md' +$catalogPath = Join-Path $referencesRoot 'command-catalog.md' +$openAiYamlPath = Join-Path $agentsRoot 'openai.yaml' + +$skillContent = Build-SkillContent -Notes $notes +$catalogContent = Build-CatalogContent -Notes $notes -BinaryRoot $binaryRoot +$openAiYamlContent = Build-OpenAiYamlContent + +if ($Check) { + Assert-GeneratedFileMatches -Path $skillPath -Content $skillContent -Label 'Mercury Toolbox skill' + Assert-GeneratedFileMatches -Path $catalogPath -Content $catalogContent -Label 'Mercury Toolbox command catalog' + Assert-GeneratedFileMatches -Path $openAiYamlPath -Content $openAiYamlContent -Label 'Mercury Toolbox openai.yaml' + Write-Host "AI skill is up to date: $SkillRoot" + return +} + +New-Item -ItemType Directory -Force -Path $SkillRoot, $referencesRoot, $agentsRoot | Out-Null +Write-Utf8FileWithRetry -Path $skillPath -Content $skillContent +Write-Utf8FileWithRetry -Path $catalogPath -Content $catalogContent +Write-Utf8FileWithRetry -Path $openAiYamlPath -Content $openAiYamlContent +Write-Host "Generated AI skill: $SkillRoot" diff --git a/scripts/install-jade-tooling.ps1 b/scripts/install-jade-tooling.ps1 new file mode 100644 index 0000000..9cfa93b --- /dev/null +++ b/scripts/install-jade-tooling.ps1 @@ -0,0 +1,100 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-OptionalCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + + return $command.Source +} + +function Resolve-XperfPath { + $commandPath = Get-OptionalCommandPath -Name 'xperf' + if (-not [string]::IsNullOrWhiteSpace($commandPath)) { + return $commandPath + } + + $candidates = @() + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if (-not [string]::IsNullOrWhiteSpace($root)) { + $candidates += (Join-Path $root 'Windows Kits\10\Windows Performance Toolkit\xperf.exe') + } + } + + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + return $null +} + +function Ensure-CargoBinstall { + if ($null -ne (Get-Command -Name 'cargo-binstall' -ErrorAction SilentlyContinue)) { + return + } + + Write-Host 'cargo-binstall is missing; installing it first so required Jade tools can use binary releases when available.' + Invoke-StrictNative -FilePath cargo -ArgumentList @("install", "cargo-binstall", "--locked") +} + +function Test-IsElevated { + if (-not $IsWindows) { + return $false + } + + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +Invoke-StrictNative -FilePath rustup -ArgumentList @("toolchain", "install", "nightly") +Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "miri", "--toolchain", "nightly") +Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "rust-src", "--toolchain", "nightly") +Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "llvm-tools-preview", "--toolchain", "stable") +Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "llvm-tools-preview", "--toolchain", "nightly") +Ensure-CargoBinstall +Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "cargo-nextest", "cargo-deny", "cargo-udeps", "cargo-llvm-cov", "cargo-expand", "cargo-bloat", "cargo-asm", "cargo-llvm-lines", "cargo-geiger", "cargo-outdated", "cargo-audit") +Invoke-StrictNative -FilePath cargo -ArgumentList @("install", "cargo-fuzz", "--locked") +Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "flamegraph") +Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "samply") +Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "sccache") +Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "just", "bacon") +if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber -Repository PSGallery +} + +if ($IsWindows) { + if (-not (Test-IsElevated)) { + Write-Warning 'cargo flamegraph falls back to blondie on Windows when DTrace is not configured, and blondie requires an elevated shell.' + } + + if ([string]::IsNullOrWhiteSpace((Resolve-XperfPath))) { + Write-Warning 'samply is installed, but recording on Windows still needs xperf from Windows Performance Toolkit (WPT).' + } +} diff --git a/scripts/install-package-toolbox.ps1 b/scripts/install-package-toolbox.ps1 new file mode 100644 index 0000000..003cbe2 --- /dev/null +++ b/scripts/install-package-toolbox.ps1 @@ -0,0 +1,557 @@ +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] + [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'), + [string]$CodexHome, + [switch]$NoCodexSkillInstall, + [switch]$NoPathUpdate +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Normalize-PathValue { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return '' + } + + return $PathValue.Replace('/', '\').Trim().TrimEnd('\').ToLowerInvariant() +} + +function Split-PathEntries { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return @() + } + + return $PathValue.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) +} + +function Add-PathEntry { + param( + [AllowNull()] + [string]$ExistingPath, + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + $normalizedEntry = Normalize-PathValue -PathValue $Entry + $entries = Split-PathEntries -PathValue $ExistingPath + foreach ($existing in $entries) { + if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { + return @{ + Changed = $false + Value = $ExistingPath + } + } + } + + $newEntries = @($entries + $Entry) + return @{ + Changed = $true + Value = ($newEntries -join ';') + } +} + +function Remove-PathEntry { + param( + [AllowNull()] + [string]$ExistingPath, + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + $normalizedEntry = Normalize-PathValue -PathValue $Entry + $remaining = [System.Collections.Generic.List[string]]::new() + $changed = $false + + foreach ($existing in (Split-PathEntries -PathValue $ExistingPath)) { + if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { + $changed = $true + continue + } + $remaining.Add($existing) + } + + return @{ + Changed = $changed + Value = ($remaining -join ';') + } +} + +function Copy-OptionalFile { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (Test-Path -LiteralPath $SourcePath) { + $destinationDirectory = Split-Path -Parent $DestinationPath + if (-not [string]::IsNullOrWhiteSpace($destinationDirectory)) { + New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null + } + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force + } +} + +function Assert-ExistingPathNotReparsePoint { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $item = Get-Item -LiteralPath $resolvedPath -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description must not be a reparse point: $resolvedPath" + } +} + +function Assert-ExistingTreeHasNoReparsePoints { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $resolvedPath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "$Description must not contain reparse points: $($reparsePoints[0].FullName)" + } +} + +function Copy-DirectoryTree { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-Path -LiteralPath $SourcePath)) { + throw "Required directory not found: $SourcePath" + } + + $sourceRoot = Get-Item -LiteralPath $SourcePath -Force + if (($sourceRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to copy reparse-point directory: $SourcePath" + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $SourcePath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "Refusing to copy directory tree containing reparse points: $($reparsePoints[0].FullName)" + } + + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Recurse -Force + } + + $destinationParent = Split-Path -Parent $DestinationPath + if (-not [string]::IsNullOrWhiteSpace($destinationParent)) { + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + } + + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force +} + +function Get-PathWithTrailingSeparator { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $trimmed = $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + return $trimmed + [System.IO.Path]::DirectorySeparatorChar +} + +function Assert-PackageSha256Sums { + param( + [Parameter(Mandatory = $true)] + [string]$PackageRoot, + [Parameter(Mandatory = $true)] + [string]$HashPath + ) + + if (-not (Test-Path -LiteralPath $HashPath)) { + throw "Package checksum file not found: $HashPath" + } + + $packageRootItem = Get-Item -LiteralPath $PackageRoot -Force + if (($packageRootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Package root must not be a reparse point: $PackageRoot" + } + + $packageReparsePoints = @(Get-ChildItem -LiteralPath $PackageRoot -Force -Recurse -Attributes ReparsePoint) + if ($packageReparsePoints.Count -gt 0) { + throw "Package tree contains reparse points: $($packageReparsePoints[0].FullName)" + } + + $resolvedPackageRoot = [System.IO.Path]::GetFullPath($PackageRoot) + $packageRootPrefix = Get-PathWithTrailingSeparator -Path $resolvedPackageRoot + $listedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $lineNumber = 0 + $entryCount = 0 + foreach ($line in Get-Content -LiteralPath $HashPath) { + $lineNumber++ + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + if ($line -notmatch '^(?[A-Fa-f0-9]{64})\s+\*?(?.+)$') { + throw "Invalid checksum entry at ${HashPath}:$lineNumber" + } + + $relativePath = $Matches.path.Trim() + if ([string]::IsNullOrWhiteSpace($relativePath) -or [System.IO.Path]::IsPathRooted($relativePath)) { + throw "Invalid checksum path at ${HashPath}:$lineNumber" + } + + $filePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedPackageRoot $relativePath)) + if (-not $filePath.StartsWith($packageRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Checksum path escapes package root at ${HashPath}:$lineNumber" + } + if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { + throw "Package checksum listed file is missing: $relativePath" + } + $normalizedRelativePath = [System.IO.Path]::GetRelativePath($resolvedPackageRoot, $filePath) + if (-not $listedFiles.Add($normalizedRelativePath)) { + throw "Duplicate checksum entry for package file: $normalizedRelativePath" + } + + $expectedHash = $Matches.hash.ToLowerInvariant() + $actualHash = (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "Package checksum mismatch for ${relativePath}: expected $expectedHash, got $actualHash" + } + $entryCount++ + } + + if ($entryCount -eq 0) { + throw "Package checksum file contains no entries: $HashPath" + } + + foreach ($file in Get-ChildItem -LiteralPath $PackageRoot -Force -Recurse -File) { + if ($file.FullName -eq $HashPath) { + continue + } + $relativePath = [System.IO.Path]::GetRelativePath($resolvedPackageRoot, $file.FullName) + if (-not $listedFiles.Contains($relativePath)) { + throw "Package contains file missing from SHA256SUMS: $relativePath" + } + } +} + +function Remove-PathItem { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + + $item = Get-Item -LiteralPath $Path -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + Remove-Item -LiteralPath $Path -Force + return + } + + if ($item.PSIsContainer) { + Remove-Item -LiteralPath $Path -Recurse -Force + return + } + + Remove-Item -LiteralPath $Path -Force +} + +function New-VersionDirectoryName { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' + $suffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + return "$stamp-package-$suffix" +} + +function Set-CurrentInstallLink { + param( + [Parameter(Mandatory = $true)] + [string]$CurrentLinkPath, + [Parameter(Mandatory = $true)] + [string]$TargetPath + ) + + $currentParent = Split-Path -Parent $CurrentLinkPath + if (-not [string]::IsNullOrWhiteSpace($currentParent)) { + New-Item -ItemType Directory -Force -Path $currentParent | Out-Null + } + + if (Test-Path -LiteralPath $CurrentLinkPath) { + Remove-PathItem -Path $CurrentLinkPath + } + + try { + New-Item -ItemType Junction -Path $CurrentLinkPath -Target $TargetPath | Out-Null + } + catch { + throw "Failed to create current install junction '$CurrentLinkPath' -> '$TargetPath'. Ensure this is running on Windows with a local NTFS-compatible install root. $($_.Exception.Message)" + } + if (-not (Test-Path -LiteralPath $CurrentLinkPath)) { + throw "Current install junction was not created: $CurrentLinkPath" + } +} + +function Remove-ObsoleteVersionDirectories { + param( + [Parameter(Mandatory = $true)] + [string]$VersionsRoot, + [AllowNull()] + [string]$CurrentVersionRoot + ) + + if (-not (Test-Path -LiteralPath $VersionsRoot)) { + return + } + + foreach ($directory in Get-ChildItem -LiteralPath $VersionsRoot -Directory -Force) { + if (-not [string]::IsNullOrWhiteSpace($CurrentVersionRoot) -and $directory.FullName -eq $CurrentVersionRoot) { + continue + } + + try { + Remove-Item -LiteralPath $directory.FullName -Recurse -Force + } + catch { + Write-Warning "Leaving stale toolbox version in place because it is still in use: $($directory.FullName)" + } + } +} + +function Resolve-CodexHomePath { + param( + [AllowNull()] + [string]$ExplicitCodexHome + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitCodexHome)) { + return $ExplicitCodexHome + } + + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + return $env:CODEX_HOME + } + + return (Join-Path $env:USERPROFILE '.codex') +} + +function Write-CodexSkillOwnershipMarker { + param( + [Parameter(Mandatory = $true)] + [string]$SkillRoot, + [Parameter(Mandatory = $true)] + [string]$InstallKind + ) + + $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' + $payload = [ordered]@{ + owner = 'mercury-toolbox' + install_kind = $InstallKind + installed_utc = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath $markerPath -Value $payload -Encoding utf8NoBOM +} + +function Assert-CodexSkillInstallAllowed { + param( + [AllowNull()] + [string]$SkillRoot + ) + + if ([string]::IsNullOrWhiteSpace($SkillRoot) -or -not (Test-Path -LiteralPath $SkillRoot)) { + return + } + + $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' + if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { + throw "Refusing to overwrite unmanaged Codex skill directory: $SkillRoot" + } + + try { + $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json + } + catch { + throw "Refusing to overwrite Codex skill directory with invalid ownership marker: $SkillRoot" + } + if ($marker.owner -ne 'mercury-toolbox') { + throw "Refusing to overwrite Codex skill directory owned by '$($marker.owner)': $SkillRoot" + } +} + +$packageRoot = Split-Path -Parent $PSScriptRoot +$binSource = Join-Path $packageRoot 'bin' +$docsSource = Join-Path $packageRoot 'docs' +$skillsSource = Join-Path $packageRoot 'skills' +$supportSource = Join-Path $packageRoot 'support' +$manifestSource = Join-Path $packageRoot 'mercury-toolbox-package.json' +$hashSource = Join-Path $packageRoot 'SHA256SUMS.txt' + +if (-not (Test-Path -LiteralPath $binSource)) { + throw "Package bin directory not found: $binSource" +} +if (-not (Test-Path -LiteralPath $docsSource)) { + throw "Package docs directory not found: $docsSource" +} +if (-not (Test-Path -LiteralPath $skillsSource)) { + throw "Package skills directory not found: $skillsSource" +} + +$legacyBinDir = Join-Path $InstallRoot 'bin' +$versionsRoot = Join-Path $InstallRoot 'versions' +$currentRoot = Join-Path $InstallRoot 'current' +$activeBinDir = Join-Path $currentRoot 'bin' +$versionRoot = Join-Path $versionsRoot (New-VersionDirectoryName) +$stagingBinDir = Join-Path $versionRoot 'bin' +$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox' +$codexHomeRoot = if ($NoCodexSkillInstall) { $null } else { Resolve-CodexHomePath -ExplicitCodexHome $CodexHome } +$codexSkillRoot = if ([string]::IsNullOrWhiteSpace([string]$codexHomeRoot)) { $null } else { Join-Path $codexHomeRoot 'skills\mercury-toolbox' } +$legacyCommandNames = @('context', 'waitfor') + +Assert-ExistingPathNotReparsePoint -Path $InstallRoot -Description 'InstallRoot' +Assert-ExistingPathNotReparsePoint -Path $versionsRoot -Description 'versions root' +Assert-ExistingPathNotReparsePoint -Path $shareRoot -Description 'share root' +Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root' +if ($null -ne $codexSkillRoot) { + Assert-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root' + Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root' +} +Assert-PackageSha256Sums -PackageRoot $packageRoot -HashPath $hashSource +Assert-CodexSkillInstallAllowed -SkillRoot $codexSkillRoot + +Write-Host "Mercury Toolbox package installer" +Write-Host "package root: $packageRoot" +Write-Host "install root: $InstallRoot" +Write-Host "active bin dir: $activeBinDir" +Write-Host "versions root: $versionsRoot" +Write-Host "share dir: $shareRoot" +if ($null -ne $codexSkillRoot) { + Write-Host "Codex skill dir: $codexSkillRoot" +} + +New-Item -ItemType Directory -Force -Path $stagingBinDir, $shareRoot | Out-Null +foreach ($legacyName in $legacyCommandNames) { + $legacyPath = Join-Path $legacyBinDir "$legacyName.exe" + if (Test-Path -LiteralPath $legacyPath) { + Remove-Item -LiteralPath $legacyPath -Force + } +} + +$installed = [System.Collections.Generic.List[string]]::new() +foreach ($commandName in Get-ToolboxCommandNames) { + $sourcePath = Join-Path $binSource "$commandName.exe" + if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "Expected packaged binary not found: $sourcePath" + } + + Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $stagingBinDir "$commandName.exe") -Force + $installed.Add($commandName) +} + +foreach ($runtimeFile in Get-ChildItem -LiteralPath $binSource -Filter '*.dll' -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $runtimeFile.FullName -Destination (Join-Path $stagingBinDir $runtimeFile.Name) -Force +} + +Copy-DirectoryTree -SourcePath $docsSource -DestinationPath (Join-Path $shareRoot 'docs') +Copy-DirectoryTree -SourcePath $skillsSource -DestinationPath (Join-Path $shareRoot 'skills') +if (Test-Path -LiteralPath $supportSource) { + Copy-DirectoryTree -SourcePath $supportSource -DestinationPath (Join-Path $shareRoot 'support') +} +Copy-OptionalFile -SourcePath $manifestSource -DestinationPath (Join-Path $shareRoot 'mercury-toolbox-package.json') +Copy-OptionalFile -SourcePath $hashSource -DestinationPath (Join-Path $shareRoot 'SHA256SUMS.txt') + +if ($null -ne $codexSkillRoot) { + $packagedSkillRoot = Join-Path $skillsSource 'mercury-toolbox' + if (Test-Path -LiteralPath $packagedSkillRoot) { + Copy-DirectoryTree -SourcePath $packagedSkillRoot -DestinationPath $codexSkillRoot + Write-CodexSkillOwnershipMarker -SkillRoot $codexSkillRoot -InstallKind 'package' + } +} + +Set-CurrentInstallLink -CurrentLinkPath $currentRoot -TargetPath $versionRoot +Remove-ObsoleteVersionDirectories -VersionsRoot $versionsRoot -CurrentVersionRoot $versionRoot + +$pathStatus = 'skipped' +if (-not $NoPathUpdate) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $userPathChanged = $false + $userPathRemoval = Remove-PathEntry -ExistingPath $userPath -Entry $legacyBinDir + if ($userPathRemoval.Changed) { + $userPath = $userPathRemoval.Value + $userPathChanged = $true + } + $userPathAdd = Add-PathEntry -ExistingPath $userPath -Entry $activeBinDir + if ($userPathAdd.Changed) { + $userPath = $userPathAdd.Value + $userPathChanged = $true + } + if ($userPathChanged) { + [Environment]::SetEnvironmentVariable('Path', $userPath, 'User') + $pathStatus = 'updated' + } + else { + $pathStatus = 'already_present' + } + + $sessionPath = $env:Path + $sessionRemoval = Remove-PathEntry -ExistingPath $sessionPath -Entry $legacyBinDir + if ($sessionRemoval.Changed) { + $sessionPath = $sessionRemoval.Value + } + $sessionAdd = Add-PathEntry -ExistingPath $sessionPath -Entry $activeBinDir + if ($sessionAdd.Changed) { + $sessionPath = $sessionAdd.Value + } + $env:Path = $sessionPath +} + +Write-Host '' +Write-Host "Installed commands ($($installed.Count)):" +foreach ($commandName in $installed) { + Write-Host " - $commandName" +} +Write-Host "PATH status: $pathStatus" +Write-Host "AI prompt: $(Join-Path $shareRoot 'docs\ai\mercury-toolbox-ai-prompt.md')" +Write-Host "Shared skill copy: $(Join-Path $shareRoot 'skills\mercury-toolbox\SKILL.md')" +Write-Host "Command catalog: $(Join-Path $shareRoot 'skills\mercury-toolbox\references\command-catalog.md')" +if ($null -ne $codexSkillRoot) { + Write-Host "Codex skill: $(Join-Path $codexSkillRoot 'SKILL.md')" +} +if (-not $NoPathUpdate) { + Write-Host "Shell note: future shells pick up the new PATH automatically." + Write-Host "Shell note: if you launched install via 'pwsh -File', reopen this shell or run:" + Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine')" +} +Write-Host 'Verification: pathshadow msudo' +Write-Host 'Verification: msudo status --json' +Write-Host 'Verification: msudo --help' diff --git a/scripts/install-toolbox.ps1 b/scripts/install-toolbox.ps1 new file mode 100644 index 0000000..71f569d --- /dev/null +++ b/scripts/install-toolbox.ps1 @@ -0,0 +1,507 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [ValidateNotNullOrEmpty()] + [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'), + [string]$CodexHome, + [switch]$NoPathUpdate, + [switch]$NoCodexSkillInstall, + [switch]$SkipBuild, + [switch]$SkipSlimBinaryRebuild +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-RequiredCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found on PATH: $Name" + } + + return $command.Source +} + +function Normalize-PathValue { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return '' + } + + return $PathValue.Replace('/', '\').Trim().TrimEnd('\').ToLowerInvariant() +} + +function Split-PathEntries { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return @() + } + + return $PathValue.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) +} + +function Add-PathEntry { + param( + [AllowNull()] + [string]$ExistingPath, + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + $normalizedEntry = Normalize-PathValue -PathValue $Entry + $entries = Split-PathEntries -PathValue $ExistingPath + foreach ($existing in $entries) { + if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { + return @{ + Changed = $false + Value = $ExistingPath + } + } + } + + $newEntries = @($entries + $Entry) + return @{ + Changed = $true + Value = ($newEntries -join ';') + } +} + +function Remove-PathEntry { + param( + [AllowNull()] + [string]$ExistingPath, + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + $normalizedEntry = Normalize-PathValue -PathValue $Entry + $remaining = [System.Collections.Generic.List[string]]::new() + $changed = $false + + foreach ($existing in (Split-PathEntries -PathValue $ExistingPath)) { + if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { + $changed = $true + continue + } + $remaining.Add($existing) + } + + return @{ + Changed = $changed + Value = ($remaining -join ';') + } +} + +function Resolve-CodexHomePath { + param( + [AllowNull()] + [string]$ExplicitCodexHome + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitCodexHome)) { + return $ExplicitCodexHome + } + + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + return $env:CODEX_HOME + } + + return (Join-Path $env:USERPROFILE '.codex') +} + +function Write-CodexSkillOwnershipMarker { + param( + [Parameter(Mandatory = $true)] + [string]$SkillRoot, + [Parameter(Mandatory = $true)] + [string]$InstallKind + ) + + $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' + $payload = [ordered]@{ + owner = 'mercury-toolbox' + install_kind = $InstallKind + installed_utc = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath $markerPath -Value $payload -Encoding utf8NoBOM +} + +function Assert-CodexSkillInstallAllowed { + param( + [AllowNull()] + [string]$SkillRoot + ) + + if ([string]::IsNullOrWhiteSpace($SkillRoot) -or -not (Test-Path -LiteralPath $SkillRoot)) { + return + } + + $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' + if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { + throw "Refusing to overwrite unmanaged Codex skill directory: $SkillRoot" + } + + try { + $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json + } + catch { + throw "Refusing to overwrite Codex skill directory with invalid ownership marker: $SkillRoot" + } + if ($marker.owner -ne 'mercury-toolbox') { + throw "Refusing to overwrite Codex skill directory owned by '$($marker.owner)': $SkillRoot" + } +} + +function Copy-OptionalFile { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (Test-Path -LiteralPath $SourcePath) { + $destinationDirectory = Split-Path -Parent $DestinationPath + if (-not [string]::IsNullOrWhiteSpace($destinationDirectory)) { + New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null + } + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force + } +} + +function Assert-ExistingPathNotReparsePoint { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $item = Get-Item -LiteralPath $resolvedPath -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description must not be a reparse point: $resolvedPath" + } +} + +function Assert-ExistingTreeHasNoReparsePoints { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $resolvedPath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "$Description must not contain reparse points: $($reparsePoints[0].FullName)" + } +} + +function Copy-DirectoryTree { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-Path -LiteralPath $SourcePath)) { + throw "Required directory not found: $SourcePath" + } + + $sourceRoot = Get-Item -LiteralPath $SourcePath -Force + if (($sourceRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to copy reparse-point directory: $SourcePath" + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $SourcePath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "Refusing to copy directory tree containing reparse points: $($reparsePoints[0].FullName)" + } + + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Recurse -Force + } + + $destinationParent = Split-Path -Parent $DestinationPath + if (-not [string]::IsNullOrWhiteSpace($destinationParent)) { + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + } + + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force +} + +function Remove-PathItem { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + + $item = Get-Item -LiteralPath $Path -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + Remove-Item -LiteralPath $Path -Force + return + } + + if ($item.PSIsContainer) { + Remove-Item -LiteralPath $Path -Recurse -Force + return + } + + Remove-Item -LiteralPath $Path -Force +} + +function New-VersionDirectoryName { + param( + [Parameter(Mandatory = $true)] + [string]$Configuration + ) + + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' + $suffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + return "$stamp-$($Configuration.ToLowerInvariant())-$suffix" +} + +function Set-CurrentInstallLink { + param( + [Parameter(Mandatory = $true)] + [string]$CurrentLinkPath, + [Parameter(Mandatory = $true)] + [string]$TargetPath + ) + + $currentParent = Split-Path -Parent $CurrentLinkPath + if (-not [string]::IsNullOrWhiteSpace($currentParent)) { + New-Item -ItemType Directory -Force -Path $currentParent | Out-Null + } + + if (Test-Path -LiteralPath $CurrentLinkPath) { + Remove-PathItem -Path $CurrentLinkPath + } + + try { + New-Item -ItemType Junction -Path $CurrentLinkPath -Target $TargetPath | Out-Null + } + catch { + throw "Failed to create current install junction '$CurrentLinkPath' -> '$TargetPath'. Ensure this is running on Windows with a local NTFS-compatible install root. $($_.Exception.Message)" + } + if (-not (Test-Path -LiteralPath $CurrentLinkPath)) { + throw "Current install junction was not created: $CurrentLinkPath" + } +} + +function Remove-ObsoleteVersionDirectories { + param( + [Parameter(Mandatory = $true)] + [string]$VersionsRoot, + [AllowNull()] + [string]$CurrentVersionRoot + ) + + if (-not (Test-Path -LiteralPath $VersionsRoot)) { + return + } + + foreach ($directory in Get-ChildItem -LiteralPath $VersionsRoot -Directory -Force) { + if (-not [string]::IsNullOrWhiteSpace($CurrentVersionRoot) -and $directory.FullName -eq $CurrentVersionRoot) { + continue + } + + try { + Remove-Item -LiteralPath $directory.FullName -Recurse -Force + } + catch { + Write-Warning "Leaving stale toolbox version in place because it is still in use: $($directory.FullName)" + } + } +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$profileName = Resolve-ToolboxProfileName -Configuration $Configuration +$legacyBinDir = Join-Path $InstallRoot 'bin' +$versionsRoot = Join-Path $InstallRoot 'versions' +$currentRoot = Join-Path $InstallRoot 'current' +$activeBinDir = Join-Path $currentRoot 'bin' +$versionRoot = Join-Path $versionsRoot (New-VersionDirectoryName -Configuration $Configuration) +$stagingBinDir = Join-Path $versionRoot 'bin' +$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox' +$codexHomeRoot = if ($NoCodexSkillInstall) { $null } else { Resolve-CodexHomePath -ExplicitCodexHome $CodexHome } +$codexSkillRoot = if ([string]::IsNullOrWhiteSpace([string]$codexHomeRoot)) { $null } else { Join-Path $codexHomeRoot 'skills\mercury-toolbox' } +$legacyCommandNames = @('context', 'waitfor') + +Assert-ExistingPathNotReparsePoint -Path $InstallRoot -Description 'InstallRoot' +Assert-ExistingPathNotReparsePoint -Path $versionsRoot -Description 'versions root' +Assert-ExistingPathNotReparsePoint -Path $shareRoot -Description 'share root' +Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root' +if ($null -ne $codexSkillRoot) { + Assert-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root' + Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root' +} +Assert-CodexSkillInstallAllowed -SkillRoot $codexSkillRoot + +Write-Host "Mercury Toolbox installer" +Write-Host "workspace: $workspaceRoot" +Write-Host "install root: $InstallRoot" +Write-Host "active bin dir: $activeBinDir" +Write-Host "versions root: $versionsRoot" +Write-Host "share dir: $shareRoot" +if ($null -ne $codexSkillRoot) { + Write-Host "Codex skill dir: $codexSkillRoot" +} +Write-Host "configuration: $Configuration" + +New-Item -ItemType Directory -Force -Path $stagingBinDir, $shareRoot | Out-Null +foreach ($legacyName in $legacyCommandNames) { + $legacyPath = Join-Path $legacyBinDir "$legacyName.exe" + if (Test-Path -LiteralPath $legacyPath) { + Remove-Item -LiteralPath $legacyPath -Force + } +} + +if (-not $SkipBuild) { + $cargoPath = Get-RequiredCommandPath -Name 'cargo' + Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration -SkipSlimBinaryRebuild:$SkipSlimBinaryRebuild +} + +$promptScript = Join-Path $PSScriptRoot 'generate-ai-prompt.ps1' +& $promptScript -Configuration $Configuration -SkipBuild +if ($LASTEXITCODE -ne 0) { + throw "generate-ai-prompt.ps1 exited with $LASTEXITCODE" +} + +$skillScript = Join-Path $PSScriptRoot 'generate-ai-skill.ps1' +& $skillScript -Configuration $Configuration -SkipBuild +if ($LASTEXITCODE -ne 0) { + throw "generate-ai-skill.ps1 exited with $LASTEXITCODE" +} + +$installed = [System.Collections.Generic.List[string]]::new() +foreach ($commandName in Get-ToolboxCommandNames) { + $sourcePath = Join-Path $workspaceRoot "target\$profileName\$commandName.exe" + if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "Expected binary not found: $sourcePath" + } + + $destinationPath = Join-Path $stagingBinDir "$commandName.exe" + Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force + $installed.Add($commandName) +} + +$duckdbDllPath = Join-Path $workspaceRoot "target\$profileName\deps\duckdb.dll" +if (Test-Path -LiteralPath $duckdbDllPath) { + Copy-Item -LiteralPath $duckdbDllPath -Destination (Join-Path $stagingBinDir 'duckdb.dll') -Force +} + +Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'docs\ai') -DestinationPath (Join-Path $shareRoot 'docs\ai') +Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath (Join-Path $shareRoot 'skills\mercury-toolbox') +if (Test-Path -LiteralPath (Join-Path $workspaceRoot 'support')) { + Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'support') -DestinationPath (Join-Path $shareRoot 'support') +} +Copy-OptionalFile -SourcePath (Join-Path $workspaceRoot 'README.md') -DestinationPath (Join-Path $shareRoot 'README.md') + +if ($null -ne $codexSkillRoot) { + Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath $codexSkillRoot + Write-CodexSkillOwnershipMarker -SkillRoot $codexSkillRoot -InstallKind 'workspace' +} + +Set-CurrentInstallLink -CurrentLinkPath $currentRoot -TargetPath $versionRoot +Remove-ObsoleteVersionDirectories -VersionsRoot $versionsRoot -CurrentVersionRoot $versionRoot + +$pathStatus = 'skipped' +if (-not $NoPathUpdate) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $userPathChanged = $false + $userPathRemoval = Remove-PathEntry -ExistingPath $userPath -Entry $legacyBinDir + if ($userPathRemoval.Changed) { + $userPath = $userPathRemoval.Value + $userPathChanged = $true + } + $userPathAdd = Add-PathEntry -ExistingPath $userPath -Entry $activeBinDir + if ($userPathAdd.Changed) { + $userPath = $userPathAdd.Value + $userPathChanged = $true + } + if ($userPathChanged) { + [Environment]::SetEnvironmentVariable('Path', $userPath, 'User') + $pathStatus = 'updated' + } + else { + $pathStatus = 'already_present' + } + + $sessionPath = $env:Path + $sessionRemoval = Remove-PathEntry -ExistingPath $sessionPath -Entry $legacyBinDir + if ($sessionRemoval.Changed) { + $sessionPath = $sessionRemoval.Value + } + $sessionAdd = Add-PathEntry -ExistingPath $sessionPath -Entry $activeBinDir + if ($sessionAdd.Changed) { + $sessionPath = $sessionAdd.Value + } + $env:Path = $sessionPath +} + +Write-Host '' +Write-Host "Installed commands ($($installed.Count)):" +foreach ($commandName in $installed) { + Write-Host " - $commandName" +} +Write-Host "PATH status: $pathStatus" +Write-Host "AI prompt: $(Join-Path $shareRoot 'docs\ai\mercury-toolbox-ai-prompt.md')" +Write-Host "Command catalog: $(Join-Path $shareRoot 'skills\mercury-toolbox\references\command-catalog.md')" +Write-Host "Shared skill copy: $(Join-Path $shareRoot 'skills\mercury-toolbox\SKILL.md')" +if ($null -ne $codexSkillRoot) { + Write-Host "Codex skill: $(Join-Path $codexSkillRoot 'SKILL.md')" +} +if (-not $NoPathUpdate) { + Write-Host "Shell note: future shells pick up the new PATH automatically." + Write-Host "Shell note: if you launched install via 'pwsh -File', reopen this shell or run:" + Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine')" +} +Write-Host 'Verification: pathshadow msudo' +Write-Host 'Verification: msudo status --json' +Write-Host 'Verification: msudo --help' diff --git a/scripts/invoke-gitea-git.ps1 b/scripts/invoke-gitea-git.ps1 new file mode 100644 index 0000000..15225ba --- /dev/null +++ b/scripts/invoke-gitea-git.ps1 @@ -0,0 +1,189 @@ +[CmdletBinding()] +param( + [string]$Remote = 'origin', + [string]$BaseUrl, + [string]$Owner, + [string]$Repo, + [string]$UserName, + [string]$ApiToken, + [switch]$InsecureSkipTlsVerify, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$GitArgs +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-GitCapture { + param( + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $output = & git @ArgumentList 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")" + } + + return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() +} + +function Resolve-GiteaRepositoryContext { + param( + [string]$RemoteName, + [string]$RemoteUrl + ) + + if ([string]::IsNullOrWhiteSpace($RemoteUrl)) { + $RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', $RemoteName) + } + + $normalized = $RemoteUrl.Trim() + if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') { + return [pscustomobject]@{ + BaseUrl = $Matches[1] + Owner = $Matches[2] + Repo = $Matches[3] + } + } + + if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') { + $scheme = 'https' + $giteaHost = $Matches[1] + $port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" } + return [pscustomobject]@{ + BaseUrl = "${scheme}://$giteaHost$port" + Owner = $Matches[3] + Repo = $Matches[4] + } + } + + throw "Could not parse Gitea remote URL: $normalized" +} + +function Resolve-ApiToken { + param( + [string]$ExplicitToken + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) { + return $ExplicitToken + } + + foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + + throw 'No Gitea token was provided. Pass -ApiToken or set GITEA_API_TOKEN/GITEA_TOKEN for this process; this helper deliberately does not call Git Credential Manager.' +} + +function Get-ScopedEnvironment { + param( + [Parameter(Mandatory = $true)] + [string[]]$Names + ) + + $values = @{} + foreach ($name in $Names) { + $values[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + } + + return $values +} + +function Set-ScopedEnvironment { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Values + ) + + foreach ($entry in $Values.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process') + } +} + +function Write-GitOutput { + param( + [object[]]$Output + ) + + foreach ($item in $Output) { + $line = [string]$item + if ($line -match '^\d{2}:\d{2}:\d{2}\.\d+\s+http\.c:\d+\s+') { + continue + } + + Write-Output $line + } +} + +if ($GitArgs.Count -gt 0 -and $GitArgs[0] -eq '--') { + $GitArgs = @($GitArgs | Select-Object -Skip 1) +} + +if ($GitArgs.Count -eq 0) { + throw 'Pass the git subcommand, for example: .\scripts\invoke-gitea-git.ps1 ls-remote origin refs/heads/main' +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $workspaceRoot + +try { + $context = Resolve-GiteaRepositoryContext -RemoteName $Remote + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = $context.BaseUrl + } + if ([string]::IsNullOrWhiteSpace($Owner)) { + $Owner = $context.Owner + } + if ([string]::IsNullOrWhiteSpace($Repo)) { + $Repo = $context.Repo + } + if ([string]::IsNullOrWhiteSpace($UserName)) { + $UserName = $Owner + } + + $resolvedToken = Resolve-ApiToken -ExplicitToken $ApiToken + $basic = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${UserName}:$resolvedToken")) + $configArgs = @( + '-c', 'credential.helper=', + '-c', 'http.sslBackend=openssl', + '-c', "http.extraHeader=Authorization: Basic $basic" + ) + if ($InsecureSkipTlsVerify) { + $configArgs += @('-c', 'http.sslVerify=false') + } + + $scopedEnvironmentValues = @{ + GIT_TERMINAL_PROMPT = '0' + GIT_TRACE = '0' + GIT_TRACE2 = '0' + GIT_TRACE2_EVENT = '0' + GIT_TRACE2_PERF = '0' + GIT_TRACE_PACKET = '0' + GIT_TRACE_CURL = '0' + GIT_CURL_VERBOSE = $null + } + $scopedNames = @($scopedEnvironmentValues.Keys) + $previousEnvironment = Get-ScopedEnvironment -Names $scopedNames + Set-ScopedEnvironment -Values $scopedEnvironmentValues + + try { + $gitOutput = @(& git @configArgs @GitArgs 2>&1) + $exitCode = $LASTEXITCODE + } + finally { + Set-ScopedEnvironment -Values $previousEnvironment + } + + Write-GitOutput -Output $gitOutput + if ($exitCode -ne 0) { + throw "git command failed with exit code $exitCode. The transient Authorization header was not printed or persisted." + } +} +finally { + Pop-Location +} diff --git a/scripts/package-toolbox.ps1 b/scripts/package-toolbox.ps1 new file mode 100644 index 0000000..aa208a8 --- /dev/null +++ b/scripts/package-toolbox.ps1 @@ -0,0 +1,356 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [ValidateNotNullOrEmpty()] + [string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'dist'), + [string]$PackageName, + [switch]$SkipBuild, + [switch]$SkipSlimBinaryRebuild, + [switch]$SkipPromptGeneration +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-RequiredCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found on PATH: $Name" + } + + return $command.Source +} + +function Get-HostTargetTriple { + $rustcPath = Get-RequiredCommandPath -Name 'rustc' + $lines = & $rustcPath -vV + if ($LASTEXITCODE -ne 0) { + $commandLine = Format-ToolboxNativeCommand -FilePath $rustcPath -ArgumentList @('-vV') + throw "Failed to query rustc host target with command: $commandLine" + } + + foreach ($line in $lines) { + if ($line -like 'host:*') { + return ($line -replace '^host:\s*', '').Trim() + } + } + + throw 'rustc host target was not reported' +} + +function Assert-ArchiveCreated { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Compress-Archive did not create the expected archive: $Path" + } + + $archive = Get-Item -LiteralPath $Path -Force + if ($archive.Length -le 0) { + throw "Compress-Archive created an empty archive: $Path" + } +} + +function Reset-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } + + New-Item -ItemType Directory -Force -Path $Path | Out-Null +} + +function Assert-SingleDirectoryName { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { + throw 'PackageName must not be empty.' + } + if ([System.IO.Path]::IsPathRooted($Name) -or $Name -ne [System.IO.Path]::GetFileName($Name) -or $Name -in @('.', '..')) { + throw "PackageName must be a single directory name, not a path: $Name" + } + if ($Name.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw "PackageName contains invalid file name characters: $Name" + } +} + +function Get-PathWithTrailingSeparator { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $trimmed = $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + return $trimmed + [System.IO.Path]::DirectorySeparatorChar +} + +function Resolve-PackageRootPath { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + Assert-SingleDirectoryName -Name $Name + $resolvedRoot = [System.IO.Path]::GetFullPath($RootPath) + if (Test-Path -LiteralPath $resolvedRoot) { + $rootItem = Get-Item -LiteralPath $resolvedRoot -Force + if (($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "OutputRoot must not be a reparse point: $resolvedRoot" + } + } + + $resolvedPackageRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $Name)) + $rootPrefix = Get-PathWithTrailingSeparator -Path $resolvedRoot + if (-not $resolvedPackageRoot.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Resolved package root must stay inside OutputRoot: $resolvedPackageRoot" + } + + return [pscustomobject]@{ + OutputRoot = $resolvedRoot + PackageRoot = $resolvedPackageRoot + } +} + +function Copy-DirectoryTree { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-Path -LiteralPath $SourcePath)) { + throw "Required directory not found: $SourcePath" + } + + $sourceRoot = Get-Item -LiteralPath $SourcePath -Force + if (($sourceRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to copy reparse-point directory: $SourcePath" + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $SourcePath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "Refusing to copy directory tree containing reparse points: $($reparsePoints[0].FullName)" + } + + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Recurse -Force + } + + $destinationParent = Split-Path -Parent $DestinationPath + if (-not [string]::IsNullOrWhiteSpace($destinationParent)) { + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + } + + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force +} + +function Write-Sha256SumsFile { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath + ) + + $lines = [System.Collections.Generic.List[string]]::new() + foreach ($file in (Get-ChildItem -LiteralPath $RootPath -Recurse -File | Sort-Object FullName)) { + if ($file.FullName -eq $OutputPath) { + continue + } + + $relativePath = [System.IO.Path]::GetRelativePath($RootPath, $file.FullName).Replace('\', '/') + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $lines.Add("$hash $relativePath") + } + + Set-Content -LiteralPath $OutputPath -Value $lines -Encoding utf8NoBOM +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$profileName = Resolve-ToolboxProfileName -Configuration $Configuration +$binaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $profileName) +$hostTarget = Get-HostTargetTriple + +if ([string]::IsNullOrWhiteSpace($PackageName)) { + $PackageName = "MercuryToolbox-$hostTarget-$Configuration" +} + +$resolvedPackagePaths = Resolve-PackageRootPath -RootPath $OutputRoot -Name $PackageName +$OutputRoot = $resolvedPackagePaths.OutputRoot +$packageRoot = $resolvedPackagePaths.PackageRoot +$zipPath = Join-Path $OutputRoot "$PackageName.zip" +$binDir = Join-Path $packageRoot 'bin' +$docsAiDir = Join-Path $packageRoot 'docs\ai' +$skillsDir = Join-Path $packageRoot 'skills' +$scriptsDir = Join-Path $packageRoot 'scripts' +$supportDir = Join-Path $packageRoot 'support' +$startHerePath = Join-Path $packageRoot 'START-HERE.txt' + +Write-Host "Mercury Toolbox release packager" +Write-Host "workspace: $workspaceRoot" +Write-Host "configuration: $Configuration" +Write-Host "host target: $hostTarget" +Write-Host "output root: $OutputRoot" +Write-Host "package root: $packageRoot" +Write-Host "archive: $zipPath" + +New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null + +$cargoPath = Get-RequiredCommandPath -Name 'cargo' +if (-not $SkipBuild) { + Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration -SkipSlimBinaryRebuild:$SkipSlimBinaryRebuild +} + +if (-not $SkipPromptGeneration) { + $promptScript = Join-Path $PSScriptRoot 'generate-ai-prompt.ps1' + $skillScript = Join-Path $PSScriptRoot 'generate-ai-skill.ps1' + $promptArgs = @{ + Configuration = $Configuration + } + if ($SkipBuild) { + $promptArgs.SkipBuild = $true + } + & $promptScript @promptArgs + if ($LASTEXITCODE -ne 0) { + throw "generate-ai-prompt.ps1 exited with $LASTEXITCODE" + } + & $skillScript @promptArgs + if ($LASTEXITCODE -ne 0) { + throw "generate-ai-skill.ps1 exited with $LASTEXITCODE" + } +} + +Reset-Directory -Path $packageRoot +New-Item -ItemType Directory -Force -Path $binDir, $docsAiDir, $skillsDir, $scriptsDir | Out-Null + +foreach ($commandName in Get-ToolboxCommandNames) { + $binaryPath = Join-Path $binaryRoot "$commandName.exe" + if (-not (Test-Path -LiteralPath $binaryPath)) { + throw "Expected binary not found: $binaryPath" + } + + Copy-Item -LiteralPath $binaryPath -Destination (Join-Path $binDir "$commandName.exe") -Force +} + +$duckdbDllPath = Join-Path $binaryRoot 'deps\duckdb.dll' +if (-not (Test-Path -LiteralPath $duckdbDllPath)) { + throw "Expected DuckDB runtime not found: $duckdbDllPath" +} +Copy-Item -LiteralPath $duckdbDllPath -Destination (Join-Path $binDir 'duckdb.dll') -Force + +Copy-Item -LiteralPath (Join-Path $workspaceRoot 'LICENSE') -Destination (Join-Path $packageRoot 'LICENSE') -Force +Copy-Item -LiteralPath (Join-Path $workspaceRoot 'docs\ai\mercury-toolbox-ai-prompt.md') -Destination (Join-Path $docsAiDir 'mercury-toolbox-ai-prompt.md') -Force +Copy-Item -LiteralPath (Join-Path $workspaceRoot 'docs\ai\toolbox-ai-prompt-notes.json') -Destination (Join-Path $docsAiDir 'toolbox-ai-prompt-notes.json') -Force +Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath (Join-Path $skillsDir 'mercury-toolbox') +if (Test-Path -LiteralPath (Join-Path $workspaceRoot 'support')) { + Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'support') -DestinationPath $supportDir +} +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'toolbox-commands.ps1') -Destination (Join-Path $scriptsDir 'toolbox-commands.ps1') -Force +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install-package-toolbox.ps1') -Destination (Join-Path $scriptsDir 'install-package-toolbox.ps1') -Force +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall-package-toolbox.ps1') -Destination (Join-Path $scriptsDir 'uninstall-package-toolbox.ps1') -Force + +$startHereLines = @( + 'Mercury Toolbox portable package', + '', + 'Install from this extracted directory:', + ' pwsh -NoProfile -File .\scripts\install-package-toolbox.ps1', + '', + 'After install, open a new PowerShell or reload PATH:', + ' $env:Path = [Environment]::GetEnvironmentVariable(''Path'',''User'') + '';'' + [Environment]::GetEnvironmentVariable(''Path'',''Machine'')', + '', + 'Safe first commands:', + ' fileprobe ', + ' reposhape . --json | ConvertFrom-Json', + ' peimports --json | ConvertFrom-Json', + ' drvshape --json | ConvertFrom-Json', + ' asmref diagnose --resolve-dir --format toon', + '', + 'AI and Codex assets:', + ' docs\ai\mercury-toolbox-ai-prompt.md', + ' skills\mercury-toolbox\SKILL.md', + ' skills\mercury-toolbox\references\command-catalog.md', + '', + 'Trust guided triage first. Every command is documented with an answer shape, trust basis, and next actions in the packaged skill catalog. PE tools also expose report_quality and next_actions in --json/--toon, and text output starts with answer=, trust=, and next_action= lines.', + '', + 'This archive intentionally omits the repo README; use the files above as the package entry points.' +) +Set-Content -LiteralPath $startHerePath -Value $startHereLines -Encoding utf8NoBOM + +$gitCommit = (& git -C $workspaceRoot rev-parse HEAD 2>$null) +if ($LASTEXITCODE -ne 0) { + $gitCommit = $null +} + +$manifest = [ordered]@{ + name = 'Mercury Toolbox' + package_name = $PackageName + configuration = $Configuration + profile = $profileName + host_target = $hostTarget + built_at_utc = (Get-Date).ToUniversalTime().ToString('o') + git_commit = if ([string]::IsNullOrWhiteSpace($gitCommit)) { $null } else { $gitCommit.Trim() } + command_count = (Get-ToolboxCommandNames).Count + commands = @(Get-ToolboxCommandNames) + install_script = 'scripts/install-package-toolbox.ps1' + uninstall_script = 'scripts/uninstall-package-toolbox.ps1' + ai_prompt = 'docs/ai/mercury-toolbox-ai-prompt.md' + ai_skill = 'skills/mercury-toolbox/SKILL.md' + ai_skill_catalog = 'skills/mercury-toolbox/references/command-catalog.md' +} +$manifestPath = Join-Path $packageRoot 'mercury-toolbox-package.json' +$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + +$hashPath = Join-Path $packageRoot 'SHA256SUMS.txt' +Write-Sha256SumsFile -RootPath $packageRoot -OutputPath $hashPath + +if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} +Compress-Archive -LiteralPath $packageRoot -DestinationPath $zipPath -CompressionLevel Optimal +Assert-ArchiveCreated -Path $zipPath + +Write-Host '' +Write-Host "Packaged commands ($((Get-ToolboxCommandNames).Count)):" +foreach ($commandName in Get-ToolboxCommandNames) { + Write-Host " - $commandName" +} +Write-Host "Portable install: $(Join-Path $PackageName 'scripts\install-package-toolbox.ps1')" +Write-Host "AI prompt: $(Join-Path $PackageName 'docs\ai\mercury-toolbox-ai-prompt.md')" +Write-Host "Codex skill: $(Join-Path $PackageName 'skills\mercury-toolbox\SKILL.md')" +Write-Host "Command catalog: $(Join-Path $PackageName 'skills\mercury-toolbox\references\command-catalog.md')" +Write-Host "Archive ready: $zipPath" diff --git a/scripts/probe-toolbox.ps1 b/scripts/probe-toolbox.ps1 new file mode 100644 index 0000000..3930c35 --- /dev/null +++ b/scripts/probe-toolbox.ps1 @@ -0,0 +1,510 @@ +[CmdletBinding()] +param( + [ValidateSet('Release', 'ReleaseFast', 'ReleaseSize')] + [string]$BuildProfile = 'ReleaseFast', + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$FlamegraphBuildProfile = 'Release', + [int]$Warmup = 2, + [int]$Runs = 6, + [switch]$SkipBenchmarks, + [switch]$IncludeBloat, + [switch]$IncludeLlvmLines, + [switch]$IncludeBuildTimings, + [switch]$IncludeFlamegraph, + [string[]]$FlamegraphPackages = @('codeshape', 'refs') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Invoke-StrictNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-RequiredCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found on PATH: $Name" + } + + return $command.Source +} + +function Get-OptionalCommandPath { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + + return $command.Source +} + +function Resolve-XperfPath { + $commandPath = Get-OptionalCommandPath -Name 'xperf' + if (-not [string]::IsNullOrWhiteSpace($commandPath)) { + return $commandPath + } + + $candidates = @() + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if (-not [string]::IsNullOrWhiteSpace($root)) { + $candidates += (Join-Path $root 'Windows Kits\10\Windows Performance Toolkit\xperf.exe') + } + } + + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + return $null +} + +function Test-IsElevated { + if (-not $IsWindows) { + return $false + } + + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-FlamegraphCapability { + $capability = [ordered]@{ + platform = if ($IsWindows) { 'windows' } else { 'other' } + can_attempt = $true + cargo_flamegraph_backend = if ($IsWindows) { 'unknown' } else { 'platform-default' } + is_elevated = $false + elevation_wrapper = '.\scripts\cargo-flamegraph-windows.ps1' + dtrace_path = $null + dtrace_source = $null + xperf_path = $null + samply_path = $null + samply_available = $false + reason = 'Non-Windows platform; defer to cargo flamegraph defaults.' + note = $null + } + + if (-not $IsWindows) { + return [pscustomobject]$capability + } + + $capability.is_elevated = Test-IsElevated + $capability.samply_path = Get-OptionalCommandPath -Name 'samply' + $capability.xperf_path = Resolve-XperfPath + $capability.samply_available = ( + -not [string]::IsNullOrWhiteSpace([string]$capability.samply_path) -and + -not [string]::IsNullOrWhiteSpace([string]$capability.xperf_path) + ) + + $dtraceOverride = $env:DTRACE + if (-not [string]::IsNullOrWhiteSpace($dtraceOverride)) { + $resolvedOverride = $null + if (Test-Path -LiteralPath $dtraceOverride) { + $resolvedOverride = (Resolve-Path -LiteralPath $dtraceOverride).Path + } + else { + $resolvedCommand = Get-Command -Name $dtraceOverride -ErrorAction SilentlyContinue + if ($null -ne $resolvedCommand) { + $resolvedOverride = $resolvedCommand.Source + } + } + + if (-not [string]::IsNullOrWhiteSpace($resolvedOverride)) { + $capability.dtrace_path = $resolvedOverride + $capability.dtrace_source = 'env:DTRACE' + } + else { + $capability.note = "DTRACE is set but does not currently resolve: $dtraceOverride" + } + } + + if ([string]::IsNullOrWhiteSpace([string]$capability.dtrace_path)) { + $dtracePath = Get-OptionalCommandPath -Name 'dtrace' + if (-not [string]::IsNullOrWhiteSpace($dtracePath)) { + $capability.dtrace_path = $dtracePath + $capability.dtrace_source = 'PATH' + } + } + + if (-not [string]::IsNullOrWhiteSpace([string]$capability.dtrace_path)) { + $capability.cargo_flamegraph_backend = 'dtrace' + $capability.reason = 'DTrace detected; cargo flamegraph will prefer DTrace on Windows.' + if (-not $capability.is_elevated -and [string]::IsNullOrWhiteSpace([string]$capability.note)) { + $capability.note = 'Microsoft documents DTrace setup and validation from an elevated prompt; if collection fails, rerun from an elevated shell.' + } + + return [pscustomobject]$capability + } + + $capability.cargo_flamegraph_backend = 'blondie' + if ($capability.is_elevated) { + $capability.reason = 'No DTrace detected; cargo flamegraph will fall back to blondie, and the shell is elevated.' + } + else { + $capability.can_attempt = $false + $capability.reason = 'No DTrace detected; cargo flamegraph will fall back to blondie, which requires an elevated shell on Windows because it uses the ETW Kernel Logger session.' + if ($capability.samply_available) { + $capability.note = 'Use .\scripts\cargo-flamegraph-windows.ps1 for an auto-elevated one-shot flamegraph. samply is also available separately because xperf is installed, but cargo flamegraph itself still needs elevation or DTrace.' + } + elseif ([string]::IsNullOrWhiteSpace([string]$capability.note)) { + $capability.note = 'Use .\scripts\cargo-flamegraph-windows.ps1 for an auto-elevated one-shot flamegraph. samply is not a usable fallback on this machine because xperf from Windows Performance Toolkit is not installed.' + } + } + + return [pscustomobject]$capability +} + +function Get-BinaryTable { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + [Parameter(Mandatory = $true)] + [string]$TargetDir + ) + + return @( + Get-ChildItem (Join-Path $WorkspaceRoot $TargetDir) -Filter '*.exe' | + Sort-Object Length -Descending | + Select-Object Name, + @{ Name = 'SizeKB'; Expression = { [math]::Round($_.Length / 1KB, 1) } }, + @{ Name = 'SizeBytes'; Expression = { $_.Length } } + ) +} + +function New-BenchmarkCommands { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + [Parameter(Mandatory = $true)] + [string]$TargetDir + ) + + $binaryRoot = Join-Path $WorkspaceRoot $TargetDir + $fixtureRoot = Join-Path $WorkspaceRoot 'fixtures' + + return @( + ('"{0}" --sort-keys "{1}"' -f (Join-Path $binaryRoot 'cjson.exe'), (Join-Path $fixtureRoot 'cjson\sample.json')), + ('"{0}" level=error "{1}" --pick ts,msg' -f (Join-Path $binaryRoot 'jsonlgrep.exe'), (Join-Path $fixtureRoot 'jsonl\events.jsonl')), + ('"{0}" "{1}" --json' -f (Join-Path $binaryRoot 'jsonshape.exe'), (Join-Path $fixtureRoot 'toon\config.json')), + ('"{0}" --root "{1}" --since 9999h --ext rs --limit 20' -f (Join-Path $binaryRoot 'recent.exe'), $fixtureRoot), + ('"{0}" "{1}" --max-depth 2 --limit-per-file 12' -f (Join-Path $binaryRoot 'codeshape.exe'), (Join-Path $fixtureRoot 'polyglot\repo')), + ('"{0}" helper "{1}" --limit 6' -f (Join-Path $binaryRoot 'defsnip.exe'), (Join-Path $fixtureRoot 'polyglot\repo')), + ('"{0}" helper "{1}" --limit 12' -f (Join-Path $binaryRoot 'refs.exe'), (Join-Path $fixtureRoot 'polyglot\repo')), + ('"{0}" --context 1 "{1}:18" "{1}:26"' -f (Join-Path $binaryRoot 'hitsnip.exe'), (Join-Path $fixtureRoot 'reading\sample.rs')), + ('"{0}" "{1}"' -f (Join-Path $binaryRoot 'stringscan.exe'), (Join-Path $fixtureRoot 'binaries\stringscan-sample.bin')), + ('"{0}" "{1}" --json' -f (Join-Path $binaryRoot 'toon.exe'), (Join-Path $fixtureRoot 'toon\config.toon')), + ('"{0}" rg' -f (Join-Path $binaryRoot 'pathshadow.exe')), + ('"{0}" --env none --group shell' -f (Join-Path $binaryRoot 'sysshape.exe')) + ) +} + +function Get-FlamegraphTarget { + param( + [Parameter(Mandatory = $true)] + [string]$Package, + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $fixtureRoot = Join-Path $WorkspaceRoot 'fixtures' + + switch ($Package) { + 'codeshape' { + return @{ + Package = 'codeshape' + Binary = 'codeshape' + Arguments = @((Join-Path $fixtureRoot 'polyglot\repo'), '--max-depth', '2', '--limit-per-file', '12') + } + } + 'refs' { + return @{ + Package = 'refs' + Binary = 'refs' + Arguments = @('helper', (Join-Path $fixtureRoot 'polyglot\repo'), '--limit', '12') + } + } + 'jsonshape' { + return @{ + Package = 'jsonshape' + Binary = 'jsonshape' + Arguments = @((Join-Path $fixtureRoot 'toon\config.json'), '--json') + } + } + default { + return @{ + Package = $Package + Binary = $Package + Arguments = @('--help') + } + } + } +} + +function Assert-FlamegraphPackageName { + param( + [Parameter(Mandatory = $true)] + [string]$Package + ) + + if ([string]::IsNullOrWhiteSpace($Package)) { + throw 'Flamegraph package name must not be empty.' + } + + if ([System.IO.Path]::IsPathRooted($Package) -or $Package.Contains([System.IO.Path]::DirectorySeparatorChar) -or $Package.Contains([System.IO.Path]::AltDirectorySeparatorChar)) { + throw "Flamegraph package name must be a single package token: $Package" + } + + if ($Package.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw "Flamegraph package name contains invalid filename characters: $Package" + } +} + +function Write-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [object]$Value + ) + + $directory = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null + } + + $Value | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$cargoPath = Get-RequiredCommandPath -Name 'cargo' +$batPath = Get-OptionalCommandPath -Name 'bat' +$targetProfile = Resolve-ToolboxProfileName -Configuration $BuildProfile +$flamegraphTargetProfile = Resolve-ToolboxProfileName -Configuration $FlamegraphBuildProfile +$targetDir = Join-Path 'target' $targetProfile +$outputRoot = Join-Path (Join-Path $workspaceRoot 'dist') 'probe' +$profileOutputRoot = Join-Path $outputRoot $targetProfile +$benchmarkJsonPath = Join-Path $profileOutputRoot 'hyperfine.json' +$sizeJsonPath = Join-Path $profileOutputRoot 'binary-sizes.json' +$bloatRoot = Join-Path $profileOutputRoot 'bloat' +$llvmLinesRoot = Join-Path $profileOutputRoot 'llvm-lines' +$flamegraphRoot = Join-Path $profileOutputRoot 'flamegraphs' + +Write-Host "Mercury Toolbox probe" +Write-Host "workspace: $workspaceRoot" +Write-Host "profile: $BuildProfile" +Write-Host "target dir: $targetDir" +Write-Host "output root: $profileOutputRoot" + +New-Item -ItemType Directory -Force -Path $profileOutputRoot | Out-Null +if ($IncludeBuildTimings) { + Invoke-StrictToolboxNative -FilePath $cargoPath -ArgumentList ((Get-ToolboxBuildArguments -Configuration $BuildProfile) + '--timings') + if ($BuildProfile -ne 'Debug') { + Invoke-StrictToolboxNative -FilePath $cargoPath -ArgumentList ( + Get-ToolboxBuildArguments -Configuration $BuildProfile -Packages (Get-ToolboxSlimBuildCommandNames) + ) + } +} +else { + Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $BuildProfile +} + +$binaryTable = Get-BinaryTable -WorkspaceRoot $workspaceRoot -TargetDir $targetDir + +Write-Host '' +Write-Host 'Binary sizes:' +$binaryTable | Format-Table -AutoSize +Write-JsonFile -Path $sizeJsonPath -Value $binaryTable +Write-Host "Size report: $sizeJsonPath" + +if ($IncludeBuildTimings) { + $timingsDir = Join-Path (Join-Path $workspaceRoot 'target') 'cargo-timings' + $latestTiming = Get-ChildItem -LiteralPath $timingsDir -Filter 'cargo-timing*.html' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -ne $latestTiming) { + Write-Host "Build timings: $($latestTiming.FullName)" + } +} + +if (-not $SkipBenchmarks) { + $hyperfinePath = Get-RequiredCommandPath -Name 'hyperfine' + $benchmarkCommands = New-BenchmarkCommands -WorkspaceRoot $workspaceRoot -TargetDir $targetDir + $hyperfineArguments = @( + '--warmup', + $Warmup.ToString(), + '--runs', + $Runs.ToString(), + '--shell', + 'none', + '--export-json', + $benchmarkJsonPath + ) + $benchmarkCommands + Write-Host '' + Write-Host 'Representative latency benchmarks:' + Invoke-StrictNative -FilePath $hyperfinePath -ArgumentList $hyperfineArguments + Write-Host "Benchmark report: $benchmarkJsonPath" +} + +if ($IncludeBloat) { + $largest = $binaryTable | Select-Object -First 3 + New-Item -ItemType Directory -Force -Path $bloatRoot | Out-Null + + foreach ($binary in $largest) { + $packageName = [System.IO.Path]::GetFileNameWithoutExtension([string]$binary.Name) + $reportPath = Join-Path $bloatRoot "$packageName.txt" + Write-Host '' + Write-Host "cargo-bloat top crates for ${packageName}:" + & $cargoPath @( + 'bloat', + '--profile', + $targetProfile, + '-p', + $packageName, + '--crates', + '-n', + '20' + ) *> $reportPath + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: cargo bloat --profile $targetProfile -p $packageName --crates -n 20" + } + if ([string]::IsNullOrWhiteSpace($batPath)) { + Get-Content -LiteralPath $reportPath -TotalCount 24 + } + else { + & $batPath '--style=plain' '--paging=never' $reportPath | Select-Object -First 24 + } + Write-Host "Bloat report: $reportPath" + } +} + +if ($IncludeLlvmLines) { + $largest = $binaryTable | Select-Object -First 3 + New-Item -ItemType Directory -Force -Path $llvmLinesRoot | Out-Null + + foreach ($binary in $largest) { + $packageName = [System.IO.Path]::GetFileNameWithoutExtension([string]$binary.Name) + $reportPath = Join-Path $llvmLinesRoot "$packageName.txt" + Write-Host '' + Write-Host "cargo-llvm-lines top items for ${packageName}:" + & $cargoPath @( + 'llvm-lines', + '--profile', + $targetProfile, + '-p', + $packageName, + '--bin', + $packageName + ) *> $reportPath + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: cargo llvm-lines --profile $targetProfile -p $packageName --bin $packageName" + } + if ([string]::IsNullOrWhiteSpace($batPath)) { + Get-Content -LiteralPath $reportPath -TotalCount 80 + } + else { + & $batPath '--style=plain' '--paging=never' $reportPath | Select-Object -First 80 + } + Write-Host "LLVM lines report: $reportPath" + } +} + +if ($IncludeFlamegraph) { + [void](Get-RequiredCommandPath -Name 'cargo-flamegraph') + New-Item -ItemType Directory -Force -Path $flamegraphRoot | Out-Null + $flamegraphCapability = Get-FlamegraphCapability + $capabilityPath = Join-Path $flamegraphRoot 'capability.json' + Write-JsonFile -Path $capabilityPath -Value $flamegraphCapability + Write-Host "Flamegraph capability: $capabilityPath" + if (-not [bool]$flamegraphCapability.can_attempt) { + Write-Warning "Skipping flamegraph collection on this machine: $($flamegraphCapability.reason)" + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$flamegraphCapability.note)) { + Write-Warning $flamegraphCapability.note + } + + foreach ($package in $FlamegraphPackages) { + Assert-FlamegraphPackageName -Package $package + $target = Get-FlamegraphTarget -Package $package -WorkspaceRoot $workspaceRoot + $outputPath = Join-Path $flamegraphRoot "$($target.Package).svg" + $logPath = Join-Path $flamegraphRoot "$($target.Package).log" + $errorPath = Join-Path $flamegraphRoot "$($target.Package)-error.txt" + $arguments = @( + 'flamegraph', + '--profile', + $flamegraphTargetProfile, + '-p', + $target.Package, + '--bin', + $target.Binary, + '-o', + $outputPath, + '--' + ) + $target.Arguments + + Write-Host '' + Write-Host "cargo-flamegraph for $($target.Package):" + if (-not [bool]$flamegraphCapability.can_attempt) { + Write-JsonFile -Path $errorPath -Value ([ordered]@{ + package = $target.Package + skipped = $true + reason = $flamegraphCapability.reason + profiler = $flamegraphCapability + }) + Write-Host "Flamegraph skipped: $errorPath" + continue + } + + try { + & $cargoPath @arguments *> $logPath + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: cargo $($arguments -join ' ')" + } + Write-Host "Flamegraph: $outputPath" + } + catch { + Write-Warning "Skipping flamegraph for $($target.Package): $($_.Exception.Message)" + Write-JsonFile -Path $errorPath -Value ([ordered]@{ + package = $target.Package + command = @('cargo') + $arguments + error = $_.Exception.Message + log_path = $logPath + profiler = $flamegraphCapability + }) + Write-Host "Flamegraph error log: $errorPath" + } + } +} + +$finalBinaryTable = Get-BinaryTable -WorkspaceRoot $workspaceRoot -TargetDir $targetDir +if (($finalBinaryTable | ConvertTo-Json -Depth 4) -cne ($binaryTable | ConvertTo-Json -Depth 4)) { + Write-Host '' + Write-Host 'Binary sizes changed during probe; refreshing final size report:' + $finalBinaryTable | Format-Table -AutoSize + Write-JsonFile -Path $sizeJsonPath -Value $finalBinaryTable + Write-Host "Refreshed size report: $sizeJsonPath" +} diff --git a/scripts/publish-gitea-release.ps1 b/scripts/publish-gitea-release.ps1 new file mode 100644 index 0000000..8a6a0fd --- /dev/null +++ b/scripts/publish-gitea-release.ps1 @@ -0,0 +1,280 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^v\d+\.\d+\.\d+.*$')] + [string]$Tag, + [string]$NotesPath, + [string]$BaseUrl, + [string]$Owner, + [string]$Repo, + [string]$ApiToken, + [string]$DistRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'dist') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-GitCapture { + param( + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $output = & git @ArgumentList 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")" + } + + return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() +} + +function Resolve-GiteaRepositoryContext { + param( + [string]$RemoteUrl + ) + + if ([string]::IsNullOrWhiteSpace($RemoteUrl)) { + $RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', 'origin') + } + + $normalized = $RemoteUrl.Trim() + if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') { + return [pscustomobject]@{ + BaseUrl = $Matches[1] + Owner = $Matches[2] + Repo = $Matches[3] + } + } + + if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') { + $scheme = 'https' + $giteaHost = $Matches[1] + $port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" } + return [pscustomobject]@{ + BaseUrl = "${scheme}://$giteaHost$port" + Owner = $Matches[3] + Repo = $Matches[4] + } + } + + throw "Could not parse Gitea origin remote URL: $normalized" +} + +function Resolve-ApiToken { + param( + [string]$BaseUrl, + [string]$Owner, + [string]$Repo, + [string]$ExplicitToken + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) { + return $ExplicitToken + } + + foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + + $uri = [Uri]$BaseUrl + $lines = @( + 'protocol=https' + "host=$($uri.Authority)" + "path=$Owner/$Repo.git" + '' + ) | git credential fill + $passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1 + if ($null -eq $passwordLine) { + throw 'Could not resolve a Gitea API token from the current environment or git credential manager.' + } + + return $passwordLine.Substring(9) +} + +function Invoke-GiteaApi { + param( + [Parameter(Mandatory = $true)] + [string]$Method, + [Parameter(Mandatory = $true)] + [string]$Uri, + [string]$Token, + [object]$Body + ) + + $headers = @{ + Authorization = "token $Token" + } + + $invokeArgs = @{ + Method = $Method + Uri = $Uri + Headers = $headers + ErrorAction = 'Stop' + } + + if ($PSBoundParameters.ContainsKey('Body')) { + $invokeArgs.ContentType = 'application/json' + $invokeArgs.Body = ($Body | ConvertTo-Json -Depth 10) + } + + try { + return Invoke-RestMethod @invokeArgs + } + catch { + $response = $_.Exception.Response + if ($null -eq $response) { + throw + } + $statusCode = [int]$response.StatusCode + $payload = '' + if ($response -is [System.Net.Http.HttpResponseMessage]) { + if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) { + $payload = $_.ErrorDetails.Message + } + elseif ($null -ne $response.Content) { + try { + $payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } + catch { + $payload = '' + } + } + } + elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') { + $stream = $response.GetResponseStream() + if ($null -ne $stream) { + $reader = [System.IO.StreamReader]::new($stream) + try { + $payload = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + $stream.Dispose() + } + } + } + throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload" + } +} + +function Find-ReleaseArtifacts { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath + ) + + if (-not (Test-Path -LiteralPath $RootPath)) { + throw "dist root not found: $RootPath" + } + + $zip = Get-ChildItem -LiteralPath $RootPath -File -Filter 'MercuryToolbox-*-ReleaseFast.zip' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $zip) { + throw "No packaged archive found under $RootPath. Run just package first." + } + + $packageRoot = Join-Path $RootPath $zip.BaseName + if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { + throw "Package directory matching archive was not found: $packageRoot" + } + + $hashPath = Join-Path $packageRoot 'SHA256SUMS.txt' + $manifestPath = Join-Path $packageRoot 'mercury-toolbox-package.json' + foreach ($requiredPath in @($hashPath, $manifestPath)) { + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "Expected packaged artifact not found: $requiredPath" + } + } + + return @($zip.FullName, $hashPath, $manifestPath) +} + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $workspaceRoot + +try { + $context = Resolve-GiteaRepositoryContext + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = $context.BaseUrl + } + if ([string]::IsNullOrWhiteSpace($Owner)) { + $Owner = $context.Owner + } + if ([string]::IsNullOrWhiteSpace($Repo)) { + $Repo = $context.Repo + } + if ([string]::IsNullOrWhiteSpace($NotesPath)) { + $NotesPath = Join-Path $workspaceRoot (Join-Path 'docs\releases' "$Tag.md") + } + $ApiToken = Resolve-ApiToken -BaseUrl $BaseUrl -Owner $Owner -Repo $Repo -ExplicitToken $ApiToken + if (-not (Test-Path -LiteralPath $NotesPath -PathType Leaf)) { + throw "Release notes not found: $NotesPath" + } + + $notes = Get-Content -Raw -LiteralPath $NotesPath + $releaseName = "Mercury Toolbox $Tag" + $apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo" + $targetCommitish = Invoke-GitCapture -ArgumentList @('rev-parse', 'HEAD') + + $release = $null + try { + $release = Invoke-GiteaApi -Method 'GET' -Uri "$apiBase/releases/tags/$Tag" -Token $ApiToken + } + catch { + if (-not $_.Exception.Message.Contains('HTTP 404')) { + throw + } + } + + if ($null -eq $release) { + $release = Invoke-GiteaApi -Method 'POST' -Uri "$apiBase/releases" -Token $ApiToken -Body @{ + tag_name = $Tag + target_commitish = $targetCommitish + name = $releaseName + body = $notes + draft = $false + prerelease = $false + } + } + else { + $release = Invoke-GiteaApi -Method 'PATCH' -Uri "$apiBase/releases/$($release.id)" -Token $ApiToken -Body @{ + tag_name = $Tag + target_commitish = $targetCommitish + name = $releaseName + body = $notes + draft = $false + prerelease = $false + } + } + + $assets = @() + if ($release.PSObject.Properties.Name -contains 'assets' -and $null -ne $release.assets) { + $assets = @($release.assets) + } + + foreach ($assetPath in (Find-ReleaseArtifacts -RootPath $DistRoot)) { + $assetName = [System.IO.Path]::GetFileName($assetPath) + $existing = $assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 + if ($null -ne $existing) { + Invoke-GiteaApi -Method 'DELETE' -Uri "$apiBase/releases/$($release.id)/assets/$($existing.id)" -Token $ApiToken | Out-Null + } + + $uploadHeaders = @{ + Authorization = "token $ApiToken" + } + $uploadUri = "$apiBase/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))" + Invoke-RestMethod -Method 'POST' -Uri $uploadUri -Headers $uploadHeaders -Form @{ + attachment = Get-Item -LiteralPath $assetPath + } -ErrorAction Stop | Out-Null + Write-Host "Uploaded release asset: $assetName" + } + + Write-Host "Release published: $($release.html_url)" +} +finally { + Pop-Location +} diff --git a/scripts/setup-gitea-runner.ps1 b/scripts/setup-gitea-runner.ps1 new file mode 100644 index 0000000..77792db --- /dev/null +++ b/scripts/setup-gitea-runner.ps1 @@ -0,0 +1,319 @@ +[CmdletBinding()] +param( + [string]$InstanceUrl, + [string]$Owner, + [string]$Repo, + [string]$RunnerName, + [string]$RunnerRoot, + [string]$ApiToken, + [switch]$SkipInstall, + [switch]$SkipStartupRegistration +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-GitCapture { + param( + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $output = & git @ArgumentList 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")" + } + + return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() +} + +function Resolve-GiteaRepositoryContext { + $remoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', 'origin') + $normalized = $remoteUrl.Trim() + + if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') { + return [pscustomobject]@{ + BaseUrl = $Matches[1] + Owner = $Matches[2] + Repo = $Matches[3] + } + } + + if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') { + $giteaHost = $Matches[1] + $port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" } + return [pscustomobject]@{ + BaseUrl = "https://$giteaHost$port" + Owner = $Matches[3] + Repo = $Matches[4] + } + } + + throw "Could not parse Gitea origin remote URL: $normalized" +} + +function Resolve-ApiToken { + param( + [Parameter(Mandatory = $true)] + [string]$BaseUrl, + [Parameter(Mandatory = $true)] + [string]$RepoOwner, + [Parameter(Mandatory = $true)] + [string]$RepoName, + [string]$ExplicitToken + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) { + return $ExplicitToken + } + + foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + + $uri = [Uri]$BaseUrl + $lines = @( + 'protocol=https' + "host=$($uri.Authority)" + "path=$RepoOwner/$RepoName.git" + '' + ) | git credential fill + $passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1 + if ($null -eq $passwordLine) { + throw 'Could not resolve a Gitea API token from the current environment or git credential manager.' + } + + return $passwordLine.Substring(9) +} + +function Invoke-GiteaApi { + param( + [Parameter(Mandatory = $true)] + [string]$Method, + [Parameter(Mandatory = $true)] + [string]$Uri, + [Parameter(Mandatory = $true)] + [string]$Token + ) + + $headers = @{ + Authorization = "token $Token" + } + + try { + return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $headers -ErrorAction Stop + } + catch { + $response = $_.Exception.Response + if ($null -eq $response) { + throw + } + $statusCode = [int]$response.StatusCode + $payload = '' + if ($response -is [System.Net.Http.HttpResponseMessage]) { + if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) { + $payload = $_.ErrorDetails.Message + } + elseif ($null -ne $response.Content) { + try { + $payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } + catch { + $payload = '' + } + } + } + elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') { + $stream = $response.GetResponseStream() + if ($null -ne $stream) { + $reader = [System.IO.StreamReader]::new($stream) + try { + $payload = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + $stream.Dispose() + } + } + } + throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload" + } +} + +function Resolve-RunnerExe { + $command = Get-Command -Name 'gitea-runner.exe' -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $command = Get-Command -Name 'gitea-runner' -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $candidate = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Gitea.Runner_Microsoft.Winget.Source_8wekyb3d8bbwe\gitea-runner.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + + if ($SkipInstall) { + throw 'gitea-runner was not found and -SkipInstall was requested.' + } + + & winget install --id Gitea.Runner -e --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + throw "winget install Gitea.Runner failed with exit code $LASTEXITCODE" + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { + throw "gitea-runner was installed but not found at expected path: $candidate" + } + + return $candidate +} + +$context = Resolve-GiteaRepositoryContext +if ([string]::IsNullOrWhiteSpace($InstanceUrl)) { + $InstanceUrl = $context.BaseUrl +} +if ([string]::IsNullOrWhiteSpace($Owner)) { + $Owner = $context.Owner +} +if ([string]::IsNullOrWhiteSpace($Repo)) { + $Repo = $context.Repo +} +if ([string]::IsNullOrWhiteSpace($RunnerName)) { + $RunnerName = "$Repo-local-win" +} +if ([string]::IsNullOrWhiteSpace($RunnerRoot)) { + $RunnerRoot = Join-Path $env:LOCALAPPDATA "GiteaRunner\$Repo" +} + +$runnerExe = Resolve-RunnerExe +$ApiToken = Resolve-ApiToken -BaseUrl $InstanceUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken +$registration = Invoke-GiteaApi -Method 'POST' -Uri "$InstanceUrl/api/v1/repos/$Owner/$Repo/actions/runners/registration-token" -Token $ApiToken +$registrationToken = [string]$registration.token +if ([string]::IsNullOrWhiteSpace($registrationToken)) { + throw 'Gitea did not return a runner registration token.' +} + +$configPath = Join-Path $RunnerRoot 'config.yaml' +$registrationPath = Join-Path $RunnerRoot '.runner' +$logPath = Join-Path $RunnerRoot 'daemon.log' +$startScriptPath = Join-Path $RunnerRoot 'start-runner.ps1' +$startupFolder = [Environment]::GetFolderPath('Startup') +$startupCmdPath = Join-Path $startupFolder "$Repo-gitea-runner.cmd" +$workdirParent = Join-Path $RunnerRoot 'workdir' + +New-Item -ItemType Directory -Force -Path $RunnerRoot, $workdirParent | Out-Null + +$fullPath = [string]::Join(';', (@( + [Environment]::GetEnvironmentVariable('Path', 'User'), + [Environment]::GetEnvironmentVariable('Path', 'Machine') +) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) +$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $HOME '.cargo' } +$rustupHome = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $HOME '.rustup' } +$repoHostLabel = "${Repo}:host" + +$config = @" +log: + level: info + +runner: + file: .runner + capacity: 1 + envs: + PATH: $fullPath + CARGO_HOME: $cargoHome + RUSTUP_HOME: $rustupHome + CARGO_INCREMENTAL: '0' + env_file: .env + timeout: 3h + shutdown_timeout: 30s + insecure: false + fetch_timeout: 5s + fetch_interval: 2s + fetch_interval_max: 5s + workdir_cleanup_age: 24h + idle_cleanup_interval: 10m + labels: + - 'windows-amd64:host' + - 'windows:host' + - '$repoHostLabel' + allocate_pty: false + +cache: + enabled: true + dir: '' + host: '' + port: 0 + external_server: '' + external_secret: '' + offline_mode: false + +container: + network: '' + privileged: false + options: + workdir_parent: + valid_volumes: [] + docker_host: '' + force_pull: true + force_rebuild: false + require_docker: false + docker_timeout: 0s + bind_workdir: false + +host: + workdir_parent: '$($workdirParent -replace '\\', '/')' + +metrics: + enabled: false + addr: '127.0.0.1:9101' +"@ +Set-Content -LiteralPath $configPath -Value $config -Encoding utf8NoBOM + +if (-not (Test-Path -LiteralPath $registrationPath)) { + Push-Location -LiteralPath $RunnerRoot + try { + & $runnerExe register --no-interactive --config $configPath --instance $InstanceUrl --token $registrationToken --name $RunnerName + if ($LASTEXITCODE -ne 0) { + throw "gitea-runner register failed with exit code $LASTEXITCODE" + } + } + finally { + Pop-Location + } +} + +$startScript = @" +`$ErrorActionPreference = 'Stop' +Set-Location '$RunnerRoot' +& '$runnerExe' daemon --config '$configPath' *>> '$logPath' +"@ +Set-Content -LiteralPath $startScriptPath -Value $startScript -Encoding utf8NoBOM + +if (-not $SkipStartupRegistration) { + $startupCmd = @" +@echo off +powershell -NoProfile -WindowStyle Hidden -File "$startScriptPath" +"@ + Set-Content -LiteralPath $startupCmdPath -Value $startupCmd -Encoding ascii +} + +$existing = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'gitea-runner.exe' -and $_.CommandLine -like "*$configPath*" } +if ($null -eq $existing) { + Start-Process -FilePath $runnerExe -ArgumentList @('daemon', '--config', $configPath) -WorkingDirectory $RunnerRoot -WindowStyle Hidden | Out-Null + Start-Sleep -Seconds 3 +} + +$runners = Invoke-GiteaApi -Method 'GET' -Uri "$InstanceUrl/api/v1/repos/$Owner/$Repo/actions/runners" -Token $ApiToken +$matched = @($runners.runners | Where-Object { $_.name -eq $RunnerName }) +if ($matched.Count -eq 0) { + throw "Runner $RunnerName was not visible in the repository runner list after setup." +} + +$matched | ConvertTo-Json -Depth 6 diff --git a/scripts/sync-json-family-specs.ps1 b/scripts/sync-json-family-specs.ps1 new file mode 100644 index 0000000..24a53d1 --- /dev/null +++ b/scripts/sync-json-family-specs.ps1 @@ -0,0 +1,57 @@ +[CmdletBinding()] +param( + [string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'third_party\json-family'), + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$sources = @( + @{ + Name = 'toon' + Url = 'https://github.com/toon-format/toon.git' + }, + @{ + Name = 'ison' + Url = 'https://github.com/ISON-format/ison.git' + }, + @{ + Name = 'zon' + Url = 'https://github.com/ZON-Format/zon-TS.git' + }, + @{ + Name = 'tonl' + Url = 'https://github.com/tonl-dev/tonl.git' + } +) + +New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null + +foreach ($source in $sources) { + $target = Join-Path $OutputRoot $source.Name + if (Test-Path -LiteralPath $target) { + if ($Force) { + Remove-Item -LiteralPath $target -Recurse -Force + } + else { + git -C $target fetch --all --tags --prune + git -C $target pull --ff-only + if ($LASTEXITCODE -ne 0) { + throw "Failed to update $($source.Name) specs in $target" + } + continue + } + } + + git clone --depth 1 $source.Url $target + if ($LASTEXITCODE -ne 0) { + throw "Failed to clone $($source.Name) specs from $($source.Url)" + } +} + +@{ + generated_at = (Get-Date).ToUniversalTime().ToString('o') + purpose = 'Spec, fixture, and golden-test source snapshots only; not runtime dependencies.' + sources = $sources +} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputRoot 'manifest.json') -Encoding utf8NoBOM diff --git a/scripts/toolbox-commands.ps1 b/scripts/toolbox-commands.ps1 new file mode 100644 index 0000000..5f7064a --- /dev/null +++ b/scripts/toolbox-commands.ps1 @@ -0,0 +1,180 @@ +function Get-ToolboxCommandNames { + return @( + 'cjson', + 'ison', + 'isonl', + 'zon', + 'tonl', + 'mhash', + 'jsonlgrep', + 'jsonshape', + 'recent', + 'pathshadow', + 'portping', + 'portunlock', + 'msudo', + 'asmtype', + 'asmmember', + 'asmref', + 'asmapi', + 'asmflow', + 'llvmobjdump', + 'llvmreadobj', + 'llvmnm', + 'peexports', + 'peimports', + 'pecalls', + 'pesig', + 'pestrrefs', + 'drvshape', + 'ioctlscan', + 'binmeta', + 'fileprobe', + 'outline', + 'codeshape', + 'refs', + 'snip', + 'defsnip', + 'ctxpack', + 'chunkcat', + 'hitsnip', + 'diagpick', + 'logshape', + 'stringscan', + 'toon', + 'csvshape', + 'sqliteshape', + 'sqlshape', + 'unityasset', + 'unityprobe', + 'unitydiag', + 'envdiff', + 'proctree', + 'sysshape', + 'runprobe', + 'await', + 'argv', + 'config', + 'gitshape', + 'reposhape', + 'dotnetshape', + 'unlock' + ) +} + +function Get-ToolboxSlimBuildCommandNames { + return @( + 'codeshape', + 'refs', + 'defsnip' + ) +} + +function Resolve-ToolboxProfileName { + param( + [Parameter(Mandatory = $true)] + [string]$Configuration + ) + + switch ($Configuration) { + 'Debug' { return 'debug' } + 'Release' { return 'release' } + 'ReleaseFast' { return 'release-fast' } + 'ReleaseSize' { return 'release-size' } + default { throw "Unsupported configuration: $Configuration" } + } +} + +function Get-ToolboxBuildArguments { + param( + [Parameter(Mandatory = $true)] + [string]$Configuration, + [string[]]$Packages = @() + ) + + $arguments = @('build') + if ($Packages.Count -gt 0) { + foreach ($package in $Packages) { + $arguments += @('-p', $package) + } + } + else { + $arguments += '--workspace' + } + + switch ($Configuration) { + 'Release' { + $arguments += '--release' + } + 'ReleaseFast' { + $arguments += @('--profile', 'release-fast') + } + 'ReleaseSize' { + $arguments += @('--profile', 'release-size') + } + } + + return $arguments +} + +function Format-ToolboxNativeCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($part in @($FilePath) + $ArgumentList) { + if ([string]::IsNullOrEmpty($part)) { + $parts.Add("''") + continue + } + + if ($part.IndexOfAny([char[]]" `t`r`n'`"") -ge 0) { + $parts.Add("'" + $part.Replace("'", "''") + "'") + continue + } + + $parts.Add($part) + } + + return ($parts -join ' ') +} + +function Invoke-StrictToolboxNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + $commandLine = Format-ToolboxNativeCommand -FilePath $FilePath -ArgumentList $ArgumentList + throw "Command failed with exit code ${LASTEXITCODE} in '$((Get-Location).Path)': $commandLine" + } +} + +function Invoke-ToolboxBuild { + param( + [string]$CargoPath = 'cargo', + [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] + [string]$Configuration = 'ReleaseFast', + [switch]$SkipSlimBinaryRebuild + ) + + Invoke-StrictToolboxNative -FilePath $CargoPath -ArgumentList (Get-ToolboxBuildArguments -Configuration $Configuration) + + if ($SkipSlimBinaryRebuild -or $Configuration -eq 'Debug') { + return + } + + foreach ($commandName in Get-ToolboxSlimBuildCommandNames) { + Invoke-StrictToolboxNative -FilePath $CargoPath -ArgumentList ( + Get-ToolboxBuildArguments -Configuration $Configuration -Packages @($commandName) + ) + } +} diff --git a/scripts/uninstall-package-toolbox.ps1 b/scripts/uninstall-package-toolbox.ps1 new file mode 100644 index 0000000..06a111a --- /dev/null +++ b/scripts/uninstall-package-toolbox.ps1 @@ -0,0 +1,221 @@ +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] + [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'), + [string]$CodexHome +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Normalize-PathValue { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return '' + } + + return $PathValue.Replace('/', '\').Trim().TrimEnd('\').ToLowerInvariant() +} + +function Split-PathEntries { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return @() + } + + return $PathValue.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) +} + +function Remove-PathEntry { + param( + [AllowNull()] + [string]$ExistingPath, + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + $normalizedEntry = Normalize-PathValue -PathValue $Entry + $remaining = [System.Collections.Generic.List[string]]::new() + $changed = $false + + foreach ($existing in (Split-PathEntries -PathValue $ExistingPath)) { + if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { + $changed = $true + continue + } + $remaining.Add($existing) + } + + return @{ + Changed = $changed + Value = ($remaining -join ';') + } +} + +function Resolve-CodexHomePath { + param( + [AllowNull()] + [string]$ExplicitCodexHome + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitCodexHome)) { + return $ExplicitCodexHome + } + + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + return $env:CODEX_HOME + } + + return (Join-Path $env:USERPROFILE '.codex') +} + +function Test-CodexSkillOwnedByMercury { + param( + [Parameter(Mandatory = $true)] + [string]$SkillRoot + ) + + $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' + if (-not (Test-Path -LiteralPath $markerPath)) { + return $false + } + + try { + $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json + return [string]$marker.owner -eq 'mercury-toolbox' + } + catch { + return $false + } +} + +function Assert-ExistingPathNotReparsePoint { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $item = Get-Item -LiteralPath $resolvedPath -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description must not be a reparse point: $resolvedPath" + } +} + +function Assert-ExistingTreeHasNoReparsePoints { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $resolvedPath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "$Description must not contain reparse points: $($reparsePoints[0].FullName)" + } +} + +$legacyBinDir = Join-Path $InstallRoot 'bin' +$currentRoot = Join-Path $InstallRoot 'current' +$activeBinDir = Join-Path $currentRoot 'bin' +$versionsRoot = Join-Path $InstallRoot 'versions' +$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox' +$codexSkillRoot = Join-Path (Resolve-CodexHomePath -ExplicitCodexHome $CodexHome) 'skills\mercury-toolbox' +$legacyCommandNames = @('context', 'waitfor') + +Assert-ExistingPathNotReparsePoint -Path $InstallRoot -Description 'InstallRoot' +Assert-ExistingPathNotReparsePoint -Path $versionsRoot -Description 'versions root' +Assert-ExistingPathNotReparsePoint -Path $shareRoot -Description 'share root' +Assert-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root' +Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root' +Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root' + +if (Test-Path -LiteralPath $legacyBinDir) { + foreach ($commandName in Get-ToolboxCommandNames) { + $commandPath = Join-Path $legacyBinDir "$commandName.exe" + if (Test-Path -LiteralPath $commandPath) { + Remove-Item -LiteralPath $commandPath -Force + } + } + + foreach ($legacyName in $legacyCommandNames) { + $legacyPath = Join-Path $legacyBinDir "$legacyName.exe" + if (Test-Path -LiteralPath $legacyPath) { + Remove-Item -LiteralPath $legacyPath -Force + } + } + + $remainingBinItems = @(Get-ChildItem -LiteralPath $legacyBinDir -Force) + if ($remainingBinItems.Count -eq 0) { + Remove-Item -LiteralPath $legacyBinDir -Force + } +} + +if (Test-Path -LiteralPath $currentRoot) { + Remove-Item -LiteralPath $currentRoot -Force +} + +if (Test-Path -LiteralPath $versionsRoot) { + Remove-Item -LiteralPath $versionsRoot -Recurse -Force +} + +if (Test-Path -LiteralPath $shareRoot) { + Remove-Item -LiteralPath $shareRoot -Recurse -Force +} + +if (Test-Path -LiteralPath $codexSkillRoot) { + if (Test-CodexSkillOwnedByMercury -SkillRoot $codexSkillRoot) { + Remove-Item -LiteralPath $codexSkillRoot -Recurse -Force + } + else { + Write-Warning "Skipping Codex skill removal because Mercury Toolbox does not own this skill copy: $codexSkillRoot" + } +} + +if (Test-Path -LiteralPath $InstallRoot) { + $remainingItems = @(Get-ChildItem -LiteralPath $InstallRoot -Force) + if ($remainingItems.Count -eq 0) { + Remove-Item -LiteralPath $InstallRoot -Force + } +} + +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +foreach ($entry in @($legacyBinDir, $activeBinDir)) { + $pathUpdate = Remove-PathEntry -ExistingPath $userPath -Entry $entry + if ($pathUpdate.Changed) { + $userPath = $pathUpdate.Value + } +} +[Environment]::SetEnvironmentVariable('Path', $userPath, 'User') + +foreach ($entry in @($legacyBinDir, $activeBinDir)) { + $sessionUpdate = Remove-PathEntry -ExistingPath $env:Path -Entry $entry + if ($sessionUpdate.Changed) { + $env:Path = $sessionUpdate.Value + } +} + +Write-Host "Mercury Toolbox package removed from $InstallRoot" +Write-Host "Removed Codex skill root: $codexSkillRoot" diff --git a/scripts/uninstall-toolbox.ps1 b/scripts/uninstall-toolbox.ps1 new file mode 100644 index 0000000..6740c76 --- /dev/null +++ b/scripts/uninstall-toolbox.ps1 @@ -0,0 +1,221 @@ +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] + [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'), + [string]$CodexHome +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'toolbox-commands.ps1') + +function Normalize-PathValue { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return '' + } + + return $PathValue.Replace('/', '\').Trim().TrimEnd('\').ToLowerInvariant() +} + +function Split-PathEntries { + param( + [AllowNull()] + [string]$PathValue + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return @() + } + + return $PathValue.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) +} + +function Remove-PathEntry { + param( + [AllowNull()] + [string]$ExistingPath, + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + $normalizedEntry = Normalize-PathValue -PathValue $Entry + $remaining = [System.Collections.Generic.List[string]]::new() + $changed = $false + + foreach ($existing in (Split-PathEntries -PathValue $ExistingPath)) { + if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { + $changed = $true + continue + } + $remaining.Add($existing) + } + + return @{ + Changed = $changed + Value = ($remaining -join ';') + } +} + +function Resolve-CodexHomePath { + param( + [AllowNull()] + [string]$ExplicitCodexHome + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitCodexHome)) { + return $ExplicitCodexHome + } + + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + return $env:CODEX_HOME + } + + return (Join-Path $env:USERPROFILE '.codex') +} + +function Test-CodexSkillOwnedByMercury { + param( + [Parameter(Mandatory = $true)] + [string]$SkillRoot + ) + + $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' + if (-not (Test-Path -LiteralPath $markerPath)) { + return $false + } + + try { + $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json + return [string]$marker.owner -eq 'mercury-toolbox' + } + catch { + return $false + } +} + +function Assert-ExistingPathNotReparsePoint { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $item = Get-Item -LiteralPath $resolvedPath -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description must not be a reparse point: $resolvedPath" + } +} + +function Assert-ExistingTreeHasNoReparsePoints { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + return + } + + $reparsePoints = @(Get-ChildItem -LiteralPath $resolvedPath -Force -Recurse -Attributes ReparsePoint) + if ($reparsePoints.Count -gt 0) { + throw "$Description must not contain reparse points: $($reparsePoints[0].FullName)" + } +} + +$legacyBinDir = Join-Path $InstallRoot 'bin' +$currentRoot = Join-Path $InstallRoot 'current' +$activeBinDir = Join-Path $currentRoot 'bin' +$versionsRoot = Join-Path $InstallRoot 'versions' +$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox' +$codexSkillRoot = Join-Path (Resolve-CodexHomePath -ExplicitCodexHome $CodexHome) 'skills\mercury-toolbox' +$legacyCommandNames = @('context', 'waitfor') + +Assert-ExistingPathNotReparsePoint -Path $InstallRoot -Description 'InstallRoot' +Assert-ExistingPathNotReparsePoint -Path $versionsRoot -Description 'versions root' +Assert-ExistingPathNotReparsePoint -Path $shareRoot -Description 'share root' +Assert-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root' +Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root' +Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root' + +if (Test-Path -LiteralPath $legacyBinDir) { + foreach ($commandName in Get-ToolboxCommandNames) { + $commandPath = Join-Path $legacyBinDir "$commandName.exe" + if (Test-Path -LiteralPath $commandPath) { + Remove-Item -LiteralPath $commandPath -Force + } + } + + foreach ($legacyName in $legacyCommandNames) { + $legacyPath = Join-Path $legacyBinDir "$legacyName.exe" + if (Test-Path -LiteralPath $legacyPath) { + Remove-Item -LiteralPath $legacyPath -Force + } + } + + $remainingBinItems = @(Get-ChildItem -LiteralPath $legacyBinDir -Force) + if ($remainingBinItems.Count -eq 0) { + Remove-Item -LiteralPath $legacyBinDir -Force + } +} + +if (Test-Path -LiteralPath $currentRoot) { + Remove-Item -LiteralPath $currentRoot -Force +} + +if (Test-Path -LiteralPath $versionsRoot) { + Remove-Item -LiteralPath $versionsRoot -Recurse -Force +} + +if (Test-Path -LiteralPath $shareRoot) { + Remove-Item -LiteralPath $shareRoot -Recurse -Force +} + +if (Test-Path -LiteralPath $codexSkillRoot) { + if (Test-CodexSkillOwnedByMercury -SkillRoot $codexSkillRoot) { + Remove-Item -LiteralPath $codexSkillRoot -Recurse -Force + } + else { + Write-Warning "Skipping Codex skill removal because Mercury Toolbox does not own this skill copy: $codexSkillRoot" + } +} + +if (Test-Path -LiteralPath $InstallRoot) { + $remainingItems = @(Get-ChildItem -LiteralPath $InstallRoot -Force) + if ($remainingItems.Count -eq 0) { + Remove-Item -LiteralPath $InstallRoot -Force + } +} + +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +foreach ($entry in @($legacyBinDir, $activeBinDir)) { + $pathUpdate = Remove-PathEntry -ExistingPath $userPath -Entry $entry + if ($pathUpdate.Changed) { + $userPath = $pathUpdate.Value + } +} +[Environment]::SetEnvironmentVariable('Path', $userPath, 'User') + +foreach ($entry in @($legacyBinDir, $activeBinDir)) { + $sessionUpdate = Remove-PathEntry -ExistingPath $env:Path -Entry $entry + if ($sessionUpdate.Changed) { + $env:Path = $sessionUpdate.Value + } +} + +Write-Host "Mercury Toolbox removed from $InstallRoot" +Write-Host "Removed Codex skill root: $codexSkillRoot" diff --git a/skills/mercury-toolbox/SKILL.md b/skills/mercury-toolbox/SKILL.md new file mode 100644 index 0000000..2d69130 --- /dev/null +++ b/skills/mercury-toolbox/SKILL.md @@ -0,0 +1,58 @@ +--- +name: mercury-toolbox +description: 'Use whenever Mercury Toolbox binaries are present and the task is local terminal inspection or triage: code and log reading, structured-data shaping, repo or runtime diagnosis, process, port, or lock troubleshooting, managed assembly or Unity analysis, or when the agent would otherwise reach for Get-Content, cat, tree, grep, findstr, netstat, tasklist, or ad-hoc PowerShell glue.' +--- + +# Mercury Toolbox + +Use this skill immediately when Mercury Toolbox binaries are present and the job is local terminal inspection, shaping, or triage. Read it first, then route through Mercury before falling back to raw dumps, legacy builtins, or ad-hoc shell glue. + +## First Choice Rules + +- Prefer Mercury readers over `Get-Content`, `cat`, `tree`, `tasklist`, `netstat`, or whole-file dumps. +- For source and logs, usually start with `fileprobe`, `outline`, `snip`, or `chunkcat`, then move to `hitsnip`, `defsnip`, `refs`, `codeshape`, or `ctxpack` as needed. +- Prefer compact text by default, switch to `--json` when the next step parses the result, and switch to `--toon` or `--format toon` when the next consumer is an AI/model. +- Pipe external JSON into `toon` only when the producer is not a Mercury tool; TOON auto-detects JSON and emits dense TOON by default. +- Prefer stdin and pipelines over reopening the same file repeatedly. +- Pair Mercury with modern CLI companions instead of legacy builtins. +- Treat `msudo` as the top-level high-risk toolbox command: start with `msudo status --json` or `--help` before considering any privileged launch. + +## Modern Pairings + +- Search and files: `rg`, `fd`, and optionally `fzf` or `PSFzf` for interactive narrowing. +- Viewing and editing: `bat`, `hexyl`, and `nvim` for bounded human reads instead of raw file dumps. +- Data and text: `jq`, `yq`, and `sd` for structured queries and small safe rewrites. +- Git and navigation: `git`, `lazygit`, `delta`, and `zoxide` when they answer the question faster than shell glue. +- Stats and diagnostics: `tokei`, `eza`, `procs`, `dust`, and `hyperfine` for fast shape or performance checks. +- Utilities: `xh`, `ouch`, and `tealdeer` for quick HTTP work, archives, and terse help. + +## Modern Default Replacements + +- Use `rg` over recursive `grep` or broad `Select-String`. +- Use `fd` over `Get-ChildItem -Recurse` when you only need file discovery. +- Use `bat` or Mercury readers over raw `Get-Content` for bounded viewing. +- Use `jq` or `yq` over manual JSON or YAML parsing. +- Use `sd` for simple regex replacements and `xh` over `curl` for quick HTTP checks. + +## Fast Routing + +- Code and Context: `fileprobe`, `outline`, `codeshape`, `refs`, `snip`, `defsnip`, `ctxpack`, `chunkcat`, `hitsnip`, `diagpick`, `gitshape`, `reposhape`, `dotnetshape` +- Data and Config: `cjson`, `ison`, `isonl`, `zon`, `tonl`, `jsonlgrep`, `jsonshape`, `mhash`, `toon`, `csvshape`, `sqliteshape`, `sqlshape`, `config` +- Logs, Process, and Waiting: `logshape`, `envdiff`, `proctree`, `sysshape`, `runprobe`, `await`, `argv`, `recent`, `pathshadow` +- Network and Locks: `portping`, `portunlock`, `msudo`, `unlock` +- Managed, Unity, and Binary Inspection: `asmtype`, `asmmember`, `asmref`, `asmapi`, `asmflow`, `llvmobjdump`, `llvmreadobj`, `llvmnm`, `peexports`, `peimports`, `pecalls`, `pesig`, `pestrrefs`, `drvshape`, `ioctlscan`, `unityasset`, `unityprobe`, `unitydiag`, `binmeta`, `stringscan` + +## Job Routing + +- Unknown file: start with `fileprobe `, then choose `binmeta`, `stringscan`, `snip`, or `chunkcat` from the detected shape. +- Repository map: start with `reposhape . --json`, then use `codeshape`, `gitshape`, `dotnetshape`, `sqlshape`, or `ctxpack`. +- Symbol or source context: start with `defsnip .` or `refs .`, then pack evidence with `hitsnip` or `ctxpack`. +- Logs and failures: start with `diagpick `, then use `logshape`, `snip --match`, or `runprobe`. +- Managed or Unity DLL: start with `asmref diagnose --resolve-dir `, then use `asmtype`, `asmmember`, `asmflow`, or `asmapi diff`. +- Windows EXE/DLL: start with `peimports `; every catalog entry includes guided answer/trust/next actions, and PE deep tools also expose runtime `report_quality` and `next_actions` in `--json`/`--toon`. +- Windows driver: start with `drvshape `, then follow `ioctlscan`, `peimports --category device_io`, `pecalls --category device_io`, or `pestrrefs`. + +## Read Next + +- Read `references/command-catalog.md` beside this skill when you need command-by-command routing, usage, and examples. +- In packaged installs, the skill lives under `share\\mercury-toolbox\\skills\\mercury-toolbox` and the generated prompt lives under `share\\mercury-toolbox\\docs\\ai`. diff --git a/skills/mercury-toolbox/agents/openai.yaml b/skills/mercury-toolbox/agents/openai.yaml new file mode 100644 index 0000000..bbbb524 --- /dev/null +++ b/skills/mercury-toolbox/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Mercury Toolbox" + short_description: "Route proactive local CLI triage through Mercury Toolbox" + icon_small: "./assets/logo.png" + icon_large: "./assets/logo.png" + brand_color: "#35C2FF" + default_prompt: "Use $mercury-toolbox first for local code, log, data, repo, process, port, lock, managed, or Unity triage, and pair it with modern CLI tools before falling back to raw dumps or legacy builtins." + +policy: + allow_implicit_invocation: true \ No newline at end of file diff --git a/skills/mercury-toolbox/assets/logo.png b/skills/mercury-toolbox/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ef81437160329958f4ab6bb35af96f1f0b36eb07 GIT binary patch literal 147968 zcmYIvX&}^X^!9I!@T&c2Q)RG$DqRC4?4P zl6{yOG^IgILu4$&%)Gbf|9{{2Q+;rL_qoqG_c_Vu>3y$t;03vreWvU8sM5do}5Dzoc@%OjTY`*TvDY8n=IZ!p7xCJ@n1TSB8?&)bb0$x`Dl+ z_p00x7Z5x zyOs2pR`=-CH$5}-6L^cL>ALi9O$)hxKB$x+7VQ5;ij}L=W;`eTZj9+;O809q&t>l3 zPVNyUu`Q(`=OSCwhaxt1JgTHYG?zJYI5}h-;fP!jzA(quY@loV$n*q@+#Q*U%iuf} zhNRKbpAMtB?tyf_%GRkyo%_XJueRbkqb@#-;QAJa1VUu?_C)ZT{OD#Is)1)qULVnoJw%%()RbT9h54n|0@(6A&$SP}7~zNNnTX`}$03y=rSk zV*o2xx55;c$&{jbn8*ap>|sTbo1+Z}Xzqt}ua4{)7KVV~_K=NOXD~h=F)n3X7r|$h zUQN$LYi%1bI1fwQ!d(D#uA z5Nk>Pt8>z9-gn8&EAO7+C2 zA?V+!`#HMt$2i97tO_z&X~}e#fN@?V$CPPpkoC=;NOK8qofM-ohbph$31J*@>u&DV zskJcmVGNcuQ1HSSv~M!5r&;da9*72~$hrH_h)dDi2U-T#)m%O|Xt3OM87ccwf%??? zrI1x)TN7ikV9$F9nyaq|e(uhbEit;1?D&x!<{4|bHUF#XQQ3lsuYu^!&?7mf2eAI# zQreP5YLxzV#Aq&7Zl@|#lUOEK6J28}%4L)%>nxoUL&vrfiJuemmpQ{DDHLv+wb7Jg zpHe9?S*P~sbhNS~gw8x`gyz1M)mb{D$h%x5$%~8Qpu+SiRZAS~%pFgTR!(^DsTo!| zIlp1~>~ z@BZ~7We}8}+#>c*425E3O>=iL@-uF778N~43>GMD4p18LFkQ#bG4J;1r9 zgPAEbR}L=rDvXPJS#8?7V_ejbz{eR= zMXsnQ+j#Wg8En2)X}P_2V9c8{L8BP|t75D^Z}2q6E~B}6$uv%TZF4Q#UaiJ!4+4Rx zvE8jROlsqBOlCIy^*9N9+errR$LBWQA3(kC?&ky|YT3!w5&HFYJYmt9p^++4QPJDF z8lNr5T<7pS>j0lFTzOe?LyeJ#IAojuP|=;ATwE7@U5O{klV~&=Wn<9F(kd0FbJbjQ zMvR>E@G~XAKHHS)$}gbc(iIy$ZaEw%ODU|i#wFNm_?VE*OPZs9Y>up zO^6$wbpP8>oiH1GzDX0aLxR#taIJ~)0+)90Z!cz-k_l74molyS3`cvHELx>Itz@5@ z8Vs%1`9A`z%yX>DTKh<254vBuJ-CVNYR$h7uH>tcPv~d}cM?b~VIai6uA!sq zl`yPS^9}Ave4$^ZhpDOQfy}-^)E`o9hl>GzCdYFYWH$_kzB3m(Uu27(E@qEsmsT4g z7FM>gqMkKJ*CMIRGt9Q~3)b3NMZfE8He;};O?Q}LsLpa$ff&bbStXAIdOR87p&11I zpkL+tXCJE4Mv^a##bNKa6dcX=EE%l4?#J4m-Y=FW75y@x2DkKe1Qct zJ=0B%CLJt#PJboncsJFxzuBSIyj98TLk7(;bMsYsow=BYL681(Rvn`JYw3P(u zd+VA;qOYCO;AUB$aC#!SalBt)U^;1PmxH%QcAaKUODCzIw!SQ`&JSJx4|t44V*{Mu z7NxbCh=ouKyQnNDFYDh)Bm6M?XiUCYT>DtGIx9SDq@aP-O_B>LyUN;BwQQ7Z4(zBF zT4T5_iD4y~^TyLQ-Pz|z^NAo#-$sf3GL;tigs~R=2I*4tZVI#=M=To{?w^2xg;rH5*0wn7fvHPC!5W~T+c>ws zZq*}s|GGlQG4X*BrXC{&B@}Dn_m1Iz#ytXV3-3HZ$5(6dbWP%X~i;V=?pIPAs^hT3x56903eIJJ!!BpEE==_${3c;z8vRiW{P$ zxNQP-w284kS|P?Ri;|6??P}^70C#?eH)U2et{0J4=u#l#YOn(?wG!Wn*-HGmlhIkM z4irzMYQ-NL`LZeUs^<(4`SHIZ>3og#?XAmk?Og7B`QHNv=GMaeUb=q9DrbelNR{?j>gUJ*K%=5tw(4T zBwo)adVkR#TtkdZu0+})E8(Tmh5(&<5<~YRI#3$gNnCCLf1JEAP5(w=+nFdF}QT?iI&mXQ$QdJi2eyYNj%lO?+kYd?qk z+{`OVgl5i|$XErWR@Ldk=1Pm#+Oz~&biw{&qy>U1 zo4`qanTX*|3VpxAxebhHc(r z3qKtXI-~H2sk$LcNZHRsN5PVge!or~LfWu%_kG!P$-f(w150#ZG3YI*XATlwXeIRZL~ zFyO&kz=NisxvEvikNPPAX!#jK`C_C~^YvLW0KvjIS6a3wM?NP!+j#{sdT!CIGP}!X zN0!-rECyT2?v1jHspC+b`HrJU?xG;LS#p$i@H^hZZxn7k9x4Z*x>25GphbIGQXE;<^9f(2WMH#KJx}R~@cDO!LWdr5a$TB5m zN=af5NrK@{d*qwHX`cTPd?Nzkq_mR}>(AnJ^%e`otx!A~v!d3!wyv0HD3C6Q`J%rW zV(C~<$TqmFY)+~EBPlr(Je$2_a~#(xJVIunZ<~$;0T)bl6qi#jQy1k8z+wENDy#V_ zso0pYSu99+3+ecAYyWtxK^}Biu$FB*!@@CIKZj)XPo2dn6X&fk7N*CvPren$?1DKT zpxzssRRz9}WO%MfjaIvYsCni`8POxv*WXwyjNoobUd=h@tqh7&+?zv5IyR_jV-9JF z=l2boNyGlJR|f!UPO0ugUGj<~Xd*BGTiSN9)T5GGA2YpG7XqKQ_UdY98TnOQxHxkU z(H1jyH|Fsy=1G`9n?-N1W|`gG+MUzxtn@-@OzwHall0+!g5Q*>1JpMVK>UcM1 zN>oBl{~m;xb?^lS?0CM+2A20vE`AoR(o800@I9F);zE*dcKziCL%fd6(;hP?+4b?6Hvy^-m2W5eeoS!BcXGO~H42+{a=#u`!z> z(y$36H9UuwqM{8eTI}-cVyQjYn0B4k(w?YNo!<*mo2aN(|8c=~@I+4lG-0YODK8sD z4)w#ig3vvC+)}+E9~7w3l>MV$P7@l~f$08!h1H{i+=qH$4fi1ZVW)nxn46mxsJ*b= z7An2*(mlu^@3I>JSk&}TG;n&{RxO`(^j;weyO=*^bxiR|0a&cyb2!Gk91%F|2st( zSW&j+`9zkQ`WK%7wtD)i-e6&9vo+2lS=ldxB;4Sh8C3*F%6BkJ8Ui*oJ^r7jfXXPn z9OQb@KxU14*L>r{)E+@#tk_09ivGf2%zpJx)@Mxe@`Q?BRhH2nI?<;*r!!`KYJrBa z)&FVO%4?|YvWUWC@hR-yvPz|u2kl*{?Z+l>OP#J(b@z{1?RT%z(zcKJvuV)bR;x`V zFZr?5aLoj}VD*97`SRY{TD{#trM^3|2E|oS)=D=07mML~VSD;p9*=%8i@Am_fG&XI zVD0}Ql~iebhmdu|Z>RQRm57onH%Fmk8AEiX(4Lc8+-D^P zK$khy<=QLV*spwn71?F-_IWtgKePQ(4a&4V;* zCm}rRJX`b8YDp>9j_VX8gBUCl76@+W&l!1!5}>FGFa`@7sQT2#J*rTa;SW`ERPsiN z#hf!gBa!f%Gc}^uKy4zvvGfked?%qipeo;?OD->Y7J`1>{SS!29YN)Ry=+ZKNI6Q% z)qNeYW}o`Fi_q29n$8Pij;9&rh8pK<2?Xtg1ubsvKbvz7l!LttXSyd7^njlo+()hC z`TCUW+mlzBX7?MXD01Kh5hJzdV~!-^Em>V3WsI#)jBGk!^HLQN0Laf|g0`^!xq3s* zQBZ?xMp`8a&1><-DZv5g&g5$!%~IML4vNQn&RMFnUE{(1yr`Z`?<~lHV^;liS!c0O ztihctvp~%W6#{C+#SJF7*kmbeWuV}UAWck}g|LFLM${v06uvwHp!%82_KZyn9-|oZ zto<{ey}?#uO-+-rFMZ{y%Im21*~;b<`a&?otVD5v7VVL>&y+j;@vL_&I1c`D^SG+w zZw)m>Ny3RRs%u|doGVC&`m(SFVHoJFoH|ef3=itq>^C?xsa%F@$mD~D<=$|sSt#Ej z_%o5EKm3Y#QX~kkx{?TL+n^aViZ@XRUL)OvwW*cVHBOyf`Mo73vHyEA!1 zZ=z76PXhG4qN2G2u`%n>eOvT5kNR_GO8C2+{;*C?_*Hc%>v)4&87_IF!$Ofa^P?$6 zzec|#eCoCmw=8UN;IN#|b^(16m=hb|@cnf!DPICX+0tL~O$qKH&3)YtDcK*f#&{_5 z7tS16qkhXqB(^SdKALxNiNOj^M}tu9wZd{eGH>N-mh(W}qIgjEZ(i@NG70Cz>Lf)q zP@MV`2=1#DzBtx!N-oWvcCZA+uuHQ(I7o^R)RsK2lPly9=v7NKu!a>ex?ELo*lhmm z&cuzJsW~5!1*&H~30Mj_Dut0Mz8;iifcdkd)9p{^DIv7N+aN#+wuDbPto(+@WS}sl zI`ylIRn5^l>4YBEA2*{O7R9rEkoKfGTIG-_4Q1CryrCuUf|So8%hh&l>8H4C*0iMy zDZ#^@^C(CeG)EYQkt|}t{thUwrXJ(9!-w>~xXaI6#YwB-Iv*(Kyio&qD!Qnw%4DPp zup-0fu+6WeAglb-4+bkAQ#Ym^I`(%FEa!M6X#$!3iP-Btdq#1@wp82pwKnl=RSXJF zVb&It3ZH>0%9$-|`1Vx@BH6vL@^b;kbx>HN(zf~p577+C>_KPhV@_EJ`j86Z%#*!o z#S&?E9+J3ggZ&oe{bmpW_}iVCe;0CCWR%C&=`6}^OJ40b1$^7z8EyK!D?7W(?Vqd~ z8~GgLh=xRnmIFu66nmb6Slk?}SM6|dS`zpK?SHQMMw^|a?|VK4oK{_ z-RYW&fl!7;FlC|Akb?Y*uFj&5 z-df++S$YCNb`mo)9T(@G;J`X~Y`SwD#NA)wBx1^(rnBOa!q(md91{l*|0grQn~=O( za;od8k11bQyDjdDBSXekC_%8tBxY*etDD`ep3yoI-CbLt!mKa{J~Vk4(NvR3(Lz_r zp`H2HTpi^^ah7M|H_4O)|#-z0@S?PhcRPJR8ZLWI18AOiC z_Bh}~(SNk*k3-YCeyHb8o#bfu**wLCzTw$Ig9YjhF21di*G(qi-Sj*KYcfg))tL>$ z;9-U@dIPE<%f~|67U~qzuP;gGhjWfvfQTh*9I5e)=;_~hTw)?EG~w$fhyHEKdU{L` z%=6By<_Ud|+m!2g$hK90vUDv!HJ@--2jJY(i2PK(gT+NGq^sD+#%)b0uS(cL3!UXl|I4@qk0a<#zS#|dac<>v0m%N z4GQ9S_aUpi&(jCt=-sfXtWh(_tAETpnft)^fbVd+66bFQE$*|FlhU6ux z!8aYoDYKd&k~Djj@DD=Z+_EAbnv|TKMG@XVQ-6 zL=Cn);7B>4H&OM26K59S>t=dW+y2%ufoMMz72n;5Ot`X1WhFvh1Hn7I=%70oDHub2 zc4z9HtD>-b5;xD*n5R&6cLvF{B5oKP_*veP0>m1l@si|u%f`K7R0RRFELD5i#g45C z-VZJ#8z1qx5gQmk!kG+U7vft2wHN6XTz$iTw&`!tma24^)I+}YaRaToIGJkf z>oe)vk;cXe7@xv*C2nBJ#paHK6}$_eFnVq>s{hnn7F>(if>xqhe+p0ls4l2wD`Neh z63%5!;NHaU+7rnenih)u?!zS;pq-iABZDX`iQDSXB;Ow!!>OC_a8H?1-e_SyTlk7o zFn+`qaibRr82e>5c#aNr62dMi9m?T*R`B#C2+4hMBDoJ*k$M!ht>}Y3$WOR4tK2Nm zJl*JTtg!SjnsjbO=0a&uLmerdzj&Mbz>X)gAfD%9qy3)lfT+nl+`}G_{jRYMX}r#L zNfIhz?E`eguX`0frJSwOkX7!lNm{l!uUnC?F*$9%k@IxutTSs98OFI6<=I%cq<1rLA?xFiQNoV1R3K&a+n@l*C0;WL+f@k-N1s zkC21=_cQF8gbwT3{yI`43!9)O%3g~z<7WehbEaBM82^>{f!k=NaSnqYdz2#tE3 z`)PR0UWaPyk*}QyA-amqC5?TJCeIqx3K&~ve%L=c*h$D|w7PMBW}Q=A=p(j(6nOGz zI>oWTT;QmltqP1B?SRv2%-F+SkAA%nPa@@K4^Wu{m^GEbBnV27|Sg7oYggx zhK|eEhiESvfdNGm5mio5Z}Y((z~0HeYCUAuf=umdiaUbYDNr+Pof&%jS%mfIm(uHN zsda{*$kEo!MpsDrvce+|gP^?6*s@~t#d*g7fCLtOa!ax!#?0^wse+sbdtNw8YNE6y zZx0q-m#RVr;uy~5LNBy&3CDHlBDQcLpKS0oQUI8*WT3S4Ye8&;p&3Sm3Arct2NMX2 zT;2~_Jh|_$yoi1=yb8ScK8phP^~$Aa;BD<(kYao8G3#n7L29lzki0>);N=Y)+3-k` z{QVvAW0oyR!>zd&nFQP%&wUPiQjxnx|0((t{oeSS^!nf;#q3(tWO-R?nJ!EG&iVA}tfaA1Q%$lC z%?!eEGp)z7DZ$y0!>5BPCBO;u^Z7 zH`;YoZ9G4PxBun}ofjvw-5p)7!iuuG&T6pG40_-J#F~$@og(jg`siOdDP2}ob1GsH zsi)E(3-QA+$Ogl|lpCxX&qF?IelmJB0IPBiD16e)toE%IUL-7%_Upfh|gD~=Z z#N3blg6pF&JZxGv0CB}fsF zZ~sY~3|C3=huQs(ReLwZb!yTJ`q3sa^y50|0pR2=QxC7~sS>})ev|-uHt+H5ZW|DO z6Aw%cf)s%QWguhUKVCU-pe3C@9Nh0v)$dAeM{tTx9;%VJyEoxX{rTo9yA3vaj zVJ`fjt*?nR>^HF?K!aUj1$7cd_1z&SDxj4PUk1Zv*;+~dmC3-)T{=Pn)w56U?uh&3 zB7I@`K~DB@D=Cq-y*3=iL6Zn(Zlu|I4%H43JzjE4CVz6)^ zZmXQK*N#q0At5&x(SdI|lf_Ena%*VGRP7V%APj7yKpgrr-IAd+Q<)$<*WSy*w(Ute z|8XrhXw}0Z)mh<90!LQ1$f_Ya_18-fu zDV@LSS38x%ar-A9kXs;WMC4yjnZzpOv@DL8ywMpxbw{XZ(%X{jp9i4Hftr!-B*@`C z+%7@}BpZ(q&=(DU{Crez(dWnlb!msLHX~;6xL}ZkK#hcf+>8mMn4|&NT&L8>ryyHy zhST^J5n!x+Nv&2cm z0TX4X5yeB2=U)wmf2F)a!;JAVK{XP9%1cet&Ea30Y@MxaInMr7s;$`rvRjk}LL0%NDLju#sg_M_V);pB2)M1}uc zj~REYO6UIx)0vnT@1YfnEmgmHjGdkLMA=n4+;%aR78pE52;99ox#lMUnNUA$DPw|y zo*-Z>oGIRe>pTOY8LjF3{;-}d@RdoR$F)D)3R2yllH4W6;p*+T9ybF;!~hQV0MoMA zsynKV)Q@UL!Cue6ZkMUQRw`P+`1ocD83P!;Wayy}D0l-E+?l(t-PL%sPUnEmud2qc zB#OvfL}&ec_~)Lgwqu{WJi5PRH_G;i$lnJ3G&94>1Rdm;f!EkdkJQQB1S3ik0A7z* z*95xLXA3V)-+sLQ{0dT@G#dKqJ@fg8$8T!y`+|GCfi^CDSaryNifVZcc5h_X#0yb3 zT1c-qu+1+{E z!x!+V7Y;vS3Wy$nLQ{@o&8nzolt3a;5-W(T&l+NIvjy=bK!YbqQYtf4LO zAZu;ypYlYgaTcKEe}iA#iaUnh1JhNlaH*n^LU4#Wls&i1*W|vNijW?QAZymA;4o z*3>mK)XyNW`p~QZ9&Mh~zw*14eLi}HIY;2H2Wox&uGjnZLi~ZoZO6aE zxGN28CDw_RF?`|irvVg;+4pq9$~O%o@ZvWXY`2OPEmJ6dglaG5QhU4Y#5dqe>?)Jyg3YF)niN_Qe~NSDki@ixuC zN=3AYlx>qhr<{C@>;(Ef_6qq*;tMSpG15#MarNVM{NDKa{n82=-|M56zm|+*%E?#4 zU_!)0bY^8!K+?uqa^tt4ZuRjVJ^GuIoU-}LIbI|D$uHv3f9}k+%LM+u3pt&Z=khr| z{PMn}$QYH~2@Z#Ux9$}lDE zpZ1FVlEf~oi(DV2e=|7^(YbS>O{!IIUIup8x3I}`U?>&6@8+t9dZl1JeeM!qF&znCGsQgajwRnw6h7EJ8~P}3p$N{ofMxHGhF@7`*LfW z>=)dt9^G;82-Yz0$xoXdX8R9&t>1l}7qDE8l6C?Mf&6Ev!_3+Dp>74|w+lz^kk z!*YmU_rRZkeg|M+=pPAS;CnjuXk33M;mZx;qIG_>T{IZIHXKJ< z+`4i9x9Jh&ua_wPE%C_jyUpZO4J+3ouTkmI>#PX9sMDzFq*Lx~0&Yx0HN0h>j{U*N z^B8jL(t#-Tlj5i3i&__?-l{^9F|XljI^TV_3$`gjPm#@PB#B#>6O|n@_DVFqd29yd zEjmiS&9J=2njFn0{%9^DiUFo$ry7LL-HClXMgu71#f-^h&atQzhRz7JpLHLZwLVKU+wt&yOyn=@ zf@};0pC9-BEqJ0MUctlXxQ+tZd*ruY+i@ladm8aZ>>%sKef{7aFx>7xS!>u?5z%0k z2k>c!XpH3PZiAE96WSE)tJ}vm=Zr@OI$QZKO@_7zR@gq?} zvdoUlWPP*L^O0tqMzf{G;ZOS9qY{iR`3Ur!lrKq$MrRcMjWxNW48^^NHGAR2u~NIaJ^#(`NnNDJRe=C2j>|dm+Gv z7eQ&^gAeWu4{g;t^b^@DdTtxQI>1=X*y!YqQRD-W$X{<9C8E7wNE}CQ>{nd<20TY4 zq?}m(GWX(x7_-X=%dXzYjKg0hx0>}(O_uO^4o}}6yQsT!tHc)hm6tnW)+fJ3%(HTK z$Zz+&_HN5~E!BH8LPUOhLJoBOz@@cYN%BRH%&?sV%Y6V`xx{C!gwIZ^1+VE&qHoHf zOFx`F;Yh{3%4pF7tRMuSXrGGqqMU|T=|=Wb@qv1{|{hzoz(vJEU8IEok}zHLFw7B!^ms?HWx zz#-AhS0F>?0M~%OxnrV0`8Lu=O-FwL>$MV!aYQcx(?g`u!HeOuoje@UT?RtA^j~9hd`DXEx=t&3} zgYQuiR5-j=S&oYc`W#`o4>d>qed-I)Al8bjW0sW<^s7(O;1 zLy5KYXtDm(Z`|TUAtge`KBb)DHNJH0i*U>d1f%CILJ$Jg+ryb}-EYev__@>9(CBzv zLG^qDa<%MJ{oRppQLUZUj;~kq%)As{|9r+T+IFIp9+FzSezhR%O5jWfsZgEMF4Vej z!RM&6tw5aYc2v6DU+=@hjgP7tK3@=)f9bnddhh4O92NDU(_6aTq(zMaQxZ_(Wtj4;evG!vW#Ots4MtVPMqbHQBSNNBM9dT9iCrFo6PZC zZd}xsun`T0Y$4ZVPzM1pJyEim)@oY*4)lwSZkUXFFt>JnnOoAh6EauH`w$-Q-mdVp zu;p%r2?uGBwr(nZqQ-!?`m+h&t6$>NZ66zb{iCiIr&fwrz5emwyh%ZiugS=oHQ}^j znsrM0RJ_r~1-@3Zm;58&$#gqT*RyEfX$P?d>A0xRfglmHx0X#3T7D9d2lrL&_-c4& zZzmBQ2T6-sIXfJv^HK8=s8z9hkJZhNui$AQqXfs&v5zv3?X zm&KTOhyUJ)%*xD7@GAo}*eBHpuQ0^vX8`JjYv?Q|hr{IA42{smssT2@?HgT$f0FXW zgAN4!@v3d{>?vx!oi_S-S~W)QlI#%?{jp%G*LqCUbU{P_KgT%Qm5+Qc5&cFWHVqRr zQyA2w$ImrO*A=9RE|@NTH;B;=62m?4HO@9V9cG$wJfzRkhuj!fr||aSNB8Wj749MO zXO{KFMgEm*QIxvYv-anJJPJa#XQVPTFDDDbeQ`Dm6^|~-QVB?oXJ1}GV_DPhm)+qf zznA-PF`-Oy{WA3-{#VY~i!ps0P99A;k6jR76KsXBN)zs8F(~L)t2x+I5{6_y1u%?Q zjeL+O#5DMZTaU}dq7G`O3Tllki~AnFPo9qdWAY10h?s8<4V^73S$--<dx!)05x*?i#b1o3M?@lp4|9J^7Emy`8Z3SlnqHv111q< z@Y@>y=+;fqgish^t?;K5iM})4=9U8cuTd-|sZjxLyae+QlQQ`3RLD8Ia#u5w_+PGG zzm1n@J{a(pGMM7<=bSmf0M)upV2iFWZ?LLfRGV^gvqiUTE!_EvRiC?2ktm5?$S3>WHoR5Og!|XSA523p-p;S{Hagxt(_%x zi>YoocduC)88a>$oUC}Arq{y%zRli4-x5-PW>dxg#Wr#29bzLeKe&(xdqm?*xM_b=mm?>J-J>(nxtDyRr9&fW zif}n>OB*tcBz#5o!@TdLXlqqDeeiEh0>q3ioIUC!e^~LaX&RF6+SgF^XRpMC81art z_XOp`J6$f)L!TWs<03hf%m|Udm_7pU?|6w3cV=$TT9cJ?H(@142vhy$Kx9_>plJA< zzEZ{!Q3c}}GtXk7qFa|RrFYCh2Y);Ij4GtfB)BmM0l5xgB=4$1nKgSYiDyA;a^0b> zsVbqY;4o32UXyUf>X@#jtW)JrCjzeexwlC!@}uhqL+tpzE$O-8t#gu=a z9J-nob=vMZ6m2?qwNfYNZN$Zac*b9sH+yWbiw|AizSu!`?%E6gW+7`GU8pG0dGz4> zn8%9PKaw!{FQd46FS$VIx^dm2;FU%#%}0CF78^?BIh54(KQwF=%W%0mHJHz-;?oGz z58E8mlynS3!d|=^tab)V@R*7G+=pPN#P%!62bE|L)FT=9@y}f=U*-jnj}~Rx%xGJLe-IYmP3E*t&3`zS+tEH^ zY^{HBGAmrH?%rCylA7Ajjmr^+r;XCrKQ{ZQ{o0TnttEVF?7J?kk#P3` z$8@#qQ00b#!~L)=r&|!hEmioMBuIC!^wyhoFPN7WQFehe!Q%H%|E_9;|L(f>!!2JKWA_6!GC&rLbma@E0=r{ zembg9lt>gqe0LU(Jxi6wyS!1EiY3J*>&W)Y~$`c zhWJH!Sm!0Omh&mM4%KY)YF#URo9~yKkHlu;_06Lte6MpS4LZuC*YcQcJ#`Nc(rY}s zE!yDMt>vO~I{$8nuj#0dN<9`k^4W)%f=&?YmR*Ea$88^C^<~Mqae6IFD!b&@vC1+ zKBx6~>dTMF>D(u@!*qwkcSZK8&NWOIM0=cUgF<$m4}~Qr%l8p>RK@%^GH+AqUcUM3 zWggg2EbFGK?Fg{@qGt;|bi1O+ztCHCYoo8H zs7KR^m2Zz!x1W00)oS@m-(q=rH`ViNi%lC;{Uy(Nu{vCozh{qk=2+A6^h4tppN0wk z^K0iceqR)xt}-KA2*ub81#tXZxywv3gWoTbPs}2R9kcDl#a!OopLj8Ay1I7y4Ge$v zmR5Jm8kHp0(mQ;^E=3{6w#E0`eudbDJhPvhtwR$ian<~8*TYIeZ94^RUW%_>-E)hu zmP43nS$t5nw9=#|d3Y!`wr{~`Cs@L)+W-u+dg$}dl!M2E*W6FV0FSRq0hJ?hYf5{)mzn>m3Wrv=KL& ziVc7L@ey+nX(|1;AOxv4CAFhkc`!8LVAEV9cK(JAV1AN);X>P|&#HFhBcv-TVQRCb(9Z zhuR8O81kYm4UwJ;xXxB)k@i)Iqh4*cPm-~9`qy{qMOJ?@HGXvesLEB7UjFN1(+%YF z$hHYh))DI4q?q$kk)zSqciH)K>ymRy?I)gQj^x zFTgKD!h|5O>0mAWPZeb*hwdT5B=6qi2j?y zUrR`;Z1DC*)jwnM&z>k5qnD)g`1#(yVc4xr<}MNaSS5W*-+h!X9@Ul9A#X_8NWARZ zrfOs@84KAq{2k4naqn=QejqP^wqbHc`;VeuUxL!_Ygx6cyh(~t+5d1Y)>{GEMCZ|A zZ!L^C19Kj9jx+y$=i;pkHiGm7q-+jYX_10G_5U0AD`@0E(wUH%NxNai+I|uYX~tD5 z^@cjZ1GAMDByESvx|%{3TeA{Ivwk|ZAJ%-NoF{$Z>yxC!mlf5QNB>>xy^f;1?Dd|N zU%P*5MmGC|-XG8Bmr-SHf=Hf(KWB&L_9rW^JO#RmX$Pv8ir{jc2dq;_A#bUqNha{kb>D`p z`&(*l>TsW|aVqNh2cOA*R7t!k4Xdj##rQkxqI6)!VQz>mF@WQoOn)NgG_ARB|FepE zjY}9;J><-h#LJE%k2<{ML>6Tueh)}n;gi*z0UFJS#zd&^c^T+L-#b&2qcUyTino1j z7%7j^s^FRqYI;bRtRi{-rk$MVzxyrqR`v-g!O=Tm{=I$*#;bIjlg0%qvASO|5?Wc+ zjun51AE6=DnClM@z=aU2Kg0>In;03^x^D>o9%3v49&s)hz$Pmd%ZWb`bC><3VVFNl zt}gMEFBSgB?~QAMO%ik7wBp%j$K}kp|rliA*>uMd|Y_ko+X2e~>E?C6JX!qTgWY zS@O?D+z?$LHMZ%C(_kc2#b($B&$UFvSfZ1Y* zXQhYFl@NmqEtTcunQUb8M7#LR^q0L~KxfFj9>YKh+FG_#vn7vJ+ zIHsB>;rSy#mGkk9#JJqMKIvO^yw+zT%JIe}2=CZ!J8T{-YULa{9)i*dB6Tjqtam}s$1}nP*G$yJo3As+^?VTvBs0iwqE&9=7m)LVFStxh0(iu8!>mOBY#w5%hznISdAvmY7`%8h|XQ$4! zv_IXk(%nOw%%J@-WRh!Q^<17T>_am>>Z6{}qq=8DY$wG1BqZaRsvS8afjffiuW7>O z)GIasksB)av5@~RZKq{jBtmZWq*o9tBD4ZNMN^I zcdq;8Ini_U+%`!8`SrBuP9p{ABJ>2BfD}$DBfN`Kjf?`0B|8)bvp z?$0C$LWZ%%AztSPH40>{nJ;^3n(sE|8kv>6rkf9TShl9_ao+($x~LM8vw01EcquW) z*9X@T-=C+J?Ed-h6R2<0vnnPyI4$PZeU&sq_LdjNV=fEqw^xEc-3@abCT>j%o?RXW zzbe{Y5Be%1RFuE5e%j|4FzAQ{hZMH@+y45y0;egdLCs64Q3W4^1SQ)@{GvNzvqr9~ z)ngB12@ma6+Fmam=}0h=Yl(lcDo*AKS-Y+wxf=(z{uRN!6H$kwH&bxTH~K}!p}tr5 z={MIUzGUuIKh96PQ9y7D`Z@IB^G*TRkq^6XZ!!^0n-nB~0#_vEuZ&`M!nAL~cC?l# z8|~6nm4bnFpMMF-44*uxh?I&?nm@0)vqFJ;WI%fHt_R<6{rAv;@L$Ac4o0v*yB` ziqTgCuTprRlR3NplXjvJHHW<6+WYRQ4{EI?vDKg6aiT5zd|LEv&Y5q6!QAps$q(#6 ziuyl9op(Id{~Px|=X8ivR;W}58Oc^iS)n1TX=EIcWF<~k#yQH2?7dF)H7X*M%~9Fw z$j;8*$36~c-0#!xe%$xpJ>>YD_qeXt>-oAqFE|DG0yMPz(TOZ;<{Op*Mv$vzy~NW8 zP?s_xs=@|Vq&~KpNqT|VHpYCv_O*^<_@DzPEZEjT;91ckSe4INc+BtZQ}eHa7#I8^ zOyCafVat2s%f7`uVvoX5e@Ax%+=k0T^bUQ;h2BrMwQi_1%%?>AhrT=Ucl0pJOm|~1 zqAWo<^Vv=1Gy@r;@HJdXHW%l$^J;=aO6_(pHI;q#B|h)#TMBfiZ27 zLjYfAK|RsWgua9H6oPT00wa8G6li{x(~Eof*2&`4kq2>-f;7F}8t!B0H=CLKFVg{dSlgyHQ4AX=yTjJleIL z58Mwq?AA%9aV%Yl@T_06+QH$`U3MBe?FB1 z%V+8i8vCK`jw3DIB5dl<{n*E5 z!2RhY41T{iSFJBfwb(4cB{r`6rLtrR*#D0!z|)Nys)4}PhkWNAWI@rXCc!|x^51aN zDOnA;uvzSPsY7A3I0q$Ef%DyH8U1|&IU46*)cS3k;vO$vk6Y>pO!>m^+S4FUEY3(` zTc4x-n#6!5xDBg*%!+-Rh9@Tdj-^~=fqf5!QbVhA#@%#n8hK*M<(E(X;ySDS)KpPm zZf}hxdh{viG-TW{yCIlCzZnVCLX;|S`8H~8u)LfCJTYPV&G+I>0b;nI<^Y&RO9;3P zPpE{rO9S6l$LF+UY~S!JJU*(cRv+2In}xIT2U?`A`iWQZtP3tnWcQqH(;WL4t*yVA z(Na{s^(LkDrVem6&6KB?WgSK)rx~6T6xi_w%)`E!y*)y6*Y>c`SUaQp^_YYBSbj8> zgso`N7jU#s`EgNR-KTxme(-C%IOyY387%*srR+okps zd)4n(8a+vVAzw7R$9*jP+{nn;k%N=0Uyn`8nC)=7rfpMA`kTiaCUHOYFWw(Y|3Xom z0~Ap5_{HfYc^S_0wsVkm>?x1IM^w~=jXgFZ1Zd|+)yOS|JK%}(YB)iE78L_|3c|oe zwsr@A9g|6?`P10=V8zFvZ*#`o-NCWV^bs9ea^DMZ{l&`13HR@SUUx>RdpMTn?#z-R z{a`4z?U*N~K%G|s6g1dEciJxnTG@!3E^(0%*rWIrr+=d25Uhd`gb^44CuNW1(PYF?KJf=?@Bf8M=u+CXLqV^5?KNi=A~*#Y6$Hl%q^sWJlw-(5CdwF* zYKfPG*kQOTro)?|%1?4kjz-5u=bkIn<^=7I{nrXB8SFba)}y$9K^jvDVU$AtLE%(& zZ8pstvci&76N>BHnUbA-hqmq)U)pzVZ%MMOB3F4le5_RS*gY|EyB7iiw`{~ONYUIZ z9VEYKJr%4YRfvQ_A|`utv_zZKlqMAL*cZv6(m+4{c^<%Ga;JhlLpSP(^~Me81aA_t~6JdIvZwK|mHzqc@I zYM0SxsF6IaQGH45g$7>4-Ymtn^;p|+5)Na<2RO%JLquho|0`+ZC2NWY>*#vtC2|&e#K^xCS(aoveijg<$WpL zI4LZfpoC>PEfH~> zhSY?iG}Un2iq;VPT^qyzlG4V%Cq78y)5za&YCU8cb(aZy{CXtF%iY#yJkd{hU&L7W zomypBk+REj`H4Xy!|HRXse7{ybJ>ug*z z&2!X!fHxB=;zZw_zwqU{1g29+W7+q6_#qLly^1E0PF5QsOZ4(_t7oUnAM3@-@pQ{v zg{$xWVD|uo#Q~opwLm3}Phj`UwHt#wCmEHQxS#Vv2HWul5$S-yK5WM4&~E9|oHt7Z z=}V#N+`OU^9KNEQcImFy=|5uGE=fOb|L$MGIFuJRQ@X5!?99~F)Lv1J9H3mKm@W~~ zy6UL42T>K|z^`k=nINJ;#oIR*##ft1ru{sJFS*bO0F))E+wTypfxBlRN+*$Gpl%>k z+v*XZ+mv(P$U^FI=gcY8i~^^XzxTscetlu(vdc~e!f*s9bkVfLX4dR1ne?F4*>d)+ zx_g1tdebD~q4Nha_h8h=#>v+5^Ym`kXA1 z?ESv&jsC=~V=!wr<)V$!08yEcNv8k%u$-x=TeJcFyY;su>T%mt>-7*PIk_U!@xqiW z2OYDADYFkTr8}GU+er#kurO=3tH!AX15#9L{3V<4|DCp0RB+nrW?Z*9u@o8Z)v})e zeY$bl{l2=te5tEmiIT>xySYJKTsGIr>n(yVN6!3JT>V=>!OT((HxW|R~x%eoTjHtOMc7vuP7i`S8;a}Y_CKpJ6sMv-~y z+>boiqsQ-^)jmBtoAMCEEU`3jV*FQhIB6;(&6+Is{Bxl#EJvv>L~ZN+Y8hs~%;&0m z)k>+^@{!w>EED$*ZzmWw7h7}pCeI{D!?QB^ou!w(?~5>sBPD9AuO=Im*Ng|Ns1MiD zpoQG)(h0}&7XNAOeIPn6IO9YQ?L!hckw$bmPfR`bWIx%F2X6iHz*7;j2)ExjIU&~g z^W_$kg9}Nvu!Cp3CK{8gdif#tFAOqmpPl|!{RKm5+?RiB0I@~WFp}{^pQa|G9pUu4&ZyO$cz71}2 z^m)5n)7J38jZ90SZ?VXq>NZO#N(1TzeFjfSs!jh=I`o;|Q3joOJs+=q*?fbQ94USc z@zfG>Ei%=i?|lphgOmWHz3@GTE+S(q_{@YNXzFxji*ZJY(W-X`m`H7J!HW8ol`tyE z5%qPig959l_{kh?Z4f3}v?_+ocOD zLdaT~b>?U}A5gTfNLR1@Ty=KwF{3ACV<*LB4`#@ooaM-|}M zCmYDTh><-B1u_aAw`(J3RlF7*qLSs)ZgZyRv|F;Uhe6*=3^;%Nh z!;``s;p#$lq*_wVDhWd;Rgv&?s^4=BR*v$|{tsIo>j_nCsi3^4fxc3ELo;zE6%!^@ zHyb)b@bi2k$XI89#q;&u`R@o>P73kMhAZMoY*aQfobK9mdVpJ61!g`#O4567mXb$^ z`~E&(%NFlYdjZ6Nw}AmR?3099eX!cmD^p-@*;^|*gC=Y%RQ$fRffn1W-INHLZ#s8& z+I5|ILMhNBk%?o_MJHOL(XA20zdcxXJAmxE$^fhAcIK83ZhS!$Dsl?EFco+$KG{3lrd#lL3=$f+#?^dqYWs(YylGDSz3CohqB{{(i_rBLQ9`zW7`J zKIEg~mc%>d8P-%B|Gc53iS>5VCrUce8;3*?yDA`->;X=H z8TXTKZJ*}Foe+**p+>6h&HEqZ4W*brGSarFW-F-OTU;IRUm2(@^qrCLqgfeX>v{u= z!ysDg8!7*$^DZf^Mq5^u+#qv@)*oOwRJ3J^NHfrfx`pje{G@nT*g3Q&+Ks- z&#+|_myXnXaMf$YE59IzP|m{3^Oa9$-tgz@A~G&{Qr45ZpGFsnZ<4udMeMD)DCyU* zr~JbcP|v()gip3BOUs!azrhKa@YWSb7JqZm!D`TGj2e!VNvh6`BFlT~XgI_Ihjk(k zpOh1&b63lvEL9uZyZO1}76L2ct|J813(sV;K%biNgkpF`@^3 z^};dY!rv24e z{7~Z!V6!Y?mW%t}8!ge-W>1x57M$lF@HZz3&~nq=Zn@ao=HWlN(;hZIk+^4^a!;C$ zZc`Z88wZXi0~jiIb=tu$n>tB6IJ~C103;M}^yzjx2+V@zCsLT+YUySkXx8XhPiH6z zWkZA`y|>0pD5mhbSZrDKjMP6KjE3=#i_-N+`}Y`ZeB;Fk`Np()@UR=elr%EO>hTN@J&vWsqcsG^na#&tKPKPaenG#aKBcD zQ{Xei;RDaN|M?|=QZ-wawd^kZbdfn&dwnv<&kt21H=r!y(R zPMQ7HDkULG(jk1P0}m8zo5us)jds;%d>CTv_=ww_8jEJHfL+rW zA16}1n40U^N_Fp2XX?Y0f`xLs`UmmOS-TQT*XGLK#4>Lfe*Fjl_5wm0-SKr)(rCJh zui+sZ{n>jl|4KDn5~z@}k$k{M@KJ%Sv2aAm3}>5*CWmkwjux?S@fjYjWcQobsyR1N z`M^r@CtpwT@;$5Vl-e-p897KO{}Tcl_nwi|DDSuZ-}4IK(lR3?MPn{Bk29)~IVMxH z;xD-A(-$lx?ir@&9b&NRV;h8*2BIG5K;a7?$Muy)dk^k&=V2BD)Do(&foitYHn6NYe%;6uS11$YX8IsPXKrG)2r&4 zK0ZL)6yzdET`=--+I;iC2snU?6@yQ%%~bVfvqAxPUTb^8#A@iJ zN&FddTsHs4vYOKpc~dfO+}4lIz`6hFIYwy}B!x*>=D7h&A7|1i(O)UFPkqaaXKCh` z8U4Ep|G90Bt+)y+k z+OMCC!ce!VHOhy#=KdS|^xl?BRxE?i@)S&zq2vhS?`}9WjYhTkV8|1mjxGIt#%Ztj%e9y1&Zsam{;DVf%F6x5l zk-E8`X0Li6G;xbYM!FHg<1{dgLfeo=0tD3RLC__&e*cpl-3wG4dE4%gnGl`vNWkSj z#mr|*(}x-bjI)v@JN2i0yc%vzHxRq}PJoICu+9+WRzykDDR>W#2N!1p zH9jYy&kG6|b6P!PC=qu<{UhXu){&=^G`>dD-F4yps#m!9CSdGd8u(Kaz~w;6z?+LN zNC|03x}C6g+DY(u=AHcpD$;(R?VZCB?h9tT*ldSNgW?5;gE-z$bjMEpn!&mMA^7i< z_>h*1zSC+K=Ovsa?x9lLFfo>*Ui&L$D9`Db8#{Qp6KQE>;Dj!96;=FZFb}3^%$1$$ zc`EteX_QBL(aDWYy5zw+Ehi@(Y)7&HkvCKwUWccX=(Xd;$77Pm`)7a`yf#yRlRJ;- z%hft4q;UvVXM9G470Qe306C3V%#oQSJ=)x10gj8NA=JN(kg1VH1!4i^b1m1 zzS7D}$TNw?2DV9FzLz{U)zO@Zr!u`~Q&e4*lENyY)z#`@yP)9h_Br57^ci1D!u_dd z0?Ng%n`Z9%c5UDNL}pms4b{7GYaO?)VL>=ce|r48+WW;-$4>wiV!wJ8+t~y9fYm{p zs1-Wj&x9*F&bE8v_eAe+s3ZF)HaV>$-I>BwbZ>_8!F23!PpWmk&Ob+WxRaY&3EFGG z16&&LO1(qViWL9Um=`Vc@wklNIQP7{j{hfZb)~zf|BLV#A-Il-Rci?l87+DlIM7Lr zEXJI5hmY=lFZBDePsY*7V`wKp#9CHqX-NKbN>fw(`$qq;R-a6c7gt_BK>j5lz0)L7 zC`)wWQ=3fR9=?8t9>{GlpSklbcYCRXj0(l*GsLhNKkHb(n`(h+_{q<#06QE9w6Ch* zoJ=52D)|zCuF6Q3!t`xCZNT3`jJs0#71ws1dz?GX80F_K0}pM(6Xx#plpkAaNJ!&| z&SQ!GsK>slw zDai|-{Z>nt1Kll00+84|Sqjv$!pgRP6i(xOERvH1p!AoEpQt`h`iuhZ2^?9aSC@=Y zW|cf~`y+FE$6-r}jG02{9e7=OwIe5g;w;}(CVNot44YG9CaBDUSqp=27jF?<>ae-aQM1PU0THH$g#ctG7knW_lNNM+zmBpXVaIf zBj1X2j9I~%VST1V5{FRai80jqsU*P%wU6gBL0_g(_f*|DAtS1qUF+@*P$@51z2bw?N^$&F5jel zX63`jjQ_F2jZ$;zTez1?hlaPlRF>r|>Y0{fEJ3EG25VK)fZJIYWwNeK_A`hC^k)$h z{0O;0;IG=|`3eK^7O&6M;5?qjc^Jr{s zG>&`|$uhmQYi)r{+NuZ#SpR(fEO!am!*LU*mhQsIN(0U~pVf*{0E7%?-v9G)K9wOa zDf|t&8NQw2_;!B^1nfqtdP*#DL=GpxebL`a#Bgyr9a`+sHaeT-+AFmo`AqnTBYK2Z z0cuDN;buFBaP=gARv2og{w1q@TD*f8dC8yf7aWooCo>1ulP6z=Le<=%Y|rQ4hwKnp z$Q`^Jne}^7e9$nWSOrV!`|Jz{_}lekk){R)^&?Hd!0@i^$r1gVc7YT2gcVZeT0PlJ zudMcq#h`1{18BDicRDS09t~)M=FKdJ|18B?%FY@gj$g=0+XJkJpX*GLBcbBSF-%^- zQ`y=z!ErwPZUa^OP!v~Dal0VB{zTmhPUW%vD}7Y_WlOUERr}OrMb0{cOVLJ~RH4Vn zxT};kEcz03ob|ItS22F(sZJdw{nzfKJh`j((Y*cC@;>51dW=8 z13#`soU?W6ru`(T!W@LK43$kHpvy5o&f{*Oj%4)u$ryeas2b)~cy)dbaqw})$*VBd zqoEp|vDi-&*psYZpKxlN{S)~hH`}&Ph;ize{ks$ol2J)zG8=|!(ex0NX~7ytMVX2U zv!WS|2(%R1;-Me~>)3SCzgeF9n9!zHav=Z#$GS0$M8fK(MsuyxsNL)ZqXJ!xTY_^V zC=)7b>jU5R7<0i-F7Hqhtc{C9)(Orm?Wd^l_C_}Tn=S`X91*~Zx%t^9{!Y*ONayJ@ zbE5(C)x*!K+~?BK2E;~nu}kl~;J(x)6tPpCPNCHV1HllC`Z$5^age-WlL3ly-}tmD zmI(k5DE{cXkL3Fh_kr^1`{=#nXWdWz+GwLK5nlF}DE4<&Fh53rDkHv7NV1Q0XOYW7 z%Kj0Qr!xXs2b*7S*%Vp9&#uz*Sn%;8M#qxuehAY?bVpElavJ`RDxFLqw$RgW(uE%1 z{EC0DMCD%~3{?JIs?OHD>Li5RFYC3xCA#{)?J2yjdaAgdGmi@LnroJaletetd!DQ6 zH-GW_+9-%_EYfN!xBJbMIP0_?etCR_*R*elcAH#Z%dX1-_4nCiL+?QH|MR`bevI)3 zfsKua;u_^+PD*Z4$)P_hi0aV*>zt%77MMZQoP{kd3GO9XzO*L}MJ1#=wt7z<5#S|@ z|8y}MaG$%&8<*!?`X^_98%5t<1ze*qF-TUdTirgnoO78yyzLm-a3{i*yMF9XM_lNfKY-Y% zP37jsdY(E16?%6#R=W63@z>aCoCl+gPN2ITj-5<}-ibvG0UEjhxKkU2Uo6qDi4Es4 z?MFs}D4WGvU!K&^0chWE_Mj=$@@V~~0eR={)GVYt`-#Tv%yU$eJ(==}S8rFym!i-D ze*x+_a&7Nj82mN_w-q!nOSu3aj5-$C@W($lYLTc6Tl~G z)8oB+rJ?-KO@0XMDd1*Q&OD}iiqNY+f~W{i?fCF&6>-drW^C$>7V686w|`dC6rvN1 zSo_7*iF$QWGc>n~d@tM3fH&}ar0@xv^Na=J`eoYeSA|W}iJp`Lf0>@orr7PktCkc4!1W@xcv0+2 zzx>7AmIu)Dj$BqKBLk8>)%?xCTA@!jZyO^{Zo2Ec`!~c>#=Av#?_9Z}#7#kw1@u1#V-%{v6*On1X*>^h-n2~QD!k|wF~xR-11!w4y`bX`Y}bP z-*9+NQGR(QKE7^182+@mkn?3m)4e!i^7QCHMZ`MGqLZJV>|>P$^D@?6@bAY?%w|Px z7rg1)N%lJxr=B71E8zCnr^^uAN! z6c8uzsoyfkT&&hV6UtDT^IJu`D0u)cW038&3=E#-tAW9n|B5K(Z zA}9GMGp|P}Gyh5C*%bND_5AS`<&91Bs9C*ndbqs`g}$o3O(j4^Mt(AOrL+dMmLN9vpTzAhOkUM7 ze0kTf*C;`*?AiP7gu8%LlpNylDfrcAj2f-lMFBa^&zza;e7u<;a%qa|hl@cv2W}e2 z${lbdTShYugCZ0qy6XN4AKdmu8l`~X9Vm0;t||o>W~l8{NPEi`oP`VYsxRJ&0#FT> zVzCi+&A=vl^Y~-`XQ|oLZ{6G&#E@gOn@L9aL=^mP$cPyraP4$xfcNXPVKSrijq1muxs*bl8Fl-g~Hm z+$Rl1xYdb5(>G{y@fWVq1Sy988*x>@xBW{hqRi=i{+=X0pdG4yHSp^WhJnYQC5QV| zg~E#qFd0n#39=WN2b+z*9YVk$=dCFzLKwS+%4?uW@oo9w;Jfz-OH7kF<0)~ zWjAfQ!p@0>0;>1HJ!z#WpUpN>mosx}b0C1y1k*#LqnRN z(XXTjQjb-hq?jL6Ghq`TnZM2}sQkP?e?xu90^OlNbvLzLK_;#g9}~$+qjUUvO4n^x z_tPGil{<8e_Pw5|>ySRT7LvQ9`x^G2fB;BL)ZXCS(6pcsL?ni&EhzF2LZEQMgno-+wEH9tNJkmJ<)Ys!50t&yOHyz=1vnrsDc7JGRtN);ujXc+| z1qiV3SAeFrpVSe5H{$>U&;{m=NI6jaUMkVqq|4t`{SD^d8~0i-EZ$*nbFX2a8!1EY z1^I-9b-g~b_3qj(FZ(PhkdIp+nKJR!M(@ak~1?6Gib5vhqjqYJ*?Ma^(%=o<4lQ46(T{RR@8 zJ@;Na%>e=FYn$2BxQWs?)MavWRc6HF66qo{UB^$rXvhfqpp1i6taFsFA4q8cBoX9XIk zpW3IMyar&Z8SSlmi5aPqj>2o7TFMg9eqoTA^Y;hH1M^HWURTu(wQkRqn?5NJ!WzoQ z|Hm?sYJgTQ3gkeMvxO$pcvzrcFF{pm1+!WXw#ifPr+^kX%2F4s&xNxVthPs`Xv@a* zLJ;X2Rl<`y#!g25l)1BP%t?1|G7t5YY`a7n1j&^CRb_&=fhQQr(OF6f5ul5Q;uDJ` zro+VpuW40*aY*1mxtREV!AlyUqVT?7W6aOOrO}rr%>(;NxnO6d3~egR9tmYjy$0c? z_G9J0haVOkO9A5~1#hm$lKhScX?Vb~IhQ+%YJ;W6XRn_NmAy?F#-o(9J0@)lT1j#|6Olv*r)E;W}+lp(4IR!j{ zNPR+g42j+ZyL2LVxQWp@FbK#}bExI^Gh4VI%@>#lrK?j& zx>w;ChEz2^%Cg)P?h`425AVYmOyDc#*q&Y}pyBuO@Iy5qShlQs#@d9YfH+_tYjEY8 z9^B6)-;EPyJ(C;L1>{!IJMBT(Tm$6ie3NY+PHeKSU52}n44mWCz>MEjm5kFKgYWkj zjqyX&UPJ1Adcieq0n}R|tZr(T5Y{6`&!mS@AKTik_B0R(FJE(&ry7xw|!se*r01oS~YPci=Jr|zn`8KzWtc?L-c>{Xc}&IqrX+0z(!id7tr%;z#2=hhNk-#hq%(z3;~ZPhI@@ zN%~>YU)Z~#r|j>uElZ7tyJo6l>5OR(J}tVI-S~^U? zw*osVF)|xqKT=dQ@X&L-4@#=@3K7f~Ej3NmHaWsoiI-l<5=)1lm8ifx03o#M=|AUL z$zYpCrK^^^ktJVlG|PGFRCXEd%`&|!0gO#YiziyhRpa$JLHDO{jqC8b#%uiXs@G|E zlGrazVEsFezqugn*iIe!epIf#c)A4QAW%rjIbE!?KYCDYEx_L%B3h=stQXZ4 zM_`L6nsdbeB98m+-pRerg@(hlB2Gh{ZxEPI*y)HRZj^$&OiDwvyK7DzXea>JH z`#~o+nf?pAKX*GP`laPibbp(4RsR3K8$oTA8;YM+YVrekCNY*cf@UhV#EkoqR>pwy z?>_8Q^0r4LLmGV8p49)qn*v{u!q>zTxBtWUOj@GN#D$Dgao|Dk_7J{#Jo|P@h zV-eUqT*~4L3V~r@Ng)^Ke1P_rfg@0~qFYU<$2MO+<$!%-g-$Yt5j+-!2BR_lb0XNF z)SDm_RWUaoehu7@*?NF_jiCzu6GHdKFO^2_7Vba02WCs+d=9hKGt!l-AODM7WUXHD zz-;l5&Q9YL=qEuN?*Cifh#XARc?=$M`*{15*3|(D=J=MQjCe1z5BB zk^kTzt4Pt(>NqcZbZxA@CvFAy$C>4-Rr#s9x!q9&wvW7j?$^K-Ii;c>H%!qRW9gxx zxx7%;QGMuZow=^l$IE&#n43IajBR=&)q>}7j-rtI!7UkWzSvu9XI@d(Gi)}h-rA1$ z`(YxJ>PFCWnTBa8<<4=Kl;3`q&n!P|xgs_#{6_@(0m z>xR?g!ZiebXr>Idc_W(QT{Fhl6l3KwT58F|-5bNM7EKNn%p&a!Yal-}r27aZS zP!h%Y>fJAym%qa+LCUWP^+wF*l|K0%(CjNIHldJQ;f>FZE^%~FM5$Pn@P2UC)Xe~6 z6J7y5B4qSJ=6b|xI=XWKbto^tSUF~W(DqGb=iFqt%&Numv&w}W8k0Wm4Nqs|T^B7{ zQay9B4=ue~q!cmCp$ep{6n$DDNcgUTfmh~fMiOL_^3nQ}_k3%fYqG-BL{(F@p`?ox zLP^6W^cO!Bd1Y@!`@}ErvA|<%k6sZj0Y~-9cEK-SKEXO8#G1Q`-Ca~3;Nk0X4Tw|b zxS9148*anuKP*}N4nB1v#F*pt9u}GJnINQX{bFwN zfEE~Nr~`EgFmfOVV0FP1^il2x5HKtB>5j{M{Jlo+i1wI5#+F2!!-rul3VyGza7X68 zWji>QEXZh#*-Kh_Z5F$LsEgMx{z}F*GP+VWzN$y75M$s9YRDqD&M!6waxmX)(%Y^$ z<$=w6Zy@xh#ltfgZ(aeIWZ2cGkE;r4Gu8I@WRk(VE#yMgFk}JdWe_|w)!?`~LWG#*%Ie#q2vfYS=JXUjFm!uBtgs!n{~ z`_eYPvm2}8H{%kAI!c!DCXmnnvNJ6u2Z}blCkCY>psx$SG`HG&crH_Nn4{wOOyTe< zv7Xy-d9?1UKu5=w7neJ1H-0wMB;s*&%3KxWmaJi^6XtIY^tGr}d=DQofe2Z%)lLu$T{D*Kw6^Bcrj&yw` z%sN-J&mjV&Rt`e~Kcj|2V7R7}2X;#(^T6MJqo!VO2PujBXC^JmKvLa3%4cM1mAlKu z;!Fg0C>BEE@)YMeTkv^N+pr^G(6B^Vl$V)qpG?jmQM9VWYcXQ_1%>_H`%202SP zdmEQ#6Y)4j>4>|gGT2HrRg4KdRPE>OJl{lxh^Yplsxo@{J@nlu`XD3!T65fLRR28KH>%O5Cl6Bi7BK8*_b%wX(Dm+?H zPDQ+WVDH5{cqfn@yNf1HG=@(!qC*~_B=@-@jp!jrQ(vipYcesl zg9cxJPjh-4ft3YvVN@4*e?vb;Fgq`wPHCJtddF`&4>ih|W7fIfl2h#Dz{y^}NC@AI z!%-fSilY4TiCXtmaavm;O`3%r>iutR}Xb2te_09 ztbCKI*VRX;Zn^3=Ih?!lURpm+kzx)zeC=sPQaN6Li@t)VmeJ0b@tJfZ{PZ3s=6)?b5o4=ke#p5E zKAVY@z;|(f9Jz+Qk6-8eNhq}_r(T#{GuPN-jcmCjJ$EE%;;)<<3yd*jgboXZLgBeM z|Gn$7MP1q>#+Cp;7+U-yM@U|jnnWw z$CU$;eoghluk@j=JCXYSMK{-{Ek7#&orU&9pzL^UCHw0wW zPfaOxK9ZdJQwf^Om+R2l6-xw=s2Y}~6fL6mH9x!Ae7NX%?J(qVnW$2`W9QUJ6*cbNij#P+LaQGdCIRpAxsPhfu&udvrlWJa_eA&Q+B{bzkmMO* zcs`uKR6qp_OG#0bT&xm27bq8s4x9&CR+f2Wm~34|u>_Qb6vWyciGf`W_=2$TTzncD z)vX=CwTS#V)gr>~-LJlxLfCrszJl%dWm#s~rXN07FUSUtjenmq;V=O&I377ryZM6L zVSGZZ?e~jOC0j2H4|tEzp-m>MvE*M<91{KRs^EVZ`O15Q5FTXS8h??4$|h6&^}4?4oaS2(WeavUR*cS+12Xr zXlO+ab>oFy#<^p_QB(SLo^u7s6%`#mV|lYW+Gj(y$%A`V9^z!Jui@jw&Ly~U9D7dB zFdM($#J|DEbT@b4aMH}3>MMdoB)R#&Rt$i}=A{WCi7dIBg$K-&eaVtt*-{ zvKTyoIUVr(Ic^tDt=FEK|1WA4P>cRw%8V)#gWhSHGXXV4t%>Omb5RRMJWD-+bn7Xn zfCq}OASSx$6eOPL$5I9M-yc+&>g;i^#+wpVXe+7dDi-f$(daln4FiIn)3jcM%^P?{ z#ucVnF_ot35Z2r6C^?hzY65EQ72g)~Sw2HGsZ2;tiXhk;YWtp$eb{+0KALEcJr727 zUAy^!HdwhSmn1714m2=J&)z#XI*#rsJ@9v`KNrV$&Ir>>WN=5LP3pAuG55rzcPHju zTHoL!pG!W7*BWy{hh9Mhg-(_q+3;+2e}35dM>XJ2_Z2$0f>Z)Sv?Mo(w?ffpsYSyX zcM&%im;@Z@-qQa@RB9tRV_7TGs~%e$j7=(Nbm=!LgZ!w>#dPxymVzk3H;|`uhoU>$AY3 z;*`3gejFCVh-!%qP?K&=TeQWGhxz;bVB&j$->& zFT>3wNguH*xJpk>9$}dNfMR@lji$vZp9ZS=_w17{4=4QmeS2GGe39M`+%Vs)zvc)i zEGEGAOn4^6MXNivK+b-~G)Q)0PvX@nYwR-f#86~cliykG!2MG(;pc9r8P|=d2zzjv z1^a`Zbv21LnAlbl!V&t{CgE%053d-Bn70qE7i{-O>hqMEf~$+EpZ=ifBj14?(3uUZ zf+96VkNd-!_naT}EEDyD$zR!5QM4JqdLgrYXwSNAWG45EPKCqu#W3ERGdl8W!p7cb9V~77_GN8`fdct=M?zCWS%txd^s; zCcVRYojU!z4mK)7?Uvb|`f)rl6sm1ZEb`TiGE<&GYCZ1T$qWt9XH9}TkxZ)$zC32dIFV<0 zCJLS9Eq%BCLym@uO>!OMm;^KA@YPpX&~W~B=w^_~ zf#j7^j8@)nhMkpHDkn)-esi!`s?*5KxsOpHt0gbTL|WdcP>ZzCIr?38E z_8U7;5e`4uxy|%PF3?sDq0l(o)KC?&$$<#lE@9Yl%aRkih~|XpVClk`o$AGE9s3&J z|G?-#CMRY`<%0v5ojep@bBh9I{N|k;4&VM???dSf6a$k^4@sPV@EEg;VF|BQbT`k3 z6~64tt7M4kH3~mO=8+4m4vkGCs|2N@Lm-p1IP3P-&(ba~e%RMHX=&4Z9GHXF%QJFfM9 zBQl7q26C%DI#eWn2RBwY+vr#m^NDYj#uzQhz}XD0Q`rN|>kl-m%c$+m{EK<9)7RLk zx_5fl#5M8HH1pRp*DS;Y|7nIv+gMFn(Vh)e>2(xY)LM5UoHCsFJ-%*FV5>F=W-Ms6 zpj<@69iPNT`D%c#OKBgKU?s;t7&u~#8GT{jM4ZK65 zPK`ax^F@nD(hJ5#pZ*%YK?5@oN~(;jCwUAeG4|R@jU$E|&Qmj&zIE~|J$PPoAG}}i zk>uL=8fJtFgzE9tJ5ev=OUyjg#N~g#sE1DrQr&cxlm@u+q8mbR|9RpKi#{)!_9)E0&PnS6lwOol{j2O9g}p z4z6=Xt$?tXmo?ZJ-RiP`8d{Mh@ckSI5ID8RuOoJU^3`@(fV5gCVB&FJ5DQFg3Kr;_ zYtQ*g!K%+!@$m_TNB91&6egPHJeNs5xz;gd&GYgncV-v+yxy{JA`h2bYrKxsG~JNC zW%zGsL|Oh@R*93nD*7^7$~Yg8>R^#AWqxjPMo&?=b$6ulDR^Cr@Sg;|CSgjcJ8Mki ztct!6mUuDmD`G6=r>b zH$_*Sd-py{E1XGXlTiv*GG;m&6?thk9xzFjeqs6PwkE&c#rN+I-xr6TR{qiRbZ17D zzIh2Go}quEDY6x>S$qdOkxzQ5Gi=MS9wSMgo%W8v=BF+Pe|sZc<53IVI^j-N8hz2z zXcVA0pcgJXF+~!oN1dG=q1RT!G+#DkwY#*Qe?1Q_jH>M^p2-te;+;UF%s(f~iDNTu zYTJLk?g|FNEw)Ylxjwxqm+4p&f)*PDyI*<7QD>F@Yty?^kPhHe`s7xAaOgn}hjbQI zTK$?OOMuClKyl70iau+^FS)imB2ZRsh4l|n&C0`#%Pp&1TQ1L11UWwIiGC~ZC|(69 z2l%CCM4IA={-dYcs2enNkmG0&1?|#Oi5Z@_x+T}&3g$Oe5lMpNs2S@TL~S!(*IRiM zCB5yw&YZmO!_pQ<*zZ-4(Y~dU@b#Nq=>_ua6#~!riSf|+l|$XpiFUD;(_vZw!1M4BO6jh5yA4& z=1}NcaW?()H~&oT5i047iEp^Uz8wL>nqw&w^4_%mvf93yB@5zWjj(Vx=8#+B&>k^W z3GsTLm2diA2FguIvFtRXD@adbD;LVyFIyp&&NX|h6;NZNqKcgf7B3CnEUid%oRY!5 zW>+gmRte0PFA@O`Lv>NH;c(ZsT0fN58p1yJT-yF-o7@PsXg6FWs<>SsLvZ-ip$g6` z&duKY8eepoF1}<6^_&jkvm7MEalo9%H>-QrnSB|GSq6XyM}feony?PJi4pY6Z=$Y; zxtqOOL|~7ZC}A%^T3;hCO@^*!#ThKJ7U+DtRXknwD(U#MlK(dcg1XA`OX16}Bz*?} z-Trvx!^(HHnJyS|s+;s)Du%E4M^D+YOo*&V63Y^KVpMT8=J+cR#L0i*>fdz6{IUF+WaJNNthzdw3B z?s3lh{d&J%&)4J7G%=9t7!6V52s-*)LQwke30HwjGoE4DWYr^V^6z+wmx_mfY*D_tQtU6L{tI4FZ7!Rl#FmT$-$P#uZVw57qh|X$@bJp)Kfdr8aB{9gYyrHmL0yv1q=(cL;N19 zQnUDbywZNRbP$_I0AFb{2tqNTGnP8{HOozjYj+d>6ZrCWF=ulJ!nu)rKWM8m5Jpw> zPD=)b4Bn3&+6P3LWWY^si=A-Voj^{R4VJ8C2rfT*IAj6vMssVG7nYy6`Ga_=y80Io zge}`Mn$Zt}kBunDjy&bDo=%5UVjAOVXgqyC0Zg{;&|s^cE{D9G%vd|C1FKWN4PSO% z-+SlN^dHeLC3$jgo|;g6u{LkJ{>EACg=voTaK`Xmt>Euxb0;@b5A}c4<~hwt7X@Q+ znVar8emZPxev4;HTR3nYnw-1^MYN4HSqQ4_|`kFYh2cjh?D z*_B+j?EBn0^@83*Z1p_H(UGG5{I1AcwE43T!?Kfm)IL=wy z7`QXMr7GF=pev;7ehrn%)rSZx24o}2-sM3yf{o)K(T-F|;NEJQ&G|xiPl%Ai{*zx@ zUWp6>7vG*}g*cZ1GcqhlIpTGzJ%paZ#hZlwFKX^<@QPt@-USF0S-%i!eElXaT&~;f z&cY*DddSzN!9ZoKo-+RHZE^+e;()z0(nY-ApHPYuk<38w8&IuXs+1HWWCGU%ALT&9 z_aVO|B=4RL_u6Tnkgc}(PxV`jHy+g2;fKw!t=~#pG=gK6L;meFZ{RIbUuc}wtcje> zNm;}6Tl%eg$u;fe315oQO{$EOsP&`C= zF2$VJn&9{_<})fXfFEttCS6Io2Oefmzc4Q}lt6NDTlUy_Kr0n0q{A%O`g}fP)vLO( z-6_Msx$kJY&=?9YA~7(>2;|LGeG+?qQJ8aHnBqi`s9Wy7WzZvUI*0>jquxjf=WAy< zw>!p_6=s5#ZT=*Ca#BX$G9nu^=Y6(f*FZ4$TS>wCts@JaOqu>5b&6vOwpBcLcENot zop06MZwDwP5dEnf0pXv_7>@M#{xe!Kx=27M{82zddOVYTl3%_Z zj@lF+j+s1rD+4mh)j*ef6@IDmD$4!MepDV1rxEu+xz=|E+xM!eXzr=}YkZRZzlMY$ z?8n-{{YR5#r{bv3seprL_=oeJ8t1Kg&9SE~jYbYOwSuqHssGfK&V%Os<{9~j6oN|+ z0nXVIqB-D>i;{sGKX z?kzTh6qPlWvxM}fegr(r;Ca9HHD5dET0SITzHOeq~A5P!C&mu+uANGx4%IgI@nqP}c`y}8jW5PrW@ z*%S(=T#e_tP+U4m_h`;X2R~P$T93N4fp~HfM8N87V}kkN!wLd;h?`&xkiZ_6}{Wb&-H8{yU8!gz~ zxO%W~;Heet&4}0d2!H0)vD|wQ1?7US;*OEuj8emly7$^|?=5`0wB}MtKccWbEW>Y< zo!^=yZ33n5B67Wwh%w0==~9BxC1|UGp!iXKZaORc=n#u7n5bk0)GX1XzK!A3gsJ?C zYtsD5QspX~OL~^y;v|P7c%X@QdvdxHZowiZq&oeMZrHuKD|6|)_gg!r z&oQVYIMLJjzPH*Yj6}(emFW@NZTNayA70b{rQC`+wwzYnQ^gb-(PJb`2|@2e425_D zY}R#t(AFXMXrG(k;_#G}4qM&}7sUj%OK0`p(YlZ4NA}T+S(z8Z7gXe<>^*5UT_*G$ z_
    HfxUwcE;z`zGR}2OJu003*J#2nR&qVRj1s8nq#G7V_n*_$RSX@FrhD+Q>B26 z0qIv`llrDZxW0Fr*q4a0ARZ*V))eKtMqcqYCUO#2NaTVo{acE<=_fw)UhjtAtRK?Gr(x)=-tlsvb^{}^@w41U zD5IRAjMt<;S z_iFAmQjkFrlQ6inorbG_h&anuoxN9vsM#)WVb~Of*tyhe){aYw`8%0c#|t$f3Yo{2 z2q^HLkglDeQ)AxWAM;N$i1)Uz2m!pwg#1dTV9e+Vxf0qg;Ra)Oy0AD%N`1|r?Q6}U z^7G-xR4;-yxoD9ShLRyoncPQ>;{Dkw~CVo()w`x2o?UNW!i>$$KsAC$JVObBJa zM>(ztV4(X=wyc1~SMUy)^?Mv89-?|0M7-@IDG(ew5kp7*hcw^c)ia(%icWs`TOBI` zI3V;YqQAey*)xFt-2flZr?ta0PPnQfm*R^US1Ep1e4!S^gk2I_=6N;HK6>I#w|^i9 zW_TMXnpP!rXEffjgm1RO+h@tkq{9^Z>>wg*Q%YvLZ;lSN70>HS19w2UO>mQ#h8&}m z@yxI+BE+t-o>QIQ;{Ix#sxeGGnuHj7%8_E%jq?Qeb-mV)Y*3E1UP&Kuc1f+rf$a)T zgz9^cGtRt6@GE(Z1TA(3Ar?CAu9wvMal@PQI!zRNh=45{j+vkhgPaXHkeT7?8<`@f zOZPq*2G?gP{M+DmyUwN44{)f#+vM6ExNLrQF>uvggsl4-H-YrJ?UHe$D+OkRwYskE ze^fNk@x2y*FdneLh_o1zoH>eyhp%uz?HRA#`(24L>L*i5COl#aQJ0!XkjR)Y_c8bR z{nYUIOV07dES2`rtKZB?!{R^}NATLbqQYYOvediy6wXaTF9|0~+|Qd68)sihs(_*B zYdu|z4RHF2zt~+MDu~VYC?Vx&)j$J3iU^gr4a>{ZWh?iEIR{%3@>CojBc#g0jQm6i zDlB-S%SRTRv8#qmM{%>mI5Q+7u9HDsV6O|&$oXlV%^>_1!<9_&LVI^8O46y~B~Hye zrJ5iBC6s)#6zXXp{EtVaam6VrYO}kmWd^@2fBiefX;?$exr1_y zt-KmnX~|IG1)tYh%SJg5-Kh98OYon~5vyB}WaU#SnoY*UeDRJQGQb#HQ`=m^?C)Yj znRez-M|&MDGWlVJTRzb=XCh9AKKs2jDe6giMV5GZHgUSu+Dbc?Ptgh%Qcx+s8GP1n zx%0mXPYFeJR;%cWtNqfV^yz;NATa;8-G@r*@K_EhCYX`TsJGQ~V{m^nFUua}Z1V1$ z!Z(Tc8h(5n(t4c&l;tjz$64KM3BCs$Gt+wIjsioIXUj2UFn36i{Eu)_*ass3fP9cB z7`q1fLp*<^QueT8Z7wuZef7GWCuTjWP6DooDLPqVR76U_j%8e74E~#piuwy?K8$gf zQ*_g|K59*7O*#l}F9WFo8^4&~BX>MD6Go0D4ku=%GOtdQs6*O*zrFhFobK@qm=FG+ zw^sHsRRy`xp*S!Va)LH>UC00Bz~}Pc?x2|J{FXU-&hgE8BGAqh^nvYo$>v_4LEEQn z5{T_f1z^UaT9_*OB@U|!2JDiZBJ9|x@I?2L>2J;j8B^_+dr<_C+Bjr8C%>E#pu9uZ z+kf1k_tEVwrOakD9?w}^`S>g1 z`4yLRg*k`MD2vanR|X*P7j9KIebz873nQl{;;tkQ-@rh}^_aw4<*^ zL=KCimpgGJKe@9pcfgH1`xQ85d(z~8#&Mjvryj3I#M&l%26?1za+}n_Owz9pFuilG%Q# zw@GA@9`&0^kdab94&IVn8ILr=C7;jVCi8PqDT~W0_DCnwejStKzg5v~Li4R<^uY94c)wyf*8Z4jn!?=HTFu2;Gr#*)R4hv+O@7UMZ^|I{w% z=Gj49?w#f9k5LKEzq;N1r1P&ce=*zmk)}os$tDolvdp)?=D;A9XMKhZAmJ=zBNaX} z{^a<2%Ie;bE|c`+vC8t!v%>47Sx&w18!)8K9CqPkRi7Tz*}vjjcGbN2EVQ^7x4 zKU{XhDeqFzH>LUgwLVkxtDQSZ2<8`_LH2osCSED;-yexK=D6YB*15vbzIDD?wo77f zo%cCIqxxdx`fEEUiU>xf{v$$rn zBT797ozSj!FZhO}0E$z{QTv zfu1SSPpg>Eo7Bo|wNdAcAtCw{0&1Pp|B^6QEdb87G|mOOT$)F`^oKWnF?@N)Z$*IR zBY1I1vo@Ti8tn@}HhqSSVD)Sw+9c3ulB4@-S z*x&BPzr-T7ZkiZRQM;JY*Oq7~jRn-?@U|VURnm|!_QfJxtF`R-vgP1~0Km1?us<|# zgAk%BwlMeU!i6cRD;7;3Zt`nI=J8v;A`?hLHC8?eT)dXagS?IN#>Yz8i=4ZoOF1SnxWXI1Iuq~L`SpT`X25osG0H<(>0OzdcW44pzwWh!Jo z`XtfJ^m&(r!7`fHc2-wiKJv znW%B`cEO;)65dHmjZ(ag!w#W;m?k}na$c$_%9b)iCtZBku5p5B$38rDmNBnDLsP76QX^{lS%_hYVoSWjusK^WeTi z)w^W!AJyQX;!VtxJ*=HdZ(v$P`y38RQN*9`-k2uel@1Htt^_J-6z~MF!nA^aMn)O# zGJX+D)}@Afvq$xl*;>yy&NwiiZ~dS#noRkzm9xeNK}zZMYPikwA{$-Mh;}=E*RC zS2nO{n(OB{C}Kq&Y5p^VSW)W7P<#fIZ6j6M3N}Nuzc5`#$QMW9V{%So@Yza0xz2Di ztRmvmpm|sl_xlWk7wYb>Hhi*KJqHPimo{XU43w6AVjA)xZ~h%Vz%qM%GCLF9zaziu zmZkMk*?E*1ht%#dr34y`B{$>4ncX%YjCr<8d5Sz8Hz+YfP36qZ9yn7r!+8f$J@>-~s>-x@m z7I+9|TBW;catH5)XTfOXaI-7lJ~J~2S^4miA-JNP8@`{;u(s?o^d>RW@0B)MJbFZ7 zN-LW4n1{}Q^v|Td++q3-7)&&*uTt-C)G%#5eJHF2)l}NqA>{s1HiGdoMXND+!os98 z-Ho252v`6G+5V2iN~z;raZ@CU*TPGn>)#U6(%Tv3?lt2dO&NLDAT}wRc8d*p* zIVdT?_?-rg?{qECu9@g^AN*I*ta@ zryzQ$iig-Ff}ur@v@e#hRsoedT_qi#kZFbNMFB)Mr1FVXDO{8f`iM5Hi(me%7hk_u zUz|sd6y^ZU@d51aVgWUO;5JZIW|LS|1#_( z6%;QJTQeKF!Okn)@sJ_qYAF)J3RGvuU(WLOi|hO7_UZ#7dgDJFNy~8a3$Ak*5i}Wu zzyzwF2emt=Mnm+o1xO!pOavzfq{|QyW_aZGbMtkgmaMu@?)+Y#<$JxZi-y8!lk?#O zmncTblaS!!+9NZ*A{+@@yf22H`XF$~j24yy13TXHOrp!zH}ut?3v7R-&%A2h=nP&- zL@AWh%NOzTmxF5fia+NJ$!#H|v0`%==Lw~PNVI=iBD3%eUJ>rn$z#z37m2wk--NzC zbDipX57$Jv{W8%|M+RiGl}^z}&5q20!}Rxw%l`X;ocKz#r5RE=j=PAIAp(%|pwnz? ztDjfJw6r}?s?qw=D$?-pb4q)mW6OjSXYMCq%8_`Z9fDY|&pVZnQbQDMJg(JQ!|4V* z(T8qH@&U9C)6wTLE7E&dfiVJ1Dl|0YZ9(Agda+kK!Kf^#4MK60?Cv?2?9J7B)KLD; zkHuNU@V7+uvE}&4NB1t)Iq9`5o91nfiOfUS=Hqd^-2s1<6CQPQ zUe-PH7zAU^QOo`gp?bf&J&>AUnPmScsp0UgJNaaooF)fuXdBVxBg?5nW{JLhu{|83 zf#PEhcCnx?Y^Jyy$7zBID-_G&%@$F~-e*skB(}KycbK+HR!*%o^H@`UY@|ykbei4_ zxU~YEERL52>FrFaJLAyI^H_-;Cy+tXb^paeDM4cPfkDI?ae^_p?*6+2Ctu(~(1eLN z1Y~WV6L0F4qwU{2j(>bxygi;KLV!DQc2?S1X&%T&U=V-GNUyv$XFv>-Dp7{Tz;60J zrtW;Ua7>q?2D|$n5opm7IG7-$CH7|Fc~d+?7wC#7n3P~aGfR)S9_a)~lk}j{8-8kK z#+N@xw5MCZ+SHkx%zeWxmV*bS$Y{mc7&l~ryRVbtFXWSV!RJ0`+~07<<8COWZ1@FH z3vwHP{I4W6b#yoVOS!?$Vqv#8~kZm!*~8szR8rCrXs`L zQ~-O6du!PA!~ez-OTSw6u#SGaPwO&EZ4uw4Q2~pz%)iR<^)?c^Es6#KW`PPgM&+xx zAFDI?xXekl?8gUhSJvy!`TS{39C^arM(RQQAcCB}wfJu(GhW%r^mgARhZLe0N)yZ5y^NUqc4|Bc|w#_};pJr#(G> z{Zjdgq1ZyHZ0UH2`sMfR_xcjY6F2gPHf*^kT;u^l$}pk zDAV2e8z(wMogBo`TQ|uB&KZb%N872x9&|hZ(wdDR2GM4~^TEYF+7}?23CEF(vk?(y zqabkW5OHrdC-HSOwFSTLi+$?YXx>tM)&p-2`c^0c-H&?*pk58tw1c{yO9cCu4 zMM;)B8f7NQ@SRq!8OnqBLyRH@zq4+fxi(CTJ@W+3T04f|LCT-*p21PZos%w218(ah zgGQ$NpS&p*M39P8g1=vsom`b_+7<7m%UuE)5sq%1cd?N)*Bp5H4|Y4w-EKIj!LCKX z+7Y$jUt|>gS+qP)N-rbovatd083NKMSVHlnoEU9N_^o-MYOq@Of8Gmy;QSCAIjdFSP-jZmt*BfbrrZOZTbAriMf zmJnt=wRx0Pal^ejmRjt}p{rq3BL9Sc!(`0tg+fJ0vkwsju3H}vM?D!1#r8F0K7kQXwUV1%V~ z<|W9vyWXOW1gLKk)&7gMHX)y(x;eA0j$A(o=8cYJ#6M(;7T}SA5kr7ISrs?H$s{^0 z(hgW00xN@=v5ef5FPK0fheTjgaA(9fu?ZY3rj^)gIa5J>VByy}llq!c@D;(?iXUp3J@njI$U;DA&wfSkTVub6ej#PA8?U&*B7WR zUpMtMKTkrX`9(Swp!1+|Gu=4#SEe&EtV@3U*&x>*XYbr%BgZWUS_J#Aq!UJ)3P#sN zQ{9XryKcTvg5tM81JFj8Ui|ZyIF_&Ji3ost=7!^Th;H~nRukr&4m4MmUwa=puD6ZN zc5?_3iveD3S5KTUh4392(Wmd#nIS!knv7;1}*FehB-$R%!tZ zcn#fKiorWL?WQ>B_E&&U2(+r@{tvv#(B%K4AJ;Fk7SY zS;h(g|H^dLQ^FAogC8V(66U#q4FNf+f~IPhZ?+Dz#J%tEm7S}9>d(C;I!-&qM$-P# z2w3C{FZWtWF+3TLisLPuCD_oPrhRF-3hF45T+juvuo!0GA(tT%_17D9VCuSCHqz4^ zl-m>$^jne9di+irP|mZL=^b5kdy_CV-S5TWlxXd7zd+ml*qV#$=YVGoK0(z{7qXL1 zyq*V){l~s?^(^Nwtx7!Cc|0TtPQ}KgLWz&!9UeDT#z^+^qc>a0)vtPkKe{n<{3O?~ zgvgxDOu-pSQY-in_DZii=tWYo(8d9a};%va4bV>$D&y7f)6vQc{Os zl0ZH18ypWA+(!4{v}fxdY9H*Azj|rvGEVp_yR$*2aNSy16rx&;dBC@fF6RAA{$63; z4jzJjtNuE;hafRw_MPlG$Gc6L?pez~-w>fs?4{h*#6*4M+k}jXcJYD8I zzM~2Vm4Xs!p1kB^%PSnm@wl6@p|GFV60S{kTC=e%OzEE!!InYZa@XZT4D67833-+3 zPJR=P-$yQPD?1S!X$HPpM}Fu~y+eUuZK9RgYm2`%$E(DrUH1_D+AVDHi;2}t-3|g) z@$H_=e!~ITzjo+X{q1SC9qN$27KqE}yV`MeElLkv_{1u`jiO&UI5Z|;4g{Y4@;YWL zDmuD@OZY_oGD&!1RVgz?u3AlDqbc=EvJdMpALLlOzcrD-xjfJKSp@rEVsN%wOaDp8 z=FZEQ9i%nN_d*?SaJ*{P6-4<;p#Pe!4=UHRMu(%C7qaqkGb0K~ub3?iRVfo^h$mC% zf_Z=hj3ai)i-xgmAg@X9MvY3N+!ZoZ7J{jT=#H2HU)J{Zgtba)g>l~sf|%<7^+7JE znQ9d9hTIs5Di#YOUL%S12w1=Uc*SC!CQ-Z0Z#OL>Gw7|Yc`^;pKUF2G4hQ@W?OZ ze;wW(a~$l>oabaXe1H|dMQxG_RGTNbnlw9kTc)1GN@2#N`R?Vkte#oTE*iVXB)^|- zkfV?uGmce9Y~uH6L$$e^tRLr8&Qo!&`7O^yIeo9c_D3J~li3YwETWQbvyY6hRXnE` zQMso8uE|QY&w{|#zUwh9Ze;-d)MWDyZ`}deVGlHMF>4l!u>VC~^ZAvI=g*O|b5y)k zPNcts(!YlB2mNLM`F*Zhq&xEW4w+R0;d{Qf<7TTFJ0H#Q2jN#luh8S#+NbMjEFQvl zOK}s=a-RITuXx`sioA;=^OSb=zRmv0*Y+DPuH*yaX5^ww7=xaxI|b3-8ElkmNT|tN zTrUb^nwTo(Wuh)13Gi70=a7uO5x(BA3%#n|CWkRU9wNY7-9vl=!^awGhy2`x5XxvnJqwK2Z?GHro}b-gIT4sk=|`jBRoRPNFz} zy@u|Y2;C>?Nl`0erPjs?2tu`bpD&>h4NInB);eau=4YLIxSFR|RUGHUK26&M3aiX5 zQ@N{5A37sM2!VA&GEy%hmb_N_zQ|VrpK#K5RN?KiS*lC;u|gL4LNT*WWz97(n8V@F%?JF|%nFWpM#q@Y6F=p` z$*biHv9^qy9725Q;O}5iOSG3))EE}=hKOJ`E{RgV+_S1#awyI5H4on|R{Pf==VQDp zV({dC^i$(Q(lXo01_clEdJBfQA#a@gdgp()mQ^e|zW?EZ^@#(Sy_9RuRg-pKXrr=< zkEVxyrq$})r%x)^JTSA)jc`T)ah;Ff3*&(`-S}G0X2`AN%J#`;p$??w2fHh+MuNPcC9nN6Z?agr( zd6!9t9!N{;uM@pC=5(85wAv|f!49Yi>XD8X@sNXz>g&>aA`bErabi7{@GiamTcNk~ zIwMi9sM;-RU-K+qs@5=n5#dsgOGzkif1nEOf7`94?O@6Pztc?T;C_e&#r@VG(6``o z0JWM820w$pDadYiwHejkwA%7@M4}$~t@UJ@jVuBf)l8QmV3W{oQ8gWX$JPTLW zyR8|*vO?wqa>{(bB{utbdbxf)M0lJ;*bft-q0pJ)%QgU5aMlv~x22N-7ZEJ#s6Me( zjOv^B54J0XGuI4w;QJrE2n?rgzeR3W?quJsdGIPCF#9Wsm)H_L^-N^-lf@sea=7LC z4wFX1$_7Wv+Vd=9DHQ(&pf1JO>-`~naqM7KC9VcFknqf}J zyRIgeU;EURauyLhGLSCs$A4w$8={=R*yU?&U_)iDtBEF0r_unz4wPnFOA9foH}fbQ z)`M*%s}JHXuF>h6LQ)S|YKfUDZ(=34)T+AjCZU11wiK89xA|)>ZN(!4Q%{9Mpgcl* zuNJ)O%aONDK9l8D02hkk6b0qnIf5q@e(g#H=`I)-nCoCaDG#RkHb|<2rWS2?a{b#t zcBlPSrA;Fn!^>r}?a(G`BqXu-i_7)%pJr%!Yv-R2M-5E3CBR2$|Hv*}P1^P_dXF;ovTEREHB0;5l^j7yvOQ?@kdQ}99RmQTx9THN z;1+WyBiRM1@2@$qgbySeNlgZ&f0LSBV?1JER^R`FP9$px?iAIPoL|8k>aze5d%hji7TO;TD^zA)XeE?8o>zPwr+9KE0BWtMG_>^v+yET#Qe zX+v5l*`~@M;`O3M+@MZIo_wG`2*RU4`;)a-j5^+5y=LSoc^g8O>s-N>S@UpJ1(n{X4qYNR+U4mp6F% z5hbhE zx`MD2BK(j2tT~1G4NpMZypzrk*fd?3Mv^5~1RCiv>j60wEu8OdK6-BbchOn= zBrdiAb8T+*rZ?Du-ZMNH9(sLIBjT?>Zb#Hb$UBlkL0i*05R*|t2RM8O%R#-G?w&1I z9_Kt%dJpy7ZGHq*&}DJDXpqR+Jn+=8Je0nEn4PH`R`8aEf)njx7`5K1)je@<^WrQ8 z+dihx5);rqDo)S7BFnwq`}`6dt)(0osPaoc4%qBLtwdga|DYv9VI*=!gwQm%>+HDn z8qTLka-nEE0@fVAg5>7YuM2!D+bqf_ia_=AK20em;1y_}I%Wq%B?e-kBzxeH`b&>T zse#ZPR^~rENTK)!=i;)!gEI+UCBRa+qHM}*n)H|ppUg>29L zw*f=8)V0EloO4N@-hLKO(YL0EFkhcOfdZp?LM+fhstbMavW^3-kSrBfwnP)0+iC7M zBt4yI;+&RQ3x00(D%lF^uF);Ir!jdgKvB!PcaVNQag@E6!w-}(bhG*0#b2%`_Ji#j zD^UZ=jmHZzphUsh@$+M^sBM0k80ueZOTlKIdV}$(NE=MpZaY%`H?_{xTtzJ zi;Pxe@dL0t#4~`t&3jXRl(VJyty#;}LJg&2@gTNZVN=q(d=IqS>d}h_4u_Y~*Tk#p zzEVo!7L`VqS(BU;u4l5DR=qzOvwh$N-lTh_D;Y^AIP_&!b$HhtX|-iihK(#=wf@qb z!t2jVOD-A;Rc4Qla=iWLbZ0qJ0GQJHVy}uctN;bL%*n z-lwE#_xbEz{%AOND0I_ZvfmKDJKjnOFp=aW+Mt0C_Z69oBs5*V{t2llA;|S*_Srn0 zpXI(a?u@hF>_fWq5pixBsZUDSA)AR#%`O|r)?Nu>*^Sf*yN~gq(M+t+6Inm+e?Fz{ zBpj!07eZ5`;P;2hqdo~I5KU$#1Ju!V^^_a+*vKMV=2dObxaSXEhD6^})xw02eVLaW zzB&Bc5y#xqxc{xFbmM;>(ZCwWI!g!rxay>}oEPYV$A+e~f(}g=->y@T|CH+|d^h6Z zD-d1vNOIz^do~ShtfMG8Cdk1962rVAW~iD1O^K33Oo@FkL#8^&DmOW_6#l= z4=7p5SYL%j$WZVp3h&p8KA&lk<{8YQey6I#A=r`6F7Q4akt=N8qwwA2z3BsqD`fTH zVyO1brxc^ou=rLm3;k0ZY0ZlAeH~Lk#nndCAA^T(8}UQahNH?(R;>%m=&O%$`K;SC zeRnnMfX`p=z@E=}g8p~jW=!Z<{hNW!S`zP80bUs|s+)K(Bps^$vCBROFGZgNz~o#*1=;FNJ(PYI zI-^^{a`K7#gCt5I&l{1f(Id}zo@fft(tEyAdZ`K+U6ZY=39!-FYxNwG?(RJwt|yu< zzhG5&l1W_l1ZaEQ%tH7Cp{03)vbHAmTG=1!Ob%25QCXhxPzi!Ky$b9VO@3iKs^H`i zOk+Ce3gu?Djxg+ueQa##>Y`%rXN4Yc$dHQbW|5v`7#Y9EwN%F{^C+7$ns5k$g?4z6 zej2;70=tiy>8*ad(>QqU%Ye~n4V>BLPivq!w73kslWZpyug62uow2WLj%=EjG-UG`F>@ol0QY~y6{3D6{Fc-A)13>&v`Qm~$nS!K1dioZBC$wU?#$K+8#3fhi7(L59zFS? zy-kML9IAMR0K{H~MpO58vuJ-7yzra(TxGA(B(Qu|9-4^gdTH$TmZI%LW}zgPrT7$f zJ?Jl2KIMSV^xn+_t%~mnn|C;~6>WyYn>uug2n{IvJmYia<-)(SiGFpf+e`PbNFE$0 z-}{*C^wc+Lb!R_4xIC$Cos$LQT)W{U_!-do-1Xpf+-cdt`AWwh3$23~ni&nIWQy?@d2bagq05~oAP=^IKUA`UYgxJ;JmA!vhS1(^u2a%Q zJY&lVmsSax^H%eva>v#4=ko~{@$Gx2LOwvJMTQeJoSlocj8fp1dzeIFR+s*<*Sftc zQQG|0nXJ-*LM#Q+72oOqDYbQy`QK)bOl0k1+X>q7@uO9W=M*4&W8~*3U3+!2RYB%S z%W;X@@xuZEch}-lnQohTBr1?d5_zo$n%x_vn*P$@9$2NoL@(vg1xO(_Q_V$#dTWDx zrMCM|dQZknJUM6?z9wkjGBCOlc}xj8{{wo0r>xDNsc(k7{1o5x3LXQprGz$b)n+M! z+nrfyd!KXe`U(e$=k(^L6g?GjlZv;ejIHWdv8=gY$>vlgZ|S+g|IBzj!3+Bh&J!Wk z<(RE%Ev8MD;b<#waaK6`H;6kqhlu`Y+K+bwYhUZRP=ZyhQ8u5*nQ=OA%-1e{ z&x>u60~Sq9W_DC_!_lu9?rK9J zrT*lD_kinbBAbiP)39=D`vdB;^e)l7>~}W_O)}PO%uaHO1;+k;I42Un(utTK`LI2r zedwihTf-zPjbHAVbQ02+9?;yeC1>;0nY+y^#-k*#u^6d~Xn;ciEeagQJH+|I2c)-~ zP!N*aVvm~3>Zq>&#wSK=9J#0foDACfbsTV@ZV2>BHG7%YGe?{b@s?i~EtpsG{-1_z7p=>yVjgopz&DxuDDnHiJRLR|-uA4(k zyWeETZH&&rL{9CoB}rXsx*!qut;_B)&|zmtcZ2@Vdk}@AsfQ}uEqa#++A>ks(GZ7k z9i#W>%W7!dLz}G9Ws3a^8x%5q+Z4{4cZ13};)2SQa~e4AF7TInB{Q=wbBt0zDh8K8 zV7aEFpQeO+^j|i3*uR|MeOt33Vhpl|+1X^t_cd*ZAZ|rkjg-7wkg&yIi;+Wk5ULf@ao(h)Wq+>J6PCBPUGcKQUJGKbOSZ1 z`M;II%X_R5(P_31u0#YDcU zg|*;P^Y#ve3o3I7)?`{>8zzpeJ0@AmOSkfH={NKoZ&4KOG%woq`0(qT>V3UsP5D z;)Q3@A5Ck$Z0)}ZQ8x5^Nwj|-97$R-gD*2CY!7Le^Uu7tVNz7@BnrZR=k?5@E9@W5 zZq`#sP`bim4Emoh&#!^&#CxkKhD`-V{VSjq!-Znnc0GRZQDdGzDrxif3q z6aRgWpxGt%iC7??zs=}eAY6OLvuN~_3wxD@agcMlI?!XM-*DCDB0(gS4|G@G`a-kw zrxUs!LbS6aCl)&3`Idc}M68u&`WM#j5T7dDhh2!B3yPbs6k7S1^+P2_d09TepYKCQ zBnvq@lEgf&O{GKEeyJtOP-fJDVg2}rn#1oeS2JoOU!wxT0>E^UJ?uHm>FfhDfMp+c z>6^U4@Z1EN`naG1@e&+2fy9c?8lB&b0 zfx4HngLce$HPYyyxvF-ok)b%V5X~S-8`R*jmjwNpG@OhH(#GrG`~p*)qy2*ze>c^; zE=|!dkNod>PU@*gj6?;cq9__J025v&K>~a?v|pDiG>j!vz9&`ZYGrty#FkAl6FnmC z{jsgLy*F~~6T<1Yzq5HJil|)U_#5Kh}jsW&A z??5>$fPnvNwtCAZ6lueS_Js@T39o_eAE`?+o!6^q4XOPls@>7p3sM%Oy$_ z>=n2}B)kPA4=W_~oO<5Sl@zVvG~G@a_CD!t%WiP*PO^9g*;WeKK6&?hajJs!h#8qk zWLNwT)f@nwd`MV(qt@D3?CC9CN+Y+)AsDYUUEyh~?+&JzjwiCfy0fRLRyNv+nEwvg7E^cionQarC=gV(>{%gvFCSW^a5knDt$2 zK49m{#*}#(JE>-#Z$@BMtHTcRJ~Y@(eu2wLEr|4#7^Dn*PpCL7Z932_xZ7mV4fQP3 z8om?Oz6ny19kI#sXUVuG8f`!5vK;m{o5`AT{n67yz=(vLdaR4VO)~wJf~^xB@Xz06 z9Vv8HM_BT&cO9%iF*5}$M5;JGU7!eL#POnoP4(@v?YpA>blmBRUA>BPgSHB?PPmHkC&li!_{r5LpU7DTW4NBX)HjHjw<@%UtH@~2S_B-)qcjsgm zA%nMDssKBAY1vZx>|RRcEU~Ld!uK)N2vsz5eN(s+_>SwZ#b5k*^l8bWl}MGWQ@7#T z8j8j~#3Cn@J2V`@pK) zFD1Mku3DI?^H+>f#HKu!VweuyK|&`}F7NYi zpM1azYyCdeGe}vTVmR#O83$E_fQw>VTe2b-f!kF(*O;)j0?wgXozT=Jak^&w`KA=Q zX=*HaZBo)|%P1WlqVhkhy(y;s+f!19uXNa=bWGku$9;wI|N5CwE~4KX*E-MX*6$tk zaF@&xc+UynyfW&W9P|Sq5#Yr!Ix(7T_Kp$WU1!bWuiWz=KS<{#k&t!UBA#r+8LKc8 zPM5SR9yLSX1S8(z?KqA)=;UWjqJODudGc;o%vfhme5*+Plyzx$`9|UUU8}8ZdUvzp zy;-*`wfmM!neY&Gd2Bi67F_?%)tk2U*<+?ntOx?jt&HOZ%WRV&&}W~27BRbNXzukp zXy@YTaaJR>f`T*%_@--MZu&zuM_hv~B8;vnNJh03(@PJB>;E53=iyKF|Gx3}IS!6} zj54!lRyNtAvK5teGE)f|C)phPh&Z8RW|WbVBHK}3$ zKo~p%$Ftr$W)Bu?9|?U@olb8yQg*AnoU))P5U0NOvN~8MeW11@Ir$VR)P~G8&?y^s zCUVMe6Q$#2!h>J&8@=Bu^dE%k{B;x%g=b(I2iXOUwIO(vPB|`KvTV<(D+x6DzqkD#DA2Pk+_BTs7FBLluJ!D*C?)TobgZWXWW?ag1 zzt40dqLt!%WDTFZZ8xUPy#Doo)KxQQ(4!-Twj&4$z&HaV{&A(Xv+H#k%OT8Oo1ET~ z{bxQfW*@zS@v}2LaF)Rr(^)=QoFwuwL3>{}#~HaRNVpU@BAevQPsV zk@CLGOpA#XQij8J?aOums3yIPpUILgk?L{+tc=FegFs=$QjjozK|DtQ(rH4; zqsYOgf#lo6JRT?s79S^5cgVo24=bnrLzht}N(aBL34I?dKa}S*YxQv=40$5htT8Y) z&4q7MjS;>|7c{uo8TM*TUz za_ZhmNoYa=rp^1wjcd*vp5diYA|Z=&psR$_#k6lmRIEhF!Bp=` zpH4j_Y^Nvb>%XnZWczk|K#7rEG< zW_q+V1hB)l*f}otf3n^Zk^AfO&4IIX_sx!IVrOt7AGtSe1Yu>D4I#~+EsOCtt(y&T9}r__r=YvVO{cqtj1 zFDG^TyJtxX&cD64&uic6YBoa7^It+|phMXlq0oVm=M58v4NFejC(KGox`T2zqhN4c zopB$~k)%*h=K5?~sbL{gEO`B(`Vz!>7cMK;kbbA2lY(W4eK?6Gxt?ck%YW3R#j;*R z(I=mzYS#>ZSX9Hkd+T2=#28TM+<95vt|>oX6FDh3GRfs*<@(m$I@as(^=_(|yXDdH z2at?4IdRFNjxkgF4(jS^1lyrlNjNhO$7!(4E~F>JXFi|v9^nnIfq-ImJ!nL~{iJkA z@?i&lo|!F@Ia-J05&@$jTQjuZ5Sn9e8I3pgudjv$L`_dgE^rOTh zdN0km!myh-5~iFJ3c&>>Ey{b|Dil(BqNN#*=#mr|6g=$-5Ncx+iiz)ODE{>vnY-)! zVM(X<>#ZODC~iO)jsLiEegnD1zx%twOdFi%3hWHd_@0dT?mFGo%CHe`%N(0ELeuoW z4|N-9d8pi+IpX@@+$}K32R2Ak3{8x0mav}oHE(-wq$m})0S0`+xj!H3o^f>;U!6;@!fv&4xe#Sj1Y8OHJQ^&GPVpccpbC2WZ9_TlG(MmEF*%W&Z zC=x}!Z9YG8EGYk_q`Y)aB+hwg!^tHFVirvah@RP!_d4n3{TwYAU7n-mynrGSEE>;v8gef;7ljJo5l_z~BJ9CH%d(~gX4ZSfa}rmb z)XJn@ctlqNT3qfvq|EQt>W&Ry4HQvDQtR6@w_l&KwP%u$G0Ak?3Mf~#XEcZZok-~4>10$gBMxiTYmwUX58|Eo&*$iA8Im@7Q%>P7`Nmw}AwQMV0*_^GP^D_NB|I5F4PwONABwnHh z&dITS`U6g((0`92c86{E_QsK{ZIxNrKtW;j@w359HMA50{ynReAg*%9MYZw;R#51& z-HHDRXTTZve0qzA%Gd>-KWoEYe}!=_e0W-gxkT8;^xdXw+6A6q%`z3Q|Jjc+d%U2|vwa4t#kxoy5}q)Qkf7@92%ga|?r zj47BdFs&n~`I3f{N}0tIYA`9b`$-$^LE@DAr|7?iF}LwhToj1p!N!c;>(+gAE}*KZ zUjfyk-JXB+G)cDr35RICV_UXvzt9m{a4jxYD6Y?pr|k&6M^~*YcxoW2Zflqs-(*m2JAs>h5I0~JOb9tGqzKnH>1IyWg7yYiFG!Nc z_9`CTH#BA~z8&pm)8XSN4J>X5(_g!SFOu`R9r29!!{Dtt$BHvMEE#ITm+~g-$4g?H zzw-0BVdw~7qs5GLaBu(_59BGc1#-R7jhnM6JiJbBH4UuuPW}g}xhAWxN`FxTTrZ`{K{u8kW znguTF)PS>cfu{^lfh`tSR(ZwosBqKUy}PH*@d%GZV}YY7wy)69#tr1u!ygtWO`lW? zQe>#?&WwdVh%j1q*V&urY;9-)`&&&>$DL6R&q4x3|7fsJC4ANf3^GPF_yH+Q_-n;P z<6DvNu9^1@aR%43_jBY+t0Otzh|8x4d7{LYrRYG9bZg05f)Sv>((v=^WyU#mqZk*L z2A)Vz7`Y;$h=o-O!l_1`qav(ya58k?7G1^IB__;kQNO893j%tiTEKN)=P9hq)73E8 zeE;p2#^e4lE17LJ7aJVlm)|3@1uHHtKWKDJg1AJ8r~hu)?RnNio;umkIFdMEqHs zFB?p9#NrGJeUByiT=siPD8a~Cy8-vr*1rO8s1=N2#g-V4M*eEzZ9Cz+ja%NBCIef7o7T4&5k{wU)vvd|Ewr4 zm~eE6cuE#C7Gf*u>ZQ>bbv1GN;vY@!?>cr9y+B9Exp}JQ{s1(BX1F~2qrq?^_0Eg+ zfU309e`06j&Yn|uo{5Le-zG7Q$M|+XLhBH9h(`>p=%7sst)n%s^U+$)d6eg^iRN3J zsve=*I~OuQYfGO%op&GF7cy~;anFsHk3Txn_2>MM#94IdcVR}> zyMRTfl3BrKyYcL+d6WHgLdtuMY7%3H{w@z9d?s((6j+3Ai$sg$bj{KE%_m9xgNpi; zPs`|kVvnEg*Y5sk_(xLixgEG3!9+-}lyWMc%K$fx&9-(0v&DUbtO_A{ra<`f(d(yW zZ*;KJ(!n>y{N9HbvdNkmR+3~!g69^HSr;1$c&%kH(KmLpW76a~GEcj$7TQbP4?JiiROP_2Y-e`ubkB$Vpb4CC-O#HuzbOXEe?z?h5{Ub(?C z@0Vhv{wLfn1tJIIwLktL^O|qRKrGdN#Sz|uhPbSwdy5$0n8m*=Z4G3V5msx?IAiXP zCzi(eXC`rv}je?<6dFs)WHL*~TsBKwoDN}M{N%1>n`=pP0~-RumT*G->F zlMZceHk$DolP}5A32pN>=I~29oq~=on|Mm^>|vx{=7aUpz}7I7-|?}WpL=Pa!g^xGKgUBR4zC4h(Iw2mj}aHe>_A3Ii+7AqGDbN z&0UqRQi$^8T`Y7lEyH9e9SlC-N$7jietQ1XA5N&yp{aZ5D9WAm>oJE%>a$N_R3beT z2FHz$=$THVGudMl;%_e_>m5GNJe*Zzb= zJAv8BCk1;oXZYNBL!Mk3!J!>WIvrA6^fJST3`NCE>jzcHb)+Yy{uXL+GIVn~7Z*~L z8mXAoi<&k*GaP+MKpXZXok0rM#dy(^8lBuEu4;z&=`Ne);S--#ru{q5 z9f<*OwGwX2H3RcMS>~r7F)%#IBR1bCM(iLf+*`_2@KBDF&?ll?%yL6uhPK&k43g;K zTes~{xgo?T@4mF{bP853ldXA!y5MY_l1`>-v{s?zz2$Qc6AjWCZajEI=zSU(E@m)d zGb##<8H@zGT}!BZe{MYUh|G1(2#<@_#sw4%F|>*43X!pU6n1G#o^Ct;XGu+o4eR(W zH;7v7iXXG%J=P3}S}?&-P^cXXw(c;cMnoFbj09}eso7TmpSaAR1>px#GJ7$d6z=^M z6C5d9j1}>~=u9e#CHLrAUSV~k#j}<_QA2r-rHTiibFH-TmlX-daS!9rFH$2x*US5T@+JEGYfddv#CaeK0?Ra@pGn0XI?BcrF zlyS&l9HRqNKl-Xc)d(E`U1N8GoBcTx`|ao~t#{;tyy)p;>8fSWdoJ(&H?7~_{wOVM z|7WPK)1MvL+XCN#6~(G0I0T~tr^nDzU@9FKLmhjg-@GB`uV~&`dLPft3uGC~kY(ca z7>jg8uL@?c7_09|y8$Pk5(zKU`uzKL5L_Q}yHqk36rGHt^yl%BcG{A*|I}%J-awut z4eLeFxq&#{vF~@D@BH-7>W+Faa;@;dtK!W_bGYS;kF$4k|J2l`nr;6Kukzx@BlJfP z4XZ-xHwUs-|BXcNaY51e

    @Hes)QA!;W63e>DUGm{>_}tZwSR3ghY(*+Dupm~zUh zdW;Z`K)U%4RMUeYgEgWCibFe!ZI1lhpZfZOD=B>-qZ{4D;3c( z+2?ReqC!Q*1{+L(nbqmU(=S7V{~oa^UyG}7h51xF{dgxlK&cwQ6x%K_(F`weo|?5w z`tW=T`@U+0K}w<)aiPzh(fy!D0c1WN8cvC9qA5SpvehPik=2GvvB0wYnwb$~?V8Z9 zp+WZrzEN|T^T6h1W8GPy(_``0yplq9*zuSC!jGrOupVquj_+0>mDHPP8L$4{AP{2} zn!B!T8~4fzv!m0qbuAGaa!*r-8l|actm4+Xs%So=((dw@I;Va^heDM-AP{E3Se&E1 zg{!daE&5jxgj<^2R?#w9KzpNpJ2g_g(g93M2+wJ+PrSX$uneB#AGS{C!n@ey`IABk zCB6}lX{v`Rd(0@5i66F@^9Wm^LIYz6*-THf+i)J*rK+_^_f$-sUl{mAe6Qym`n&YR z83)5i7x{!)!y#|B&c)VVfs#Noo0E2T$50xtumJ#J7vl%^gd+yFQw0S-KlT?Y^J2lwR|riO+c@!UR4?&lP%~kVyC7S2-7ila3}+plFYeUb==Y68f%CSOs=a&4 zq_I1AFrNH!QSX&uD4MA~plqr5;itw=akK5W>S@ND427hyA~LTkZR?Wsq&w!&(-YRjV@SxBQ#%I$A)uIyJ&L zZ(C@QdzJngjNCf<+DxqOV34|U$lR;t1o5s&G!tgctiB9RGs%|_*vQhtrFB0UY;$R= zM-T&^CY?DevVi@NXDjWEc;^yNcTN0_z2ts{(S?t!Z@+mY^m&<_qKcOazKfp!dOwQN zo(Nrip-0aO^0xNRLAbv_wiPDdo{W5j*u~t~hK}=N_vYk5uzOJJ21Dz&QP&3Txv68` z9e10{pG_V?^_IZ~Dcio>NVgd0jRixCh^x}52ZoXul|#@G{?LKU@>?I+_(=QK>boG# zcY&iP>xdoDsUWRQ7hhsvRDHbtf=EqglBpg3^=s7M_-=pui-P)#`+CMgC*~ZYhgu<# zpb&rExdZj=5$7itnd$smluLRbxC;tdq5I#i>}dDOH0avfbQ{I5qZODHzE-|LQdQAL zn9d*~s#tLmQ@jUe?{zy^?G?XvP7TTLECxzF?S5jkWYqler^=<%3F=p3m|&-4Z9u7|+9N7aGm@|#~FaFxO{@>jLa&TJ&8J@gIfbyOuP3$0)oHv38o zR0K64f~xI`_#Ph#WrJr@?Ula>2o^vXH(5m{1=*8zRk{vDcP22WW4^#=Ij^4{lid`N zqnvdJKtjnUu@;V z>0nSKdwtaD6)ZP9pq{uw+nvS5jFe_=h}%e}jVV#;G-FzfqR*{-?SS#>YBueg4a(8r zcYHj7Aut#`rj~M@luM#QYqps20W*%pGapxob~V~_6bN$5{F!&lk>tIo)~G~c14mIY z8>DF3&(Xk`FT-8nnG$;6ctB{^WVR>$0p>G-Fdw!{F6-L`c&4| ztsZs=qE{#5P4&+4Grh|g;Es;9d8~$Ggtt-Hrjkx>Fn)cWGPIj!8GNC*J)Yx0%1;ft(G^ z4H{7;hlmQlbH>~y*St3YEM`U3L(et9?nw?%dV}#&~ zo^`PGXSqV=o?D_`+K$(dc$aMy^=IiT1J~y?3=O0_>UN&`Ve)Ji{nZjN7CJ2n0Zc2L z!H&gyJK$}ym5|_>gxgN10wM=G>#VDb&PvfeZ~LaUtu|A<@S{l97dkvYnhJOoS$x(7 z3m3H4|520yQRVL(A4?&==)JU6W}%qkX*nI8zAwVv zcRMdyIUBSm#!YjY>qn<~`&=3S8N2djPh&BhE=R4`OqRZ3L?rIQob;2+TLM%LaObsy zBRQKN+Vi|pM@5v^KJo}*eyjJVwLp&i`^>>RoPddl8KdE+Quzv6g%|wW*`5EX%a!E%Gr+PgrYh~;@=DKU zj`&aCt)q=5xJ5Y2rdH0`Q`Zn3FqRE|oGm$bH13p6Mh3LXt-fBc8NDl2?0jQT?042; zMUIwN#g*k_6gzXR019-OM`Q}1j{W<;*v?F}$GbWi5bb*sIg4d0NLqPfRMo`R`J7-- zp%6qN0@h==J#5O;Nxzvkzt4v7VBX%mu)KktCn!DC?m2NSIb}@6PD`<%1ASz^p9t9c zLxHuLUb&l4pf&kpNGb?-*whG-J@9!DqT$MX@Xul_@j&iGMmDN@y{ffToj(S+cTtto zLKJSBHpKdZtXvkP4N6$?Q^`6LUypf5L+D`lemo+&7c(K{LTm&S#7{xYWOzNuLX%^r zx0~Q=7UIvPMSqR$pz`vfd(^Kf-qBNH0SD@KyO-iWTnAi^THn@9^xXH1YiN8oA@@z9 zm_?sxxTtpdY`^TH0qb)0R!8Kv6m0PnIacdhb>aP5{aB}&1R2lz2PE^wm#w5PC~#)# z?R-pzPNX)0zY!?4p$GE6SOU42%63t=E9n15LwX8lVteb>l|Q}{61Xl8REGJ9HXi*v zeeM8y*t|iZy2T~FpL^gyzgn1#WLRTeOT$lII^>`wmeFnUo^D8HU}-IQPQ%f^=Z#7F766_U-c3_d=t4q zc%kc|sq-iwcrlmPvv;xb;vfe{I|EfUWPWhFH>e%p{O$^FkgtGG3>uVTV92k#C#Gf% z!24%yXysLWk*84FTiu5u9s=zg^lk;3@mz75)Zq*?HKpr*^hZg{`|BPXDu$fc8$+I`HiLBS-r}mlM_V(u&fL zU>fR7W#`M_RCHn|$Oz0wM=!Uj6wCDJ17qO(7xY6g(l4jir_uS`zt&ba1%~!S?j7Xa z*%FA=x-axn3{i5a!D~u{AGXb4dc<#L-uY5Pr^O_otr`sLjeKeO zpZcxG&*zQrmX0`mpRD^WCml>{zS7=aV1_1mhyHi6M@V&gUuD0_mcM>S5jj zATDKI^-1zS&{Q#6KPVPqa-klW0)??*>GusE-FD0>Hp}&O)1gHrow7x{>}&3E80Fuq z-(kHOtR*9=1I#t_S_A9>EE%+Vzst>=9rpe5#pFQP6lpm<6(2WKj(A_iD6lwFmM=!C z6xl?H=U26Ba~uplHNStBuH))`?faTUkSl#(XHq3Zua@gF6D0760q;{H|X#l`DAZB?T1w`48efbxeS{lER>SYL*WNmwb-cY z(}l0Dp$`S2mGplauEQ}x@cqm8=H*+PPKoE~_p{?VgvmCqo&#PLc~d979Ej`eA-rUX=9HOT6jhs4Tc+ zjip}TDZ=M2E$bFrx1Xp#7n85Ztw)VM2fSCFzIOI#TBN|nPWZZ2`1OpHp|_k0RUj_& z!a1Nn#pUSfjacKyi2focgx*t1a$gt}LoL8I_(444apw~_)L?&!i{w{@p>3Az7ORgQt$KSBR0sg1YASm?6O*0G9?viB3MHNs`uyG zCyyt(w+qwS;d97=U&w9&U+9+5xeO{?Z9a&`%!t;^fYemV{>A_j2sfGJN(-7+gYLVh3;9kjZ$MKY*+}%%y_R-(&D`v;Ph9 z0)J>9jSmtAN6=!AfeG2AQUfyk(|zKF>9qBhJtwakc6Kw+8B@&;IAW(j=%IGfYbLi+ zojEjBWu{xk&&SkZ1A%tW#Q(L_mdMq${^&%Y{0^3~_Kf(~*^RRDd^TgQ1krx+y}DkfcxE_5u>ZzZ<~hj7u-Vq5Pw$=ZpDqc)cf-zK6I3}Uj1doTfNJc~ zSws{V&X$TF)`5~?O3^MVUx#fAA}-XeI}Kz3pX8QGcZ@ycwkaCNX^I~D7TCAVNT|r% z?PEe|1)5z3J=B02BRwCLCFLA7=;DYKX*cP$1fnD*8AA)^sQufi4~3sb5;BtFhO$>RM!DhDnj9oo8e zapHZM?vKyY20^~2i)SI`uG}gmD0^1k?D=Eb3+AMk$Jy>~CgJDRZvTQ5K@{f@IW{9b ziDNNxOJCY}{^b8zVXu+bUi=ZS{#d%nqOkAxJN!ZXem`lwd5SWwe?SSdlWX4iJhL^Z zlpyg~a$2SvD@oQHIqrQFwfNQS={DwHS`k{X?Lbih%bAsom?5)a zFwHv}0z=StXv|6VumGuNvsC^|sX^O?!m+&&OwvPmfbO1}EZ8w{!1$Ujn%+d+x9!pS zF3!I%sjFI?rr&NT{0!SRg~mgpfh3%-d#!81<%^tDX*k&~inVGtAsqyDx30H<`ZB1p zazhavAU%@6IGrYxpp}BfT{SM9=sp{amBd}_I$YtoZiJ!*CbN>JSlC==;*ExDiD1^e za2;R7&iCo!ME(idfA}Swi)DQ7hYDTnyFP`Ic{4W2bCq2A*la%ef2#DFhcD|vovhV7jW?Pmwa1A zZ{?#7dczF6!4~3Vn88HfAS$;t5n1TGrA~lZ0DhuLpq}7L>p#1N*pDTal8+hB!=PZ! zhUOJ2$bIh*@_I(`=FBgvQuVu5UQ2H49Nm|*ceYNm69y1lshsqVR9m`h5lDt@E#wK_ z%07V#EF@$K5O8h(a^eNn6xM5neH z*uR)>M}MA!Lwet?5!%n8G#hVVA!lxk+`)r7dcMfy-KK+>beG8Ew9+V$O1mD@QDOw%i6DmlydU^mM+oeJ=jB(%5Yc%=5KSXlz*`@06_LUtKY@?{We#A@194tZ>Bq zudNNd@YgTr*VGWQ*RG@5u50_yhG?XzvSSD8risjePIq79>`ftC);6ug$Y)-{(ZJFd zRAOg5_Vk7mOLZKe)N)zBt<;i$X?M-0K+eMyYa3(G-)iROc=6q1*$5%2_+Msu?9+06^T`JVZMjuW)? zKv0KJU1DB22L~GL^PXB8VuyvSnR~VrT^Tg800o3~UUP(-)U|$n*aab@S-@;a0%S#au*M)weqTt-Zk4UDN}2T;^w+Q&2wdeXT#S%} z#|85vw;!E>k30t}Y>n0fQ>Ed$pOls${KY@_;^K<8#T^-3yWg{Cb}5<^Bl=HzkDxAZ znx$f=HnP3naeioxz=?fgNy^xvP*Ho}XI{%O9lWFiXuL&Q*=YqO)IvA|pGvM!tY$x) zD#U7e-_h2+{E#Fry$7MHwpbh*Gm1*mLo5UdP@-G+!j+z^-;FTm$UfY!%=4Z1wb7kz z#g}aAZfX~FGh_22YwqxkB1<_%haqVEy&3A_*dcwV`=o8**Sx|O+VZ3>PujM6A0 z&`=OQll}{DhS}p=JP+O1lE5Kc%92lz4fIrRh9(P|7Wu$j?o-@+a39J{{B_+oqf;{- zVhm!b-Mn=N9G%Avh=}C`#mj%vBR;?Ybq_Z3@3JqCtA%G>(S+lUYqDA@X9*J?5+&)g z?D&A5>sRH0drkI&9WdzaIXZ6mg(XEaWpo_|%G{c(XYSp)6(Df+o&+mt++iT$7_+7I zh^0>qhl*s#c_+>)m^oG#*H_L)WiUKQy9vfPmpQ8_{8Zi?Hm1~yNTT@dpJNgeYrWaO zZw>AW3GO#kNtr}~z@7xIIwC($<;_w$ZQnEqD=^0W`)<^~yGI(8wc}+1wJFt1z3+D} z63L=QYu+?G$kef@pChIP^R9U|Wsekk}`@&j;QgXOC^eC)$6+43)uz0{i=xm?<{q$DmKAT%T z_H3Y zi*>5#PDmZ~P6E_9VJ@$u+(0J^pZ$KAL`aqH{^Cm_!;8j$F*~9`5?jW!K9l8v9Ymq3xF%I6!o{i z2<c|CCh+A;)g`ESNmzq<)BRgD9Z=69#cw@9;-MAn{A5%m5Y4g) zWN!s?eYxl_`Y7t&Sx9&R_?hxGgKFltc~X<0W-G+pneuOLVFUR)C^$iNt2I;9QzW9) zfSt}NWZG@`n)@M^bSU_(G$VYpIRSm#i&tG2WehTl2`=X{EAP2Y=&=Me*BadSaz~rX ze6#v2`jv$)y-5f^`T&vzxY7+yDN3fd^(jA8?^Qyn;6}mkTK@fsowrz}!A?FQFi(1a zcoLY$6I&C={+bx) z2JtpRCxm{7p}AeY;X!bd>OTRdCu1yfB?4K&!V8l|UkABsS#~qq5_PlzJ#)6(u}C9e zblVLMx#O@ZZ#^1%$OaETUdxrMl|(3}iT&pN?%Ns8hl;KXQ@VaoMY+ENGzj#Bp$nWD zt9d>|-@b91zc2(_EgkC|h`EjoS}snkD+QQtg1twPK0WkbnmI*mN$@u!b4{%m=#b0X z0VG#LnS->mp|R(`+g>`(F}rVle0Jr(1L>~KJF$>=rtdBxvTZmEM_s$uQC)|xXOWkp zZ**qins%lx?@U(T=)?)%jGzeFrgDv? zO-BuCZS(1y{le%$6ml?68Ya^M!rY_9N@gWC3(eM+f<`I-k5p=+Fj)i9o1p&fkvx%M zEe(JGfQga*^%4#iw?a+oLRhHhjL-_(ixdq{BJ52a(>@=xxWjQ1s(!NbqltpF=hb|+ zw8JbpK4~YC3s&~+5RArnT8qjI=+*lfRfdD?zq09}b(+9~FT$Ok-;LSKwORVOS8u*7 zK?x=Ujk5-2yPHjesKsV1cmfQJ8u|QPP@2{F9sYbJcK=9pzZ6_Q?ZQC{7!S>|l`)Rm zXA9{%i+G`J<4av`KYx(Mn6v9Cnhi?@pxv*&q|gTWGSFj)wt@T~xs`%3l-0VCMV!7` z@E%-AIh=@~mlRS-)EM%o(UaJY%-4r#TCF1Rc>40#nP%>_FiNiQm-bTwm|c9n5x^O6 zKUqvZ*f}oZyt8ApOA>l6ec&H*5=A`K$(qZ$eERWe)gyCqbt&e?BlBXH-*`DtB6UTs zlT_y4-+TPrc;CU~CjI&}*qtIc)4TjD98Y`lR}C!=^Z_hsFzCZ4RPSr2u1`WHMnuY^ zFVXahq-oX6(to?Defh@jTen0uxZCmC0$*MYa+|k6H)xC1l+n)CDdn({9%eRRf8}Qc z+p$A8nM0X%IDdv!Lue`R@w0Lo#kCB{#2(Gt=SGEaE`qJDzfK2&2uk-eb4)wKtp+R< z_~T_V4lZg!@&iZgPX+)`7Qgc6TL-am&H{k@FWkGiBg)(c5(Y9a@d2ZU-*v9vfC0jb z+tXJF6LY4upd_QX$R4&4VJRu(@*vlBB0C1nMEq&Ou$cwkZK_u;NX8$NOh;6bSU;Xa z>3eUY_heD5bW`FpL0jAfAF7TK(DwYTa4lP&?&JKLtdd{yqbHLfy1dd3Fe|>Hg^1i5 z$|BS{cS!TI=@310L+lOXLCvDX0vxDfi5K5`PtW*@CG=ZtBRH{s)3plVnS*@={VE;F zzHZZcJoEKf&xqr-ZA!K-9Ku6c=DKJ9yWI-SPH*)Fi2bMle=IjvLyN_a=giFh7*46_ z)m?~faQVdH+e_In$t+=Q+q{rLK`jILEw{6>5tj`!?mDmIGIw(_vdnlK1Ek-dNih(< zom2aS|EBb}M<{@)$Th;jzLnG=eKEnvpi;XqJ510_ZJTOlqrT3IC%?hWk;V zls+HNsR!^}M*#n3!hF4Uv$wOcl?;_^rvF=Mg`|G%S@VU3f^+mxox)hLgT|6gljAHc zs^X;M{N`{vc=f>}n5brkAUyfKTUfn<6-sN?lxij#trXH^^GLqD6hx&(j|65?93$;VZ zbkqcW_!3#r%3j^_gYa11tq3UFmlP^LN)TM4;ABY})2Jf1V93*X$a^p=d)Eoj*&2q4 zbJ+CqmdzQIxt;!Z@_}h?xwBK-iN6ilf^0WME<1V;?p``}*Wn}>MI*agYiWhsk4(i0 zDN9>V&spq0-^<42xkEn>9gfvj-@eBtf*Y_6Cra({dEtdlu)#CEd=gv=UUhg~R@%ic zExqegABgJ+{dQo>ZV5kT6ulujOUiEfQ_rzR)_CF`44Lbf9*kxBG+2C#sdvMj;fE~~ zop9#^fL0)Uqr*sA+3lGK)yjyY?=&T~ZyJmiy>YkL{l@c%kFJ(cU1!n%RVXBvhPSiV zc@DoIz#BsN2?qBg@+dv^D2okE1-{Bs-4PM%RjI|7q50u7{-bT zbVeS3?T`9P0Dn)Pqa$2cL6BxaP6U;PaGnip>-+@-DAmntvfy zdW)|YVFKoh2>F~KwevB9_8yt$U z(hEsA69yc&!hFE`NA5ro*A3&`^ZfDR{#vXLH2qR!%^N0AMDudrZ%Xy@WAS;%9?DTz zs1XuirGG56&)3-|s6XBQcu>=J;9R4R;a2CGV_ZZWBo5dj&0G=8-g@BgleK~`PAh+% zHdsR%N%nd?O6xuih7f4|uDI`Z-m)Ee?<~u*KtTp2{AHSQ)vQs1#9AQU#AdK=v6C!j zDxMg|fm7h-_Zz^Rn0phU!(4#3%0sbjW9#;Ft=KosDOtof5QAkOC$29K6^zYI=RLNY zdNp(gQQAS-cRUM^x+ESQ$a#i8CoO=t;mxHSssm@!zU-g+aa^OIbGL^wsav5aMNid7 zti>Y{BByt!*n{rRxMPaL|93TT?RTMM<$@j|QSg#vOCM zTpK55RDdl;f-|9tl8@w=>4|&mGvx(OBSPLoRi9o8IoNoi4->TGm(S(;bjj^}Y7H+? zwN;zJ8Mqc3F9R6^#du+FqI>yk2=7LxX5CJhaRu%+xRnq&k0VpHXK5WvP!x3&%Y@$Nr8V)tnOvql80o9VJS%pqr7~#D~c<33mex(<-21KxT3l_S<(BK{%(m( z^4JhT*=|&!|3%T_py_D*m1y5U^6|KVzLKAJ6Kl0+=nzD8EWIN z(xzYTc&y9tDnndgaxCadhASU01KEZ~wT;Y7@mPvM1=NNU>Zt8iw|R(fLN2hbkmZ{G z4lw|1<5Bdr@!iyG_q{Ryx2dp zDBr~>o|JB^ICRM16i*V|UHqgF=LmxcoqP#PS753c#ABnHTPRdl9hMFg4EKvqAWT^8 zq9@Wi$nl==&;-0$RvAv;d{>O0dj0y^S6<)d>e*{1F^498rqyF8qz*CMFb=YLn^j0p z>t{cF{rhGK#4lduhg6%jAD4|clQpyj;chhMWK=H3*!oc9x|`9JszvA zQxt=1pBd!;l4=ywZe7&%?qc_$YvUP1oS|V8H@>6yG+mI2mLF&~zV(vm72j|fZ-iY6 zJrb8H{&BcJP12|$;Vs*VmT1wp>q+%F@A@S!Pv07-*BB(?vyYC_XT!^?wfQw!s*f2C zvIOrV1aN?$rP6ptQhozDPXNVuJ3LW6fag+Gm3z=O zMn)4Id6B8nY~`;H)6xz$Hrz^m5(OgnBcKMdH~b<#-$46M>vYuWm(DAfPL}+(lUe*m#D?_Lu$^Xwu;ZfOTAhNDH7$kMu<>rU;f78P zVOW=HR!v{LyB15|pwaENb3DbpVg-XkIx@dN%Pj{ZF>`?@RvK2DOuNIncbZXnyg1|d znFDfjsUnd7XOS`RYFC`~#sS!881s`p@A>V*bS1^U`((FRXUl@xb5JOeuO>iI(_Cpv z$BVug7Q@wggrGObmmm#<5ODPJJnd-wuAj->u&Oka0UYc+Ecs3CB185tle#7JLs7p= zvmVF{v)SIK!-)%YX!~d3(wbY1zc`_Vz zXb;*@)@vfEhGSp<$nU-AZ4fg6JUzVBPXqFgaL=hl2|%FzP}Pmjh{bF4-9i1mANf)h z2BtxZie+_vLbQdA$cSC%wqA3VEn(}~-gv*M;R(-tF&7IuQ?0x?_&-V#OyM#@@5A5h zUH}2u(O)PnASt7-3P6j;oaFtTYtLO6eWp#S=W3lK{t^)wJth|Ds`X#zR{(1~4}2Nw z`8|0JiOdUgUIsaT8dmTI->SX0W?T&zDn2hcR~FTHC1WE{OuN?wRjL~luZe0GIA=yn zf5emqQ!`3TY9MS^|9gcI7rRd|yVutAjl_to$G(rRW65IG58K1^jDVas*&e1^=wDjm z;JM{zURLBa>fu`K4=imUBw_2U&kD&ro&)D1|yH7tR ziFHy|0Y2`KX~Uw9`eQr^r%~s-=R@=G$tEju^aQR9e8 z5D$yMcX`u?-&m)%M*W_2*=qMA6ic^z3< z`=%%>)n~DKNdI)d5+Rjwx*{; z{TQ2amuqtPB^LOJnsaAz0WU}0A0fvQEbg@bne$(h+eSo}XGh0wdi+x!@byG}p}VN^ zt!w`xA4HJ0>*hs@W4#_kYdx^bO6aOs)q!>@v$hpXO}fcd1jSn^xX29NI5aKS%vAxpJFjN`RCK8FxqP?_S8~4n*vW^>Pi8#`Bkh4Mb;5KsbR{EW6)t?c6VmDZ}b20`-@dQj1CaKWs zlb*;y7G1u%6L*&Vf;Zbg{9=?O6xdqSY=6=%JS+6DCgOGfqCM~LVaqw2^uyA1qwQ=N zfjDn7KNC7jsiqA-mj0x|+A61-l1q;WMSPW~e+>jbDU?4_+0xHcd7^?!?dDEL>qhNfa(?nLoIEGM-Ht~#JG-d|JCUb?%; z`i~;u&)~t0E^|Sn4s*SX5PH>ml~g{@=-v$;eib}enT!29j$2AET)~?SrIe0t)i|0S zeq@c(xNCVR)_P$6VP}5|b-)}F-QE>?r$emz{o~Rfi7GcRIr!O&z07fytly{a*k7+I zsefp_p0R9oj+f@eBH>Jy`)1ohF#Kir)pj<&S^6?9ZZB5qn?jeJP`wIK3p^iQ!sZt* z3Yn~RQXX(=p1UmM4VGyN?(zYcX0=)O%!L^n=|b0*?6mVjwt(ult_7+nLEzJXC~5GYDiLoQl0!jarxBepE)kyH=S1iB)m9o?(E92q}92V+&1 zl={y%qj*GpPKNer73QCkux3eMaAknL0< z;!YbeoZi*>ZxZm$@{1D!V0Yj>BLJc-|Bt7)42bG|pNG#bOCyaeQoFd6N~$2eq%;UB zA}JwChjcAUN=hrO0wPGeG^~iUgwnN0_tG8D;q(1Jzqh^b*6GuK=*sc!XvYxO%T z!m=WbOr_cVd3q}0u>DcQz%*PDw#m(SdD%`#EYkc9;UZv_JYUKr#|3D$nDx9*2%!F9 zwwIPStG`GQ^i1Gx3keqyKPmRaybisBgaX!GJ`XytBDqMp>?4iG<3B9L{+LjMZVMT4 zNAe+zc0*p1S#T+-rc)J!wUDOPh8cYSJwIU_Q=$-6Hsm=u6to(9a>3km_-o^g5Exhm z<{UbeF(R0`0x6Gz=%Gwy7(K=Y~{qU_RwH zH(}hQ7r>Qbbi z@Si5)T=LwWsq6g)v9!=O3+HRaUQ*DXDp4`HM?;bqy~N63?R|C57b2P-kYdP=UrTs1 z!$(B&^`G6G%e?O{HW?qBp(#H9>7-lcW6Tma^L1y$Yl@IdUR@`w1umknIbV1O+`hTS_ZB>0bsbyBLcCmk1(36U<0&Z z8NrSW!v@}jhBNbJfNQ9PIWhFlJ6}Hj3@VENc6umXieq2>+`UvF=Xuye zSGxp7YpT|1%bo3>-11C4cI2{-Q;In6#5m0oBZR`s1c*}TfA=+=&{Ozj+8Ax5`?dW! zN}UL!g@qoq+J17PYi*N21_w#N0!;#PV#YJOF6u3jH-nEmpffpZO4>Y-Pqw3yUHzE+7>*=f&WR)U zu%Uc^L>xsfZnud=kZyVg9(YcMFq^)RkxMta&`s6PgYSVtLDdFJua`>iGy8gk`n>pE za1u@_;=Z$^P;;5bc#vB4TO4KFm1vFu5wVx&W!jaX5*XjC%5Gfc_udvCpJKX%ci7o{ z*PDK+lxzIeo}X85^d~3RP;F9~;Z%T!bHiIu7p_rdDda_K-+Degl8|K^PF5iM5Kb8v z$j_?4%XRQ)e?dLk`W5wuT!AG1c~Mv;gWbYvQLX{=*(T&!wZO2&I(TX) z9<)0e3cQ-vmPgerFUnmU%`3e_I*_t<%o96zi?rP-eEe;ta#^Ev(uVx{Z3wN4I0k$h zG}cM7OX5Dqu{o4+XFJHLv#I*Y8~#S2+eP&R#ptGO@3o}%)u{&QVw%3;b4}aD6p~%O zaUL5B>fw}0-Dqz<+P-+pbTe^_$LMPa4?t_k4H|l^bi%ygV?P^6`Xt>&xm3o*BpvW;YbXy?pEt@kpiC@ns2Z2Zf+vYIN zq)SAb;s79t5FhA9Ed+x5-%`KI(T}pWu$lLHMQ_oG#;fEFWYJ0skdx%MNcByVtHPa>4w@JquXr4>cq>Wg1)1?QYzh zDTAIEs#&3}Zj|Fp9dOM|caTJ#e}=@UJY3L6Z;)7cBvkvW@@iCdi4NmfQ*L}8`u2l zq(lr?aIM#335v8WvQA&GGvcZs8T=6#T9rUe1w?QFXCLRa!xO-lfA$l8eR{RWvBQkQOd(WaE2 z`6|{XVljC!lmNw*u_}ZWhYKjMZcjkZk&xh946a=&>MW!RoSgCTKbj)j;Wz%w6XTk% zn%47XdVM_Z5y{%ui?mBCFKh-)&Y$an^WKXiIUyw4uh@o8*y4()1Pvyg=CK(Z2X@ll zTNVZ;5fh+P%xEMzM~t{woFK+2`9U;pP4X=2rS@fKhW9e0{f}wO zi-bzW`~Rs^RYOnzL~S?D0KB4hjg9T(LPG+uuPuY4UD4ZmUe>W<|4yyD4Q>Z%{r`%C1i$dqt# z>@ynA2VY4i6(c*esM;$&@qTGBnm?S4HJGZWEs{lH3g>W;?Tz}ANrG6W+;elY6wMfb zPtA)Z)mfG2H7|Na7?UXU2}OgXGJg@bdsj>^Qs^GbU-lipB~fJ8X!2eG!^e#{*#w<- zti=%Jl~a=+b<0O_i+b=@WK5R`WC?tok3>tkWCIJoVfE1J6UB5cAsb!QJtCtE4=2vK zEu~OmnI@IjYlt_+;vC5Qi+{{NiNMuXo+sw%V+&ipJ@F`>_Kx3{wxQ<&oIM*3pksYV zB*{{;GDW6QI9aC4sB8tz#NkvLeJqU{g-P*x3+%$bj+BfAce`ari4PN;kZ4~KIz?v= zjx6=T8R0mzM=!P;h6!6TR6Ge*TL5{h{bgAEkB;Z6uJz7Xhfa=w#_f`2F{^E;*7r;|aEy3} zM4Ekh1Q(>`$>3xb^5y(>b2GWTHf760*C-}^X-e92o4w@V5+0H9TuA@5v7XVaCufP< z7$-I3h(!uh?(K97&Sey)(H)xVS7ak^n7&_4Lpo^Da+sNc}83K~*KaCtmwG6jAz0>#^n~9+=#2x&4Ul zO`-s#&O*_WnzoFTJxh&m-&>zO2WS+izLUggE}l$nw#5yGW{2{;R7ez&$XYR`P^|66 zh~6z~*?F6!g{iIHPy|4vM%`813;48J$uQKSOlPIq?%ih@<;<7`lWXS7IbK`=AA)`1o*xLN`))p(5!B%nTl71A7!YubX#fc1H%gUEo81Fq}S6_dzeyB>WYq zn)t&87Bk&YGi_gYR0n%GxDhuW1VfEoelgkVZKOTXIL^?t{(`q0p7WJWig_~s`4)Yl zpMeRuwSHXtDm0VUuj4g*UNK&?tY=Zo(T$B+I5tu?^ah6w+z##>^@EMb3KE6VeyW5? zDB94Fg~a^Jix!8}n&1Mny3NPGtzR2sx!HAFP4oP(j$A{1nz+v9;Q>ho3@!d!u$chTSxh)M2Gk=3`oFtsn z>_c^Zk?0Hh+XMU1zP(?<&FXQfxK@?>;i4j)N6DW4Dt@c6%T&3;_p=;K(zeCs{dN!2 z4Rr+}i*(6`P>3vB4{N4lNu_a{E_69skoDKFtbO8@zs0&s&|t##@-PNq5(?#p6mamW5|lO~p%SrzM>-JK+6iQnc;M zieM8i29Ebi2ctHe`6;}-C(M5+&7&lW-S)bG`t&P92)v0jmOl#wPCgSrk(V7u2yqOX1x_wB|jhMnr4stb^r0hy@sJ* zrEg$>?y?aHw&GhpG)!#Ijh>>Wg!h$H*(>U#c_9qN30ZcmNy`ptHrGK5IBRS=ouIG% z6X3XqN*+kCdd(Kr7az4Q5>ywRNVcsH^-htrTr^&E@ON)oO?OOCl{%gGe)I6`GJpM2 ztuwrf7(OtS|FaHe)BL_^y7hLU^JdAz79+m2=gGUGC(cTV#a0;jBYX0DdsY}Q*fUb6 zh>@YH5Xqvk^u~dnc|6q#zf-Gl&1%93xwtr#l!vYu-O!!lb(5N1pIgbdpX5dI zV?31N&pX_17M>p4Hm%7jNsMmmWfG8^PJi)nZHj6UO0QRIXphINtf@|P#^{7FltT5T z;9i1TIz2SmoM`;*265l~*0j{)?)b;gq1lQSGNntu`nYI-BOrBCPkn{?0hKx#FuLFN z3Q7i<3+;J^lHO`z_zhNl*j47=^v_Rk@f(d}DNx1GT5FzIe(J>*>99ALhA%c~k!`+P zd}5(z9q11Gqj9n1Te>=LA0U+Fo3dJUM_|@xDg|CTXn2i(TJJGU9euVC=P>@`W1K|Q zUq(YSdlXKF<0jy3d`N8HzVkJ#Y}5F*`0N7B(xM`}NxoBHzR$$>m~X|Y$GcQ#sGCe5 z-M(!C zkAG-7?P~JtI9vH+M!`YJufc%0w!j9ka9krHYX-tUxe(v|PIYYn0>5-l{N5WjYpQS1 zNtyCsd>7O>YL773-q7PTJJvx%$>Ku_Di8|Jlx`>#9a8UktbEulja-#z@?L%-lTO01*S zZ`dS6_`g#PLh`ep=5t*!c7GdIh=Td;6`Fm+nrkJE-(;r7wf>-%Jl*`swDn_u!QaTg zR}ZV{wp)cv?a1B46JVta!o@To%*4m_V)!i@Cnt|Ol z<{)Wte<97uu}AL0FKHltlg{uTY2OYrfx~bxCAN`ND$si$Nvt#sww--CPl4N~;eYNm zhTO8#>#2-d_5O?R&oiai#1EfSo&Oxr$-QljSFB>tuqoO{dWD=*2CJ+&sLjw zUa=ftH^e(D>B7l>)ky6p;QH1J!sgOKml4U_NVvH5^79KC3>%dJ)l1g>X0~|g;7K5F zgBT#=BA*5&Z@e)WFKAmjV43sI*c8a&y(_`#%hcqLH||Q^Z&S%~FihEQ1kqe2-oQnu z0p!>pz&$tyKQ_!4>7c**VX!!^Qp~-hx*@9-kONlwEp3vu_OL!1zq1W z;FfMhH9dEbh<#~O;4*C^Nz44&m{Ed&kb8|7R@REO#VPP)+|c#I?-`*AQZ&gw;@2`@ zEc{8LlnC%_Wi1u;Qer9lO>$MrpArd@Svx%%oOR(}tK6XjzZg;EkE}@3l^c z7kQtI0J9G?jWK>!{2jy+bk*v)=oKYWa}Q|r zbCTFTHq%PL>V_4y&^y6j!}ucjN(Gpk`zUgo3X2QniIJq)ZORw#?sFKL{vtgjnu2V_ z%Iv3dm81Bx*K&F;j>9a|6GWxmFyO-9E}2`50RU?T^zX<3W+=ZLdFr0 zEH~upXITygzF$jsb@4RGoErsN4e zlMOiCY8y-+|IY1c=IcO@afP10OxwhPu4W_uDmCGZpF9T+`Ug<+1LD!*3Z1~WcIh{! zjNiU8N!SPYj-Js90Uku=cl)?KzF_6wK`RDN3w-kaF1I-qC?OR-o|5EiC_{T4O@Gv}|8V$Man?sH}g*omMrP58bcbt+VM;FElfM(2S zy7(a7$EcTq_MJ6}?sp&hWkw=53*Q7j@k9EWS9iElLNLMwUAHNNV9tvY3qN7)m%pZK z=Ea^vQ7{#b_6I9E94!kHI8V2qSH$aIxGNBvZoZrG%`)CShW?*8)aP!&o&`N5B&3^5 zi1*Pp`=HmM5=xgPKR;ac$3NkIPJ%IFDiu;E-_(ZhkZUGN;QFxck=YPvidg zl1B&S4Xk17)w(xyGrxAj&?phDM3N=*?+=WF?97uDFv9gf^;8bzlN}~@P)Oh9F39F~ z;;jFVL@|O|10Op5+YJ#a;9n7S>BuW2Jw5*%)sMJG(;e9_< z`NBBrgE;(mA3O>>jsyHqyy&$P#@hcAgD`tJZZ)bwvS-|w3t2UCO~uus_qv;dEFS}+ zunN0KC0}k1&u^B6H@`O)yJ6j|mhSnJ6XO{E%_ZnO{`W?V()au5whtV~;k(w)=C}Ey zv(SYiV*Ed3Sz*4`g*7jzf^{Qj#416)#?ciBT>Eqw+)IZt==$c=4alHZ5G?t88(k1f>3_+yq zCWEtSzzodzcvY{&#R8OltiB>da#6~uLS*3(7<96W7od>t$u&XUujKc$9g9i zK-k?JhkC4&(fa6-&qK_i=?d=vWG8qXVvP%wFs8$E=V}v8Rws*-vw@j9?58_X>R&J@ zR(f`^GljlL>hqP_J@UONUxEn_>X7mQ6Bj2&>xd19XPW3bdbnsz_U2klzF=BKfVwWn zr!0|Id0+9oWq`kG9GixxYN@g0A&o;tr!@QD{o8wjw6B9oO%))Tsu53>iz9twNl9t| zJ;ygAgDC|adM&FTWB^fLNV{iL00@kq-b$L|qN!HF^nxz3{C#>R0;w5M^J93r14&`r zG(V#k4e=lQ&e3Fd{*qy^_npkmAH2!FEMjPiedB)^kY4yPSDn))uuArEuP=E+mY8R8 z;2Ln%gy8t2@;!5dDXJ`A;fIYp(*)?ie0`)gvwtq9(0BWy<&!_Srq5M4JueA!fI#RG7KQ?^5> zq<8xiDWWzTLc_YNV#@`l>F%@jzjR)`pviDs=Y4b4x6acWQK{^?9`ChAJ1xykPWvg{ z!H}O|OLipfCP|w-czQx;3iQs+uz^6)=o;#7?jL3^-YErD5-+8ANVFCMD4s+Q5tO~W zU(_}-%O8L6vn`d%fiDHNa1wK{R{_g@tZ}A1^wJ0m+vL<}>vJ*c7|rT)*UL&k)dM=6 z??c_niBbgK%T;C%%koAVUxMC`w?so2@nSWy_PJVOqe}09b?1d^o84TUngC-wCE7F| z^eTBu@Jy-L`u2GFsNLgzp+*Iw!!*$A($n3oM0amgB`E-0y%KP#UrJ zUs>Ao3kXC89-jTm2n4kap)o8ey5}N3NiKPlP5mCGn%mWZ&2m1gBT-aGdMo&tiW^qh zAS4nntBadr!o@Dr_zPNPj7-br#?S+wb--u(?})SSS^zF`*@ben*W5+%M4;N4mH6CW9r)5=DynmEvSla1 zA@OLuMrj*zDH8pDYWHoA3mDiiiVFC!L432>$)bGt@1O#>4q53aE-5*$ z;6fmgKp=Tr1o^o#>OEq8B?rI+N<)@}7U7(=#rfbXMY(3u+a}3eu3a>H_jRBdAbnRi zQ}4gyY+p@{VB0>+J)q_iL%@ohb?*sURNUh=1@Om1Rd4Yey~B;k#0{Vf`^bN+1MqLF z)Gg;rE$4$G7PW-_k!F2m#yF)Z07b?%bzx&M)qYP$5Z0Y)zm!ev(SZ6$LOH<@?A>eh-myT>-akooVA)S zoCy??mamU;j!h)Z&kc_P!TsAoLWC6J8KRsE*TOx1_C7c?cpu!xhAjDqAb#rq7;x|3 zHw_hJ{TS8joluKiR3JLiMdZOSiM2&KXY}&!TEi@#E=0Oo2i`Hv3EemxzF>x$cbi_V zi={eStNd$y@P5MXFR{#I<1b2cI(zTO=Vp6f4GR1Ko}=&2I}VI`VCZJfo>t0fa3fix z@j6=jT@cGcDdp+vMIF_M;`&&!_7i=Sx#6ebw#`RPSLYWYKG zYw5R(7Sfu3Q!3u*Dv^n#`UTvP&hU0Ho%E^W%F8GGOrJQaqCf`Es9|bagA)ad@Q~=iJlf zl-+mde+~pK+x9s|6U7D6zG`Y(;P%rgpXvVGN|m(O%>kc)68Ww?AiUf^Uh%%FIY*sN z@uE6#5%1;Lus5VHb?3X{?Yfn*wR1-Gs~wc+W*;?@aGOvE$56vR9hv|tSyl+MPuW!l zcDLSdP%R)Sn3xMH4GZJ&c~P-ZyDDhrOZ8KI>{B$zL-%l2?}lmMH4+;|v&-&FydlOb zYHEsbE2T9r(0390lC`tgwqbuaZevgCR*V1Tg7-8yKy&9OJk2*5u52vYR=lHD->`9B zlG4RgHxz&k{t_RD7|p?XIQ^Tz;Ymj{SU?T4r-qhvNaOp zIgC}K2p&kHXipLC>6DNyx-&j-g=uOro^l#o$!T`pu&KjBUqrB2p*$zYvI32mUUwV_#Tr)+>@bSYfK$!U901{?y@0;Mk&d`ADY~iyp z5+kL{9_IXmG{44-4)7)8on)eQ1|up<4$+~jZqp}_H6muIo8s~kzvb2u+aCqkW zHUx<=?zpce&i(nO-1;&1KEL%Dt+nOKmu0UdNyYeUr?J1o6y3Q=F$6KNi~N9H5fvPZ z!spfb&BL~l*ne6!#jUE_T6nRgM(X^N)Mo+|95sN2XBaPmlF8e{k`b8Hb z$n0Q1pY3lfO^533~Vui8JXy$Y7CZq>R{OW>#(K zC4~;%M#N|0*ZUj2i4BA6gx6S^DB$58k` z9Gs*&eZX)aHN~3|lCpN;5RXXe%ae{grXa@WX3;*vBIO0Mo5ZF$-@sL^g~ParEnc7I zh-ut1vOS|eN&FhHQZC{?CoPMrX)AxSIJRh=*(zo=%=b4nV0dfl{+KBMTu=&DXL}zn z=SRou4!`J%?y7N@!Taa9n3EOo}()Cp5B$=5D|54x_q=bziq*F|3Z& z&)g3Gg?aMOmsv-SFT(w@d{+2pC$HY4fwS(Y2ouX#($j~JCSK0$OiL2pQf0Ft%%-j% zO%)UV@|wN#*qa1^GwG~dLoy(yt(}4}q2d6T1b30moDY1{Xic`D)nIivBMa>fPWhK}K_~&9#1!R< z##LiflnrQhyUzr}C0a+(B``UWL|rQ5-r$?U1cVFZ3dhh>l!8LzUC{0M*PIWtY&Cdp zCR&t6J)LM|_wD;@lyezmgNMG>_y!j)-?Z*Ll{!FfYkJzy0y-ow{u$gdj*~P*IH)&$ zc8Fk|Ysfd-tr9Z+6{6b#In7Ovfe*k5Sn**r z$*QJ&3N6C4R`l{)Jki{y0d zoz3{9R6*_<{5~`;pwqJ+uy}Kkj%Jj4?;8Nx_4ti6?9&amyFhAk!{jqi7yv5w#%}$} z%*uG#76XL?Ed_`vh}Z63mTKDYVSZbEtVoy`2K}qQI|Yi^L+PLssVgFb)}Mr)k9x4D z*!;|QG-`=9tygm{Nr|K0C2BnKKf>QB(IG)XlIxW#^W^7;VY zf}woEc?s|BLH}6xuRd1F7x~Z?F-=%_v&T7P5w8V+j61XP#j4Oh+zhbVaE5#SVb!ju^ngTK@KPDRR%fr zel&C>(-wg}di;>Moi>erkKye8=hWQ3d>w$Ag+=_Kl`W0z^g5W%_7I~=;$rr*E0k#{ zFicR0$}HRRYuhDo8!nf{Oey5q zKgm|HB2aqVThZ&1AmDCsn#><`Xp1|A*qa4TO>`(WB4%_sdGWpQ!G`R$(9F* ztJJC4KD;30e{ewp~glCGy)=H ziMkjt%p-pk^z$S>yUj=;d?%;NY{hcdoKNlaAkMjo<%_jZLNQ(dMNV6u{<2QcXKx$Z z)LaT+a*wmH0Jf_mH`Newy^aPov}Pl5;nzcqmcWK9YY#zluG1+Ld6e z`uYPVkZN{dP?ee-p#b@C?ML8^%}L(e!4wfp85KtG=1I3Dd_2+S)WZ4!eJ*)H2V6l$ zcH2Z%r=dP>qq~B3&hJq6>b0b6edCG0bEG~O)8l*`H-)gmVsKauc{Zbtj=q#o-Vz7I z>)@Md!_)bL_(`g~(2Th$&k)Gx>db2dLke$0a1{A`nK=zV11>?hfaQu{-x_k}x91flQGTG6`eD#SA1A%^2%KE z1Re9{HOJfq2v6^qEb3Kh+)v>+S_e<+)dev0N{^(acC~oizVru4mzo&=8)WwmV&mwP zQ*T!wqEDdE4n7ALr_aOLT$|AgEM0l^h{AUjeden*t&ZR5bS zTyjj#EH?h5TqN{dIkm#&g7ay3|LSeenbBsyEQ{R9J0dwF31AeW#}1fI!t+%o>u>U+ z&@lGWCu+yOI;?AEgXUD3apIGUta@clZLP;cgBvt3n`8rLl3i+BSLLnT6PfxgVK-NwD?EkdMGzV93sIMq&+Chc zl^)1sZBIN@3(~^{E>BNZ$yx@t>=8A;KX}!PY^7)wnmIhW>3DK{`lo8U6`s$@++bH+ zlee85FU%iv+wlR%S=0jsSg@CvIw>gm(3tS%%4XG;Cvo(0^q!5>w;T7o)>1VZ9U@uc zB1Yv8ToIq>n*i0q-_%lT*;DH|vJ0^7Vy=H((@gdr0bvuF@JPYPAUIKdHMfYiXt=OM zMc5auH6PoE!MtE-Y1PsXQqFodioRjcYRnVd3?_Zrv$3kM@NnuUi$7!;cNOWZP4xT* z`ukSA{{$X9Dts)!n*1|{ovJ&Dftfu|6 zvqB_fHfQzoz@ol^v|c-@wZ$8kOq(=XmZ=PE2J_gy=sOQlG$3NyP}acU`VZKem;^&o z24_Me@*Nxv)%{BJs;X49osHjKHQl%O`@pvlF{4Kf>9 zNst^WN-7TX&wDvcH|eOOuD#l%Sti>gwd$IPl?IEDDRzmvA!nk+OK3ArX3Z(`hIDNN zLgCKk-1Ek1jEI^l*)iV{sj&wY*_PjE;;Y?(TB5i(nEGlp)h?L{S{BKDOHp3@0nD}2-&h#XVx~{*N488>YwgUerLG>k$ zB#D;^&Rr+9<1MBU)KTeRn1b`b-dBHudv9bPfUhLvf!EMp!K^-b4Lu6;f|SOWf-x_* zcw4weGvk0-$B1YL1_->t4>&l!FY44Ku}i+`w8$_m?RZ|Pk#?%1+6=A>kFFjQh>kT# z&Ksv?NJOTgLt#B3MYrEvlv-;P^h))vNGi!$VlcjC)VCW_SPllp1M$K*PF-)4_be%GWk7oiCn0J$mkfy2}-? z@gwDG8dcx$V+LP4y#K+Y;X5a>T>7JsUCQW+GaMRsW;8?aMtJ9V(%u)HxX zWNqAgLQjMRXAeZfk_E9KTdXc}IuRWLPLTlZX!$PaI(&HZ90+t7tXhvOT$+x=vf$gZ zT)PCS_@;$A^jR(tkXI$S@mVDwV#10O^s>@-25(~CpEB~)nUim#oRSMq0x8~EVMeIS z5+Dn-1p%75 z^~G*d&ArDFo5cff$>GvS=g7c4uT;F=LR9HyF!ZA zqt@XsuZZXPqYnP42JycEhk?p>$f}b9kd zyNRx+v+`}u5qPNBZ7AZN=pn6=F)$sds<}duYF1_u(ti~*U|Ad*C;FYB@OIfE(<@0K z@876pf|X=0h)k<-PF3KUgSygInv~meAlByeJ_Goorf`ZDle9ObBB|yC`;=^(*)^;T zX0xBwzC8&Qub1bBm;EM1s1dvTpf^xY5L1IKS*E5!i~Hv7yJ{1MR}bSMjUNv_o%Iu2 zLg_KzY*W*{@=_C@6;^?l`23@L9YxXYju2__@-VDli|#Ok(*vD&vgY8w$*9u=l*DEH z-JgO*WVkopLRuW0YQ+;3@9Ae!lFyl}j6jE!VHdLCt0x+F{_(!pn_>E%G8E68v`Uv+Ptl$opTV8c% ztW2SFB&uTW)3_(r?Xz7&&bd1`(HwjH0dMkLGeoqt%sedm&ehA>Uz;)nu)u0m#f&MU zH3s8dZFQsee3R8VoH^mD)gIfB((SP%8gtc@>f5zzusGM@a2|5ns7`Kc;0DpOR|hJ zWj{m^u9v^1srCV5!=-GUm)fcWsp^X=N7i7#KXXa)_e-}O#P2*aFC5PGt26De0vH$* z{(_dC64C9h{XqCX)C$pOq9Ma?Hrxu>Z3Zq(tn__2XLbDpFxb|EZJH%SiS`X^N+20& zy}=6&Kt9TsE|QIc{M7%4FO>!E(tS%kWjUiG;@LzOiP zXmwV@)XQdu`!?F|@p8s=+>o{YrGXLA!QL7ihu6}?qv9Hgs(0rwOK)9hELYpTK@VA3 zPt8yg3?n85&|b)46SQCSpWq#<8shT9Ahg;)ZQgbObgiV#;+xRv6UK;`YjettJ$WiN zFFFPti?I&lYZhm9F(T~{3_0=PyxIBub2?>k76qa;+ol-}!5XFYEy2PHSNtmrbff;T z2QPqTo9+$%cK2w~A)J#n?#0*J%165Hd&gWkQ`XN4L}D&&5J^__7SV@BF_r`I7Z4Ch z489F2^j$!=E_c)hYfei`ZbC6^)Ai)68|t+!e4ORR;i3gs9t?xNmjP#K9`mlrFxp`p zyt%Vxjh<`T>~Visu?fx1kBtCHE5pJa$o{&d{~5h2naT0}UA2!*Id%i&RH{1h-?y3? zE#>R9^dt8C621Qa|H@*d#NX#-p__?p=A(~MW0dMX(Ck5NTVT5X@fRdxe@4=tVvWiBn08dV*%2&4!#0pH&bh>;`A zwLGB0qn_;k7%5fY?}`#SAS}r#mbK603=?r0<${X)%rrEoQW>n}sX0pT_<= z=`!YhrYd^1<9=s5oA}Uu{$69A4pNq%N=MTd*y znshIx81v0`o@cI#Qb6k4n}+jT7LoBgZ+`K4$J${##+yf)xyWEZBx1&7d8a$LxQa}V z9TJyN);Zeva*fm4>)puJxb7J%`gK+mIKJ0Z#xPHxfI_eYKp$%-23l}xzP}o^M4LX&t(^;muAN1E>f_1#lun==3YvBnTwt7??1I$ zCX)|niQpCyWARhEw#M|5^C+rAR3^ZyXuS36fU`wTp|e;3pnW?c7&W$7_Z zRF!KLn$lu7nRwQLrLnU_H)L^JUb?m<+)ysDG6HyfV)%nuTq)xF{84|Fz$m?L z)C;9vx;~O~e#4CqK}{tw11;zc$=ioQB?42v7`f?SLKNLxHcR9Y^J^oI>vSo{IY|nf zKcBArp~hr+?9S!877-+KrO7=y(zuSOtM`k)^{9I#yK?;Rx!tYra@J8hNj+|FvnR^b zX+8kWJpJ>Z6LTpE*HElt(%2W89nZS1&y>|)rZyTFyC{3G3MCFGSrk1`x6xR>zy2`{ zx~cb1{YAX0)!U}KZ}>1#0)t>wA9`Mo|CX&8XSI%%2^Jz++HcaD!XHj#qaSNvhQe_WC?((6Htd zzQ}wcr1ziUVjqzpUo!UFhrOiddlCuh(mbYR+iR1c(ZW{#zO z7kc*!k*YwWgM!se!Nj?Mf13j*NI5v&Ebu9ZiQVXWw&i1iTAtAyOB6jzXSu{4-pGT* z3rywyWK8RBaHGcKOla{*fw0R(N)p_dg{7#D=PGM9Q+c|^D5DZar$LZiBnvO%TwF5c zW{SV6YkacT@O@l@S0N5rwlW9(e?qFzKV#K+q}ZX5yk}>5PB_T|3qWoO1Bit+qqbAi zN{UXe$D7|#`O_zoqowBfc1enOcQ0raRUcEzkCfOof#?w(#82hwt<2rC(N7^@o{<3z z2%&lJDoqBG)~ot7TB>5l{-6)-3qgc=%XJ#qa>d8TXz{#%6*=%MN+lOOO z(pu7g4mp|U%QCF2eC6KfYfikk?Cp;O;z`ll&|Q`-*L1riWpe+;#Khhys`D=Ju?!9z zxoKiwzPQ_mjaXZ@^W@hIk{1uuCtmiiP_SCv^NlfF(g9;ApN#-zXb+=pUV-8(*#!A<@Eq0_H(I3JrtbaPVNUBoWt?E&nPvWI$s3 zDh$-2Nveh3lZ7jjRJywdozgmt?f!TatI+_TcLM^=HaXtFHzjPwQ;a{Ovoe*5FY>J0 zR&EnYkA(zJFKxQ67o4tV%mgkgzJ;mJoX8NdC2{O8I62zzIGUMbYh-4qh!7__bis1k zR*22iznInUusMCS&c8?UNWGibK*( zQD$;FEn~l7L4=P9PA(84lKYE@5~RW!+YC`5P*qKABSWX|g(7t0Nv#{vkzVVaEH}kP z@5k-m{w7J6YmoM$jP|$dqvwZI`FYSOv$TNcqv0TF?yznPfrZ=_y9qD6!tp5`Ac$${@WIlji!t9-=G9C@ty|w!FT;gT?&>>ct0D^ ztlkAYr%2R9-9c+SnyNCb3*aaCvVIOr74{>1>msBQfm_9RQJ(3^HemtCbnJ54*j7Q} z-2$P)i*9Be*`qm8S`MhpjDUGLMXDmqzF)3GUzm{sclRGL!upjEW*~R~eDNg2Ju(l> zL1j%;8))aEo+=4`RT~U^u9T{sVz$?viU!5bq3R0>d+uSen&^I~5 zpQ7H&0CzgJoR6qNLEku4uuX=0vg_S7*O0F-`^d^}<3O8WaXn-V1#pr)-8WnBV(N1b z?0{s4Jqae!5{Up<)&4)C&N?cp_l@>vhHe;;5Q(7?Nxy$~m7=g<&rpz)nSO2Hh9 zHLtRY2|hQwy!P9SFWfBU$J}hG#;cXiezI2&;i$wQztwqzCeTIPT>CBNNaNk=(sfV( zoyIrm)p*GJiI}MlEC9s8xMMf$1--q=bz_9D_#pT2A^Yl+)F0rVj@WGwA8mF(SmOqr zk=kO0kG8~MboO;^2Z6y%GK2h23k7aT`fnve>q8{pNT&YNCthtiGe5jd6}?3*GSvFy z1MM~7hfP|T==NtH<(8yW@y(QH^%zbjJzrs@Qt$7JQRRxh4f_rkLPBcNgxY;tI5*}5 zN1|`gHpsBdNSoXtv%t6L1ku^N5?dH==5I%;@f|)gx*h)W#&yS|1x8n6FGwEFo&Nvw2u4IxgO(V6 zttf(HJFLYAPWR^D0j;d~*HzpOpz+%3a~?xRt<7+?WA+C)(tZlDPYO zvjCnYEX@}|uPpXn@I2xR8=rgLrp&s7y*EDx73wDc^QID6eD;vN%xAv5?!cb98t{q} z$xiFgV;Gqlra5ax_4}v9Y?If9;p`a)8o`6&Xe;;m1m@Hc!vP<*Ka6v-1T=Fxnx)M1 zUYmIT$}^9&>`qr5FQRP-F12|^yz~_AhLp2@p^AhqvF)|CVV4yglw-;5O#{*gYQkk* z#Pkjo6BYg&XnMrQ!d9^X72g`apXdRPwSo0P5UJ2!C(9YF5*p|PLei@Ya;nK_YUTv! zY^;HQwisCfph4P)+(-Hx;g7GB3@Zg}2rMb=uL`2M^Z^2@p8D|{JFY?exiTDBA72b= zVSevC$b!kkd5mIBF4px5cN#X5*DwO*X(E3V$n9=9nvyCV7BhVDQzKEo83bLFXTq2- za!}1l2fr3vW;)p^oP@aIJPLH~Wvdi(>Z14V{n6w3*E&$lhT$y4S(M;%qZ6H@E7C@5 zjnl`tY@K*M?DYT1l^EFrXE@QWI9RGLvAlsqU^qmtvQYj?mj6u8Nc4qC^xCN1V`6fI zPNk!DL;8I|l)f}HHsKU0SmQ|pGdn2hHy0L8GkD!Rp8F;^K>^l%bD~-RB^daS@3}d2 z%I4gS{AuE|m8;8TNq|QMjhFPeenXnrbjt`5*2R**(d(x}HlqNL7lMy9zK34#`a%o- zBO$&fS?>PY(H>pNurt-BCdq}qTxtJcdbq)A&0%{->WPWb-*;J^M(DMeszr? z`tf30(2DYm$49$2AwT+AA>A5m?q&}8(U@6p?N90)$f6Nmr}T3XhBs(9vroSi5_JEY zS^G>Oc>@og(2A(%80Fx93dP~=aC@O8dCp%!N>M%RQs@4n7CGbM+Y(HjCei zk8WAqIjkQ<$%;GPgw=%({xx(jl0AqlI$hYlS0%{QrDl#(6_+NMI-qd~5&A35)ySog zRFQ}Mgk{Y_oYof8ZWygLB#0!O`pZ@bbjEeNxB;<^%L6Czp21GBlcEVylYw3%3^Lj; zSxI4O5x1^6+jSryfJ*2M<#RWbZD^E-iQICGzUm`4L0IVDj`Mh>qsyAgH^zjy%XP{7 z1kND#=SSgcPDzOr*PK{#LO3+rV8>E2dL3`mqdNXssGZE{KC(BMFLB039T=gpa~5C-v~lo4E#@9UslpARCdV)T_wu1g3NB z`^ft@b}wzg7v_CWVWVr=%kT3Pk#~jO9k{fZDi~VN*GKZkhwFVAQWX@kQ(=V=u&ZNS zxklPZ(Tk*BTsM11UtP>iyyp%b=4+BMOsM%#`(uDxF9#kq#EO7CZeQ{qd{HKRQJ>!~ zO0sa{`cxyvn7QWxSa3*SF=(~y*->|5nevSe4iascKR@lpIir>a$*#qltsHUfs&vCg z5fye?1HG1P>?5G=a|>oeL-KsiJM=7?2wKUtEm?6U#5wi98)5;Ha2WtKIs)ak;2gno zY)CN)b5^${>fT23BRun4tTBF@4&%#CN-CE!HVNi3khzlDE}gu5^{eZo7?ICq0+@nA zY|>6TqqU)3buuZ@T;^ZcVC}O%wVHyG^qmz<9>xmf9PHOVpAfW_-}mMtQNDS)9)2+y zA_Q!IVF%n9sxTpyd$JF9g+DM?c|znDHS(0M#rw&!vg;feRvP%HLh^4n1ZG3!303?r zRR*6;)CcaU(FOI~ej3?WQ;*C!A%nfFt3xu~w_Y~e z=;NW6SXF7y(S`8RzsO$$6JJ{gQUz8BepH@0T*py`S^gsQ_?NVYL(cxnk%q1|o{=@Z zk^KB!EPL=lT3A0qqhUM$DRJ9}3-(-?!Q^FX2wdbd+)=o04t`CdBU_Tbl0jAFb)uy} zDSFFh*dwuB(VG>MqgI@A|Ls+tuEfne9)>&$@>_OBu+<#d_BJL(8}oErr30e;M(^d` zFvZ_3-GSOnmy?C?^yKn1aqkP;VEBj=W_a)~)+6FRE}6Hh25Ky>|Ese>h|+)~45a_KV=qg8Wo8EPU+9 zS=Jxf*M5)fyNElC#NteT_}y3k(+c!a=zM}3yiM05oI^1LbC%;X=4J|h{g9aa-`7-4 zN#;zYpV-ag41B*{%>L)0qq0{`RacXr>HWnEv?9D&+!V8u2qvbfb6!$8V!}3~yXJ3b zKf{giHGc%RI$co~DA*YIbBTsWFyunAKVDHpeGHckqb4*mr&F&niNAkc7@{ToGvZmr z&z$~0QJ6xVupNGuGr9K*04VaIb9D45V~EpBjGU{GCEJVLth}PW4Cy=N^}@Mw{t8_> z>neAaKYcFDN(p9|JoU%OJx^CKNwv5TIGRq}xGF*P@gU(>w#D+2y`+|{u=^63X<%{- zeKIhCXZFi4>^G)J)&EQ$Hqiw;Z}PnoC$4sl!q%0hi#|e9cw_dwmA@V`Q>@$7vfw4< z^39fjX_Q)9K9MwMTj$5Uye0M0j)z&LnQ?@l?WW_dyw^tTt>PfnS$;TCrB@mBEd2e~ zR#TQ)mx2x8WPetzGX6nm564}Pn{4^B^=W!Pg~|&$t3gl<`%&h^QcHkJ4fTW?dgEnk zjDZP969%y3Pnu7d1xdQG#CU=y-+(h|A1VUC#6F>CqHSnk8Vei(vp7Zz6 zOx_b;a!ESiCh9~v{%8=oXMEUU-%1yEjcjnTYDL>WUhmS5d-?V?`7Vw(IAr)!hV)3g zziJq1Z|DLDsGUfhI>K#Y^iYI0`!mLbcyp1Og zMs}mp;&rQ{q2w9hViv^#d+}~s5^_`VBt^ME73-i}=6k6fVDKODE!6fV$3w2v~NgJIdM~H-}lO84ulbnabcX-T6 zT|4J-c9Vhzht};wglm6jH#|#DtXIKWv%J6Ki@@I8CRu9-y&>b0nLItA5!*r-6Bm2fv+PU*>; zRNVcxp*AMKD5(KX;MrR0hNZK2iNwmyKvPTbmi+Yckvsg#%Jzh{xO^6E8NbmPQi zw;@!x`D&D{DHP6x2*XR8EC0G8t`p`x6I5|o7?llJ6(;9C^wPhEUu2YeOkmEyv zZhAPyoz(W5%pYRxWU|D&&f-<%Yt}yY<=Wp0FJeHF}0x z6^fE~2(e(Okq{%!k)Jy$bV2@djg`QTx|Z(~YJH(P09RAhtbVq>VQ`SoZWOvUe}8sn z1?`@K8eOp?oWX{!6C3&XzfU*Wv1)%%+Yew6V(2Y}f6B%XNiVfz8zZ|j9< z30=@V9d|>g!CF>K1v+f@yv-gf%)jmAGkhN>Bw%GraSGUfdK6#ykC!(A-IiUfmzs@aMSLa zv$=A-xfNquxqNh{^iX2O<|Q%+Zo#%ArmQ{wQJ)w_E}FRb1Uc|?POc{(_`VQI^= z=*bE)9Xm{!O+d-Cgx4c5Pnz)hH2v4uAea!aaUlJP&{C*BV`9%gJr~OY>5&;v9%jgntOihp zh1@2OjNOv%DuoWNec+^Dt>tH~FiN3mmkLJWuXag=QbC#5{1wAsGv8}$+-6z7&+iMP z)O4{Okr+{oSi(Xe{Or(Q3)yS^&iOMYvsRAn{eVK>^3;*ds`Z zY36bL_E% z=knP$#wfHx1Cy^{Qty&-C>Ilt%}nn_vi^u57a~`%cZ)2uu$A4`Q|zP zD}Ix^5W4^G{(!0MYFw)wEaS1B?)g8*DBQ0|NSpl#pmZdwRXRM&`tNP&aU4c=C5_>X^p>Yn8$ z`NSf9M+~eB?lZlh9G##uj7t*JQf7s|L23!ViJ`C2B!RIcKNtPRjT}@s+G;W#rsKTL zsZJ$ftv1uw16qq#E7$i{KgNC;_MB0O0bmJ#jHI#@Y^|xX$;X_!_u$WokoKdAk;x#a z?Z^}(yXBPWaSp0!wa|Z0dy@s8N_yc!=bP1cRIj!JC&FZLDb{cPL3k}Tlr$A6Y3TKT zx<@CEN}Pz#4`N`4mfIT6Gy9_W9(aX5guw<$kK~%COrS%aJwFV=EAHeEyi+$$P`xYY>PwMQHlPqx< z*7Jm#unl)eG!Oe2Wl>}z@=)b6puF8)GQd~KI2Wq;-MfFj_m_z<8--B5t2~<8>C#=v zIpC%I@KZF6u$leKr&(ASCXoeFQ?As`tj<{|a9z5);?uH`ktrA6!IPM5cPJu9jT^Wt zxZq^Bqn70wyh1wu)AzsXt^GwNGrtbMF)w4IC#oJ;J;D`k5bNQovqnpj=Ykh4VQCrR)@i*=2sy5Ce5rSN|bI&cI%wL?A3 zTJhwKD|zoo-BS)bT|sZD(CeR7ieItyZmg)SH>@t#TKf{5r*GG_-~L_)+NbS0Pt#_K zOFW2TL({g_WuI@hO;UbvU1f4C52k14aj3YT) zDD1@b^+3D#bPZ~;DIcV)3}W_vbZ0GarG;Oz&f|UH|Qw`R86BsN3{1Gx>Q51PMh6IIqrdCzdaqa|XJ9RE-F1X31bq)DS}ak6Y=d zDd3cC%;-{ZCLsTrnAeq0d1*X@E%z=#lelGCzY$F=I+kY08<6%EZaJOjScb>b9hcdv z3pR2kId35qJ6i#e^V^ufH%062%j8>m7lsxp4DVIw*JP_|35N{yDohjUw2b0x4Mv|J zD%5-ADfgbI`37SZw9Cq6-dn)2LyD+dVr}i+VI)`L?-$+&#*_DeRsBX++;eW0GU4q* z%xh`b-4LZ0bH6*E!`vB#;(`{5%$|}WFc#DdM(G%qW*mRO_o0{+_+?k`K1-PZ$z_-m z%S45m5;sWrLb=M=v^7q4%J{g@7VjydFsEO?QUtOK-{&lF%cSQHiApk%ou{w9r5cbN z$1$CBWqR};MExc72WgUfjkwU%N7@4q}D}_1f!1

    dsD5|v7wfp$_{QBJ?6H<=0 zU>U&x@|PPCB!}1uvRr|u)A|EXK0Ct+8Fd&n!&cg%_PXzN$dRngvx+pK2HTPX3PrA( zh6|=I7B|gP;X%8EgFWQk3i9yFi&-3cDDZ2L2+7m@kp&@k{--`$7Uoq4wjxx!(q(p* zvnu&XU3H@YZv4O9epW2y1xX!_Qybkb!Tkv5F(gQ7kza}5gD>_-l&sK3`g@Af)pK2}z->2?p;uu(3{zr@Vq>Xk*AV&)0@UO)GZl~lw zA+ydSmUtF&#|z%a>Mc&f|DPi80P+(Vx($Xf|&r|h9vl|P5`$2pyKC$J`)RqJ(d5_k7}TMjU2xc zJP7S>uj=!fEqmZ%N5Gh_gk)3XW42XsUymC*98H5hZ@p+wNZ3|bZE>~x{zsxt<)JNb zfl;KiALMS9h6mXuetk;~&6gT|gN!f+J&LHGmZ+CyY%2LS7pViPml6**U6OI zxgOIgverd}ssA&gjlh;PRNc;>$Qi_-Zort-;El#c3*cDf^$0{iY^ge+U;uI$jA8W_KCh$ zBmY_>iccJW%}=KCZYPI8v9Aapeo(bsMxyi3uM-Of`2dgcW0Q}Owm*aLwX#GJz>?rG z8;QVMCZxM_qm<^nHnz4IN@WPa$m|dd@Nx{8Q6jRb!1F84{FS8zHMdR_m=n(sxaV7Z-k{%s-d4lLTx`EYQuV| z-LQwJhjN4k)Ofr3_!r!e|E2lq?HACoRc_W(e_c0QcY2yRVSDVkFecndd7os}CyFY( zZd>QkD6%l=_ge}Y_R&cm7%^U(%j=?&ElIsWt=_#BKMmM988Q8+e`YnPGJE}BOv=gw z_x8Dew_z&C+(HL$xz)+RDd8X@`x%VPp5H5BMm3uA#wHJ;t<;RZ#~!Oqg;=?PiDW$! z12ug$a;C<7BMBF!{=v!Anx;k6UUen7q1-AR4!^pgJo_-F79{&2aHw329M!%N7oD5| zsqN|j-)ABf5~^Uvgex(k^g2fv+kVu>Xv#F(hgnyu>e&LxTZ7M7_Q+x+oaB7Te+DMD zr~oOw^$mk`U2NN0i++&pK8h_`-VnMd8EDCjHa?8F&!LE+AW<@I`n2m!-Fz-Qqqyal z2kmpe!696`HgA8s(Q})WNl{E%9u}f|y!4L-fy!*9JJug{;oo7A&k7NX?~7oaY+U22 zqxMH{k+TS|y>pY4)#W8TWlPX^Q}4eWlGcFZj7pA5XE1jCwI2h^bBTYIWy}3Z_K)=A zCb^)Lc)iXcN|xU#&(0>2xG-aQgBY6t!6&S0$W8$8mTDZ)f zL~M^)ExogfQB0+Bj3iPJ7q>VntE66#Sa~O@U1fOTjn|m@zYeB zs1-Dv-y3xE9azH0Ni)|Sq&MIq-z#feTc#e}!pSSyN`2C_^qsq~V1Q=2%0((t5g*k% znUu5>f>pv4NG@(ZHR=|$hPR#M3g4G!I>}Y){rd9O^I!b=86Rp6ud9&iV^P6--y(8< zlCqzKBxmNpeqP(m<$|jq@#F(91~GEntD_qod~6ZybM@8)H!L)#=KlHr*wPs-?YbXX zc&Sfr**&Y5@V>`n1>UZ zFe|Ts!`4Q8+>+Mf#6R;rh_7ia`G`)#g&Fjza!NH@Td~A&f-+kn{>MehMS#e#}^swb!7#Au@G@)UNd9oO$ z0FNH~p9KaF=d32b;^`+c@AVcSOG2008!R3+a}^CW!z@S1g3emnP`c-ng_np3Gi^3= zg@H5S_IFdELgg$2uO&_JP9c3LBx zL;Zspxw$#z?qddY`VdnhA4XGJSURknau2yuSt{wR2)d80vLI0$*Nw~ zJUYLTcT&FN8O}bt4EnFiosfj0)bqYl{9ICIwt)Kmdg?}HsAVk%NKfA5--ldd(mp8N zWeEz?$W9?#G(K`tNAM&!%H0Xuy5mCwAr0D$v=!JCt~1M;r`M`OcS z@bkr`1@V0-Rn3HB=VAeMP!B{OlF9IJVWQYP6}m|&;Z8R}s-K*G$@4Y!nhIUFOPg3HYTzycn(sq04mfGgZEo?DKHpV=trLk)RY&iBL?_^}Kg9A|epXMU#-up~m7W^; zxKd%B$f;3Kp(xO<@j0I`WgZNszD_QHz(NEpwLt--79rQvqE1V0&Dh0XyN-~J?|xV@ zn#T?|3b1%(iMm>7n(!GHtWS=V^Y45}@xasbFrOjVmD_E@duZPe|7;O}i)($FjxQpb zS}$Qs5Jtm$5w57|h9Utym$W4P}@cqK)pxTH&EDm*PVVVu#tx$mr}S z4u<@6WGw8;qA)s6>0%S;|4TB@nh>`Qn9%QrD4bd-GQ5NURaTD?=Ea12r=TPzc>E`6 z)824b*oqjB4@ra~7gG%TrQf=s&E8y}bvxylmjlk@v3(tT&i%)gnZDOzwc5Zz55w9RquK0!F%@W;(#}rr9M4;#cz?4(dZEbnps9}@ zZIMFWW>X5rPjJyW?|YDOiLl&XeH4eR*e` zeFd7dLtH6FkDpt38?`FWdX)r`n^C@Po#!6BB!0y%aKYnrX1pjqAln{ndlI^9MCPP- z?^N@s1^fl-ZP>W6%oLoLcv-+MBx3ND-f;iwR&J6~abf!URU)J0gD~2rwFxej?UsKL zMpPDA#Y28u1tovdq112DncwatIm#&Xz(t`etlF1PdJ16Mqhi00A_qJ!W{F$9cGbhW zx`ckp|Nn9-yedp{dhp(Z4Ua&puMr;i6{FBO!(i}k2&VYjslN3vS+*}?Bh_!4%rFAc z*W*DP;O_jGfm7!HUc6}BT9zJY}rt8RJ1iD52xzdzfRm42Zuc zH#W5#J>KHzY1IGwpFs}YcgkaCg;E>lufM}FjFza?m7LHpHA=D0A^KG!&-7BXJm+ zDxnmYlC5_mN5uoTDGj(3f29hAiGJ%GyQI?8{$5aFjTx_M0xX?v?mLyu%U?|eJY zP$tBF_Q`8Im*}(r2POVyf9?oD&>cpu6@rf;ADs5LK?k_TZ!Yt^`5P?7Tn~lJOX<+U zS}ZZ8b;q3agH*q2P}4>T%;85v-if8x4&rEXkGJ3*6#Ep8{aTOfwsyY z7XeUPVwiW)dR3)V?FRW;q%p9T>A-MjzTLMm*uur6LA|l7n#iXbZHaxP?>6r8*!`-Z z9zT3_wY%7<$zdN;2`ZTeGOPiZjZF4x~Z>&nY8mh2S3*29pH>j>`CPITWzo_ff zQ$6xsHrgp{2E$*b2~ZFlEpy+h3#!0P77>Of9gnf9dc>S@M?^_bL;D#?ajRwcBu756 zoUsH6X>4UT&}^?O?W%fl6}O>qBmJLuK^QU_n~)*AcHK5enPgX!LpWg~%8rb$@Xghg z?~0W4ta3se?OyP4)TMy=gP+Q~-k@!HMEk8iQ%J5=ZYPYZmf3rRiIdC4lfMKf{<}q} zhoB8R6H{qVw!%oJl-+q@ZcE2tbCH==m5%oV@BkvVjjUi}^Vc|_8)EP4Ly|pa1j0C- z2;QC)3*^nxIVu~-B0``t>lX82Q*)ONvis5|YPuVFgqJ$aKIQ$ncS{o+fwj}U5+uqP zd2tK>S^>U39ejo-x-VLJP~Z@CPhPmZPj|BP;PZG1y>Xtm3#~#m&0}HJe}Vbl)Ha0s zxe*j&65KE$U{rVjzu`J~zfbh5=oy3Rgxzodg!I27FK$9_0OwzO3(tPi{U%3SZHdY3 zqep9j?t}tR=@bT~C&(T0M(0Hd*r&Oo}D41LmI6XTlImSOc z_*+kKcyOy?e!%x9ZvNK?UWTDs<{wUM7VD0ZtPLO@f$aC-#`*l~Gc9S-V_Z?2!g(z# zU3R$?^QQg~mFOb$tvRdDpX$;VSJ!Ct?sRstI)=j7>$~G>`UmZTK!WkPmK-3c3}IrV z7z0z+biH%K8DF+bR9^pnGyK)O@Ha@L%(WDjerrH{>F8d~k?)54+U~D~2@_>+v}+RS z)vRTu%i~$%H45X!j3Wc7EHr2Dfn1^qRPpgX_TA{L4@!=mvaAaoL>;$-7LZ13b9D0) zG=UEQF?-L{fD4$yJj3T6^Gx=MWs$y9l*BN2bMsqa4G`v}7fq$qWYkB#K~aA^s^hNt zdu0ay*X`tB4xampAP0slkK`4vabW0(-sr6(CIW& zYkOn2JL@68VL}kk2NQ9EIVn}yU;di32$Te!2>YooX!m$rNCrJ8PaX>K|7@+TD-9G4 zvp@>ML~J$D-I5ECqkb0SRf_>H$m{&x6fThpaRWyK%Q-M*-I+- ziKb*EDW&YXqEg^-(=9JTuC=@jhBx(wQ zzhQ_p{>1FB;PS7EyTuM@)na-$ri;;Jw522Nc(LuMj2WYBn@&8c%82q2+Y~EOJ6j+{ zpaTj@V`?%*7doqKH(SQmRi2L4fRsRa1$>#iD%3jT#HV|Xwk#k_Y7a!GXgT(Vj5*r* z3TuiycUcxR+6)_QvtndPNSx&ZsuF=~-@>OAh5Re{)6TZ32H zvPtb#{C1g>qB2eCIvv`T?O2uyYKU7-i~FRQj){{=jojlsep@#C-mpaLKXt3q&ar(9 zHTmk$oOTu79!R}J;Tz;Q$+eeKG*^XPCcV@iCM*7^s;5>qJFKgO25Lgh+B3hRs3N1j zo3VB5RHA3{kkXIyq)v0#vC(8IQeaM<7;4$bp)Hw{A~@v-fLRuRdQ%)PKXdlC+K}l6 zi2f>QHYvHuHH!vQpwPydu&c@?@U!UTOHDetfxe$1vzqWS#4^5t_h@txG$9Iu?dA2 zQ=`$Y6x(R0(u7~90I>R$3zgMqBlyj28dN*z3ZxqC=o;EB-SO)BqDRW4MLTywa8c4G z=i$|yQ6$}a>~1kRMdZzB{ADZmrcVn>ND)%$9?SoiM6 z>-+6dQws;=#A5uS>}5=Ys|>%%gcWce?j^M0;)0}Sg#_5$KvKVwGFBS9S($!?a&b-h zG${U1ExluT{7wObPi;oVVgU79QTJ>5u08Z{5ghT_wt})9c|63(YkTC}%b)N1f{t=L zHtu)k>8cbf#I-NhW6d7kDn9Y@<1gD;$*})9HtYR94uYmQi7Tgw_>$dWZJ%{gm_h}iRRVBjrHdXzS;Hg8tzP)p^hZo7#kK|#CAG;1V%x2q{?j5@g zQ-a0JQM0S0^>2o(1N7OEw>d-n`M`f6S=I?R#K6rxPw(aD$g0wO*Cz7@3k`ehSnl@N z$cK8{-Xz4v*$42tT`-oQ_^q*bYHiY~P&iY@*}{^BFOq;+H1t|A`b2Ha-e^?qI}_jc zFJ$#l!#Btrdc;6NkZq9wD{u2l*{1e3AFV8={cx+xRboJt;X&`lWJI!vUod@%vPTYU z!$a7Ge3}T4>(;?0shIq5!Qa!NudS~PSfZk%<3*K8W-D|EaW!t8{T)RDD_yn zQlAAA)Dv;xf}Y@W9kWcnix$#R>2B%u70C^%w~L>4QAx-R8qie}ySbsGvrd-w>~#}W zUSO&q35HS{rgR2BAR~#pz1^3q{#XEzt=9+Wuz;A22MHe$0n!MN8WS;V`H*M}T^32{~@ z0&e3|U4Y?Jz9)@;Dsin-NEgS=(2=S|sO1BgX0Z+aU8x5aYq3`8c}i*NeI@i%xybTq zai6!{_?T#cLq}y-YIT7CTpyV$vb!hP8Osj)`Fi0~y`I&@27+ZoUBaJ zbJ*%5xwBs{S#t-E?;Dy5-Zgt^*dLd$;HOlVQEE*6Wk;1MD}2z7;XX99pD^r6<=R|M z)FgdJWJ>mj^C(+h}?yb?07C!yge3Sf|u9_L@16)W>^5}MFPtHSqh2=+? zHfHswsaLSN4x@md`vMq_x5z^`f0N7+ohA~Iayh?KpNd;?1*b*|5sF0I zN=?}b{g?!k;K!cQ613^Be8N#fZd^z~f>HW0208-o=PFnkWV zi}SpD)W_?mGXK`7{7)6~r_Fpf(^BWL+f*bcQB#i#mKTx^ zWCtKG&Ry2{m9HJDW#Wcb{Ez)Vf)C2{=@hzW+E00O;s`;-O~Azlv7dpoiSEJSA5Gp_ zhLNX?Ret-4S)T=A>WQ%4BYAj~Ra^3Y0m`cTOsK&;@S;Rqehpj^lKH}NeBR!jhs%ca zFAY8AbTm~&WmXg;{Mb?!MAzHd3FR{+3@)rUcIupT z&7kJCfC+8Q+}*A_6E*x~=63Lcx3l@4Z*uvS^3FfLb#+jfU5Pt8BNUlnXWhK=YJtl8 zQa)YkxEmYqNOJX>ASJ?Xu9xc<>?V-76nhYqUV?}u52FAF09nC6zTc*}1%#s>ZQ+|w z+hPAN&QVe}5O>~I+wPa_*lNDcD3$r4U%wf|z`LHq@`!FYVuo)hups14g2|$WOCQJ| zU7Msn@!xDu8qNA3_zG{gG?BU9ZzVTsK(DAK5quAtQ|qyaL^nb8&XNvyYv?e36CW{m z)p)}JgGOa7S$Gp zx|jPV5h;|j+_w3`gik7Ps-3g^kyD}N&BaI9PR4II1jN0^RA3rzdxhVn@fa&>rg+yt@6S-U09`Q~|>AevZ zxJ9|C!0$IfjB~~TkBPO`IqQ2cHD3{5*d)UdDc+)e9?47pl1~y^^Zp4d&OQk~(T`$E zEPov9bA#idF{G-rOUr!T^?RC^8#7LAl|P}YJfNuzC?e(=J3c2&3jp@Wwy@YWe~AlZ zJJ0so@8R`fP?2w%tnYYX!a_SP_N!B8C|4NSk*B`g3hbI1f^Xi9QI}itFC1K}+?prG z&>_B2hmHG1gcg5-{Rzf5bIZ6@?g=Mu<9p>iI7#>R_=^3{>a|E?Aq;g#UlY*fL!g=< zqx<<(W)uXO7SJB@0y#;XE786~T^S?1yVIv8O<(dc(D)w7{8Wf-~_T!5E{-0Nl2@xW@kBt348ZA<$?2Sn79FpLvY&r2= z5B|Ob($8Jlfe_>V;NLoGDB;NCHAj0Gb+W<|U*KcjlYl)A!Z>1s{N;smsM*e^<;Vim zWquk3*HNw!Nz?f@tMlY??4SbG9g~ZE>Ax93mDIR$(6_$)aeK{0$%nJ%v?gaXWv7Ce zUV!SYdX{+)=$T_{j((6lj>o>Bz?v5DJ|fjVd9G9uv2NQ*E94Kd6Iz`OqZ zuSe6*{038KJ8F@@QH{eu??tU|7<;LJXPJ)`J3w71jRC-fj_1}mL zbI26#0Uu|l$U^&AbNbg-?B3A5*PNuQc@BLf7B3dyT=Js@N1oAhvsIf$|d6;39SV)Z-5e_TQn*3Lcm5ikd+OFZ~4u zk5bDEQtPtotab2RU~X>nkH{8IavVtI+=a~S_udPrmc=$^7BZN4=JX&{$#yrbkdQBL zkrBEFSzVr(gWXX1U$xrLlU5vh$@|6!Q)H=X69nuoRs&CP07ADq%xj8bjwBZm)i!^F zp1d4e-7jEk{5c3#l4}CejQofVl2jHI-S$T0-gbq#EJ3`@TiZ)O`zyTX%ywjn@do2S z%}Pj23jTxwOBUEUkj&(RL~7;%-Ohs|AZDc#Tl{IoVOs@usYu%D>>AGoPUs48Ti)fH zHh;<|KKW&JYv^B*jH%(-ihmo*k{Ku_1-aLKuCYS7=W+amkwe>EPQ|BXLOJ>nCn+*~ao5+W&n(GH}GezvN;G!GM@p5bS zve*kGJWUa^QZI_Gp6dNSJl}9kLN{gt@)sK1fuAd+>89yQEdXyZNOx|uR| zg^6)y3QH@xw1}O)vVT6+Gp^3K-BuNwvmB^@pfdTEtXRh*Hq`U04+q_26NDGEYE1+s zKxsTbSpU5Ha+&(cvxRp7hDkEV&tv>gTUA*Y{StxcA%M?C-R{@#C(-&=$wdw{nRlqh zl42Q>zHBNKJJM`629ktcWyMSlxUKpv@~HZDzZpa`3-Ag|F!Etcqv&cjZF$3AZ-B~% zRK{fP2-`@jinCZp%{tAdS#xRy^Oz-)Js6^oRq3y3t5IDbi&`P4Ez_u`TRXRq0pKH%XZLOi?`hy(_mp-0!+Yizk zDs?S`cKf&X)ZT6SK25xJ?QqkI8&YYYDd*YcpDE0^*b)m-2e3NaY1eBHNC|VJt*TIl zrv)tZ8@%QMh#pq@ZlGVxnO46ZHhW*&$ z@f-m}O*lqdIc6Q#7wVzE?_mZF(UY>znIha{iZ96wzTLdOaP1^wkeh^Hl~!*Tv{2k4 zJuPL?8h!X*5f%7_S)6ka6bc>Q{ z!P^ccf4Gi{gJtmZ+8GvrjRTuXgC3Gd%y3OLcD3;#O>@7<8OpN zxmI_u+s_jW(x(Dqbb~W2>Ig}9N{LiJwUB|1hGzM7DmQkJ7w41K4WFsJnJU5iKkOLs4_D++AHjlZl;(^JBtH5PC7MbY)H$&|+`AmUoA ztsH%IPYm@<>2wXbE5}oZ+bG24mE&rsoK;=GZRE?IU3X(aVJmm!OzT)3MiO4y0OJ3? zB6R;%2iYH7v6B7kWp&>4JbFv&-^ud`Qu|93L7=9s`da?`qccLhL`U%U>1fK_AtM>2 zXAp*9AHDh;_0on&>$f4Q7LL!pSY=nYagfvLf$D;Skxer@tbf z!G`F(Q*luVm4ow&uJw$KHZ}j{$70O^0;`6t4-V=4pQd?ma>rv9{YR>axnVnt->9MT z4hHI_ZH4H0@8%LNs4eiqT2pc<$qG*<=-1zyjAyoP&cNx-2pXNZ{>Qv@s9YwMx4`_> z{zNgTa3~lgWIE`8ZCZvdIpAmEC!k7{(I5DD-Z`PkAHhb8q?3;`!ckhgUzPF(EW&Y% zGB12Ik&29o4Z1jmz~Dh(c)~o5*w*gL!(Ysu3C!rS$UKmA;Lyf>{urZek+HTew}1m^ zogrV47x3?KFXL}}9DWd7C}Lf97W*jJCMH!M@j+b~x&Cr;-&>+3Tq6y$;7Y&bhzWd4Il-$8Y@4!@+%D*X#A1v}F(36B^f#Ct?1f zCVOHahi^*b8{xW1iy{XNY>tr9rd%W$TBbO)5Fc_JGnfXF-BL#HzkSh1ZJmnQcfcAU zAuE7^zZg=6!6jK_(dG$3fU3ay^rxukP`ML&o{JXBnOge!cLklXnt%GYm;6Txqz zohjtpv(;#LcN^F{3Wq@^#tTJQpK1d_7fPz?sXyu}E_s}|9ORZ!XaT7p!A~ zBsQN+wSG}dUbXktOBUA-a>{1I|H8FgYDl(}Z!RK-Gg$%$VE1|(y1CKNV##Gy(F{T` zoSVmyt~Y(5ebK>0J!jU_LOc#s=79;_Ux>SPP*c~!?%s3MMDLTPziUgDJ@0`cD-<3+$ z*S~167oPAD9`qldYJ8#d@fXNVdIb`Z$Icy*^}V?+ITqiKTX%oUcgaW)a)VF^iMtEJT~=Q zKswpDabJLu_#Lj*Q>`x>YqcHl%NqPvYHKOigRtr$Kg%BIaFRZTdifh$uk33j4b|K% zwbf7|#|~e97Mj0c^R%*Xq|-|CBB)Zb>w5_X*U|Hu(YK4~RmPSP1_Yu?B76f(CJusLmvLcsIgvv zMR`QFXcy3UJSGnHO+pee?FZs1l~fye_!Zjme5)5+37Je=i@x>)>&%;_j;ggC66sQ3 zjd|u?_Qv;n3JqTFMCAJ($8Io{oAlUhErw%lY`(SUoWy2Q1R5#NG?&}oH#`>CS1*5u`Cx5(vt?1c1ac^H7*=tBK?7y)>QT1ULjt!I>2yB&WZ60it-)x@*!6FDIr z9-5sH3U&e7c&?^pdVs3ua=>yH*aI=McZp zle*+3SPXHNBP_#F3xzNGc~n36f>{?PH=t0vmOBA8_x|@4cFOt-lfk8}{kHsP@cvKjkz)$R4H#BODOqdg9PP#jb5$JVS;3ptqVA?oITm=4 zqEmLn6=D0qBfJjOJ~rRR8*AtTz4^|v{xpoPE?+q2So~>$-%cjP++(;S=`k3}{eP+6 z3&z_&ub|x+cXv^VVZj^)eS0JqFP0e*tt$*?ZoEqKH!rg;Sz1kP@H*usB|^QC*vI`dv*RKy)$pLc^*=^Ct)D;5hwf zk_c2VCOzQZ7pJ+{FHIn{}{XI=+mXkJ|N9Q)8;h&$Jom)*^DzgfXqXEiS=CJ(#izL)nf>|EQvf;a{OrT3Gem@jUE^ z`7i-Td}zDMjh;El!Io#5vMTW$4d0hF#mPMkP*VYMw-eK6-3;18cW@S2-liv8QlZAZPkHj?ki zt=yELO&uk`49bF_1vH2&5pTHS!ez?e*Mzu+DWaW-zvC+hpGa_;3|5!$R|T^^#(OuK z7oT5m(%5J}@vP;G5Q~ZJw`YfB&OZ4UFOPk*oS0A)eF(X?ZU#;L6BSeE?3IuCeXp)` zyLww!8f4wB=AXC$KwQJ`1J;_p!`Yx$$e)kS|DI+96Sx@VL4N#k3p zWx@;FqT658*ydZ_{5xjE&Fd=;MfYFjP_sV}V*PgDM+&8~^}S1uWdCaFVj$ai>2pa) zD|(bsH-2W!Zcg$3umg+#ykrwQi?ty@v z(<2aU^-o(G!p)7b(>Ld&5a(xIoBOB>USkDUo!4SRK0j-A`>Sa|lQh_1)HeS}a!`{b z>vRvV8NLBH!a2Khz%A{3(1xa;<-0C& z-rs$llgoltoJw=ir+&>UIX6AE{Oet2N1QclwwE(L!~~^Du=K$xuC?V!1`i=#>?5bH z^yj_w7b+9?`H;QI;6mLLSGaC3%Pw(TO>1tyxB`VwE3@bzk%12AqiK^c2Wg@M3`#hHPHST;5uYdZA}w_fH0ZxU-E}Yg!bC`_ zc-G-HZQH9i7dCOXG>Ge;g}^52{$NVDBLw|@DTWNkF%8f|8PGHvt6vk(LRf!E|}y z{v`^rpeiJqh*w=K|9bHDwvRXI%&Fl6*q!5|h^(%v?9n%Qfj>h6ES$3ZxW%?ZN=g1* z;~qjh*rv^ti;f1VjHNrhwuw~I{oRJ{CCOvEM2SF&@Z;ZH8=I<6y?9eJ1mJ_O?q!D5 z_~Tz(VhQgZ%u~$m+fONA-)netg@7SG_$=8(m(<*ix}^&E=fGHK3GyG?B3%z@(q{J| zJ@S9HbNGaQ4SW&R$3)?tKw~x0tjL8dBZ_hu>~t(vSENNMUFd#_4klbmXyap#YQm}M1$V2%oEx&gLL8BTs zvQEcCFch@mwUz=qenWHH#$u29L>-e5ad_M}O?za<6$^C~WS5agA-E73oTA3)T77G_ z)+9wTJsRu#;|c3zSoa1PV6R0et&g8wM^D_ep^gmp4d9{+^QpgQQ|0x45eM&%&ioCn z+~g2-4-q|5r-0ST2>*DMyIB5^-r>gLi^f9Wsy3|Odh|=$##>qp|AR--3Aw*%=hLb* z*9Is+@(VW^u<4=1*g0l8%#YG8&B3O1t69&>mrbwqblkRS)Ji#}JS@ z6(hOg)6OSm9jHkh3jG}pn0l zdM`n9>emfFAGDN7J$dtO&A|per@_R(*~Nk7>}-O;3qBm4%l1|i{2$qXw|Kf6ENi~s zT#*;G6b9$^y^iCGi!M}Vl9--5AciltrO^FyPVA`X?|_sKt+~yge@uE9+EzxrQd+VT z+@tv8%7g|R(Oj|7yHo7|qm)GwKlQkGtBB@pO-^BbIFUM|h31`P2 z{c(oAcYXKbG>c%~wxqA7bCbIbkL|l?{66R#fU*aISG+S#WmoX*yuiM67P0r!=GQYCNdnWkVLG-aK919)3_MCaKVfIdF#m z8>!jIf6$``9=liL@H$7B*feM<8(3l&z#Ew;zAwGl!Q$;eOdCsxv`@juHfdbj>mGxp z8Y>+LVlFi-?!(RN+n5xQS|lT1wSyK0rUHG9?IgDZ9tDK2l!gKesW)Vc!5_ZDH?z%c zBWChvL8)sz0r%s-xA}xhfIRoRg2sHVkhp)PtH$i3m$&{>dbA>(&fMv&_zO?V<1D7R z&sg=b{v?{k3sH5fS-g~h)5?0Gp<@B(F0y(nGol!zm0gVZ(CMyb6|dgsQ|M{1^lL{( z0|K3JMBQG{v*GqF^p7j4;URL;EA{~Yu$O%O-0`H~o;+I9=Y$Z`sLbOt!)B5+yNxF( zflfIg9-s6Dmz3vhWPJofM|&fq5Q6!p@Qb^BEqG4WD64`ej8rbLRh~`ES7c0rnAu)U60UHt-b|rJXdx)gKj=|t*G;_SCH0Vc?V4$1 zyj|^2l{*K;_vLcX@AcF6HhVm-p8JDR>aB8KUDT3P2n+{_YotMK#L(T=J`Uqj$^2{+ znMor+lh-ROPD!w7aij7by)t5T*~8DzYDs(?0JOdVQuOjL&tgHJ#eTAXpwWo0)eP^t z4(cNsRAhd?y^~f?Pnd^6?vbB2D5a9~s204P&$UBj2FGUn7&{&Y1K800JNZQ=w=Omc zyEpC56n_-`Fa?}X1Mg^BdW$yFG~Myo5nbr^ymuP<1cLljXq>;9d?^WykNW76Q-!eL zVgstcOv=y`w$9vn@x0BYsOBxyH%7;GMvPnVh)j|K7WfQIt}BkXR`` z5Zf<_&JgXaKdPdta#<>RoRc7K{kSY>e9`P~AfT6A6B&xr-L89>-!5faq!Pc|YWW|^ zxKBsZo=}@lkvYdx_?G|Bdj@co>DP(gNs4rw)0dt(hFR98Gi|G>m7oiB2DzQy*#-V;Zs5WSQ(? zrjRHVqs0iaejT5q0U^l1GgEL96lFbNOvvvuP42y?WXDpYPf66b=zD^IVG|IRaygEI zgLiO5h6KFz!HDS0&(xg7*#6`>B`5uOSlGKuLFVLd-^J#meogx6@#sZHnw6?_xs5kT zJhwF7b3jUZ$N(E0y)3PN@Ur|3s28N>XKQhk04q(Jo_$d8bqaBL;d` z|M$4Fz;zdVPpNr>_mxelU{0Nfw$}sid98@v9)v-_Wtj!25{|ZnwYv&|6AiS=wpjxD z)GXkMlh?Ob3Ss9{mL$0K@?++;z6+YxRpgP%66r0dX}M*iYBzHu2(QMw;or2G!O%Wk zU$N2dKt#4k@?j~UQkE*Wnl9I-{dg6FC1Q>VtqmTexN-cG25G&sMQy0pZO9$_S(kJC z@%VSU|2^4gJyd`7oWXv;B-VV-g5CBQ(_F1@r1J@W;IwZo=1mY|&FkSC zD(L<5a3b=Y4#%1){?)74f`OOmQaZZF67zp=e_W*UdSMbj!nEztGR8FB=l^?&R=)@< z_MA`XB5CHexR-J6)s8xfA8mNC#-8rbC9#~(lddej(+9Sow@c$(D{%JmuS;=C`97xq zy@5_UZxch4q?&f}Gq%bW0~#6)DB;i;IGehg0a&6Oi=oVURSJ({cEgc`^SqKD0ocYV zk5u~4ux^E9r^MHRuNp($SQ^8f(=i~Rl4#+`@ZM8NcKQBXj>EkifJom zHZ$SI5kcl!^-)ZDkL=B&dZm4zxJzCHSB?E_aCqJ8k1%imn!f~;;rG5fD{UnBcP8EE z%pjaNsTncnMhdw?wi3BHv5{PhvY@THNJgao=nweFLq&JeG^eWN4xy&(!SmdpT8On< zjt}!}4~#Xf9pAS-ZApC45#5=)_QE6httyDvPToWf8GNynCjlTyEvYVrlD~u3eX0vg zCp+f8u9CTYg!5Z?wAMY=|Fo?A9n5=qmaJBF9ghT{Py-J}>a08WXB;awwZT;n`oyYc z6*Id1ZGgm}UY!9$%=ym~OVLt24`H?|VwHM8|1{4a-1h)hT_R==;l3z}X=<}ozzVCJ z%}NK%xD2w#VDIlH7sx;J<*W8#krfJArrU3GBb6gs&Xhj6X!LFnGqIC_;352Ip}s}a z-l;OIx-OU&eD_7^QpZ8-ppt&SGnKN?ZKf6AZ=Yauvcw0SEJxp}EjwoOg-{cGilE7N zKi2Rd84qH6lwJc{(lOyCNBe%x62;4!rV|Yn|0)QYc=RME0s=xGSessUvqCFE)Y@;@ zQMZy_P^q1l!M8l`wiS{Rr@vHeL+iI$HvDl;N!`lTvIagND!SV9t7XNP51F- z7H_g*(HTk>JeaR$I^fT(@uNA!l9C(5KG!yA>1|m3433ZwzA()!a*>dT%EA~d0#dDb z=tjB~!oh0pg)qdpvk=|nN3DUB7lG8s`5;bkG7_b1 zssV;r-{IFJ@0x!_zw?{EuNyyrT_qzIo4X$)uxJA3S|o&LP1PG~p0MszrZg=cIQ8H4 z^oHd(7sp^zZhS=`%MJ`AAg1D<6kZP{itS=;@7avFKyYQ*w zfvV(sKs^5>Bg;e#?*vPI6$t`OH)lb?M{b3B<_9ASEyNs?`=bi%!&7)orT-l@pc!P? zc-r7X5bNa1fr$`&fbOK`JpgpKiE-I&iC(9Tg=@X1fWx$nrj1N}tXIKn4g6CxjQ{bg zMT-|P!-I)_7vI+yjQ&yitY{C=Z}Hddo73xSCCPM36ktPg`M9yqyajKQ1ykAhQ-JeX3C z@%2jLU|z_xd|Km`0a_&3=U@L%Bx%RZi~++ot#>(GO5Mj&b5$*~u9bFTXy20B7SLf< z)&a8bg$9TB$OCa;!44r#<6O`A-Lt(kb~lnG6O$L;3(|5*@sCE=}Zb z^2(r=wv2*fx7C@sZdV5CWI`895D2;eKNo@vY0u=N6ZZzWHjAV^qz#c=scAywNaS)w z$D^T^3-hyjC|I0SFjc9#XjGDsU77ucSaTJFc>q_E)<9fgfAC-4%+nJaJ^oDw(%H)_ zqsm+VHf~oe6B{N-awl%T*Ef=3bMv?Wz4o67T&Q=E)JWd{#j7;L!d_2BbPE#Fca2`) z5%}KgmUeXK=Mj@DE3*yqVt8FD2>HBE2gQ4Nb(4<|B;JF%Hio9Uv-G71-aQ)ks+B~R zD`h+Zvh)5B@&<>i7MsCmzXr=Fz}m_>{{P>};Np4(DAa9|$UhmOcFb-EG^>+pl19ZaBJmp_2BuHjEeyx29SdE>Rg~jq0yF4QGd! zdiip2g}EWO~r;d+(B)=mzQJ@7Y%hgw=?y(G@K1Tc?gRQ z=gMqCiGnBpNVN`YnCKN7Jr_y7^5^51S?3RM65KekJp&po<{aW{e?GR{83*mX1Gkwf z>&t=kRtdajWl7XqGQegP-pe2rS?Kv$nDElXDQq$0PUSDh}e{zX8!wE z^TTzKHe1`(kN*xPaT1bIgfLsiJZ+#H_RvOM2}!T{aa1iBi*qArid~qz{r@a(pf`gP zF=i6>MF1MgUpmedl3C+Miir}VjRKWszw?=vM*T6^1}51E%`$Dw;|_v?J?OM*X^`{K zN-vk5*YN#~Mk_#Dbpy;wO((Rg2zH`Yg0N|Ex~F&ox9Vrhn0is)+~bIPEu?49z3cS< z%s{}#W@LD}c-x<&#U<*-E~$Ijcx2 zt^GJvNcrm=ET=^rnY8w(bs;r-6cKOc2-ep? zOEhu9cu=6hx@$ufJk*<~64H-8z3$Hf--$`dX^_YfN%|p3u?K|)?MDyHA0$g2<&-~f zYW(fIo6wGpknOWO@;=o+Ng;Ev4JFUUWaqZcY28zWqq!RA4M5c1n;D=ezjhn0xpz}wQ*Py-t7t7ydc9v&2Dkj7``{Ni!`1UMzdJyx6kY{p z_OEI_N2I$y(u6v23Hf1EiG$6yQggL&)9f(;;6x6?jy7BCDPCeu3NP6B z?ifopxq%;jA53xlvi>c%Al%j7X4e1DJNVLQA=h;>Z>_=zEjkL>JUCnCwCYTol}2%0G~y(w!v-=J{T zo{K4a^t+X0+xpQKt?0@^Y#@7vG!*co#!);FRPVTk{!ghY38 zj-FneP(7!q7~%MP)T3hp?#ea3mKG6vgF5 zh((Y`A8OC(4(~JX)jTvQKOOviN@8E@NZB9D6}7sUwqF_bt|eIFWs^AeY&T>FIhKmi zog%xuTDLKx?DUs~R;vOXQ99U%{LijvwuZz=gRdG@2Bj2D46bpre(=*(H>zaANsjre zrz%FL>P~R>gHy&TDQv&ISqgZ3wyJ7oRr5|31IOv)FLVF*`xM2iZskmjG2thgq}h7u zHad`mbCVS2|Ia%wqH*GHis zzHQbVM>0>^hc_J4#Nq%s?R+K62pBRiPd59Aym{O0Clo>?iM?>$1=IU#GKlKwph=8s zvT=%LNNH9F#p!ElE&{{s7@=?glY9}G=!w%?(LTs?w_S(;(!xF#%ZIK%?`Gz=%pzbW zg_$0$azSxSe?@H@6d~Pb;n?2Md_8J zxdcI|+;3govw&aFh(m4^8AWG2_A)Pz7Io$ynG&Bzt0QQ`nY6Tx6-D7Nt6Y@OspKKB z+)5t4Nnga#%4GV!wBG(&>yh_#2%lp8uyDV+lHs}GWaI5yKPcTfL68B7MGm=kwY!q? zn$xPxfd@-}!perdszEZFKK}I4XTqsBNHOMMz&oYvxGM!7+{!>4RGSk~LQo)Kel}eR zaOs-@cwAmCCC9+k%hmwbRbMl!*K58Bj7(aI89cqQqS+(I-hbVDzDO3DSbH+_^h8Yg z$O`8tG0V5~%)O3G0m^<*?t>29vFF{%{5lcapFflW>_40-Fj^+P z(nIOHNqA6YFStq{>7%8A<>yQSw625ShJ&x{ujWSi}L%NU2=w2f`O&%6K6YQtu z?2c=GEJHx0{fg<}Il{vr!fP-m%10g?tTzS71vZG)7JVQqHpAa-5cE)oK3UQ})fVF0 z;@nfqS=SN9=hn8gV=Rov{BH}5tJM{eL_wo8E3m6{#M2Gg_7<0;Lk9oRiQ6@`N(`cB z#OZTlIooiX*{?wfLgpngEdvW|dRlZMA*kcAqf^}@?EX)bG9x7jqRZmb3dSL1n%}dIblpz zvjnINWB|?*e5#WSz~~*LK{b1Z!9u7m7W}f11^ty(kE0ixr+z9NI=?ai=--*DOj_}) zw%ucj^F+SSxRg-dwb-^3b%NwK_OcHAY$0gJ^;yNt-RMfu$QA9bez@=voTo$6-A+vQ z)7IAAKWX(WR|KJTWKZE+|G5}LB>4%de1X$GXd-Y{+5_@J#hsgbdF9wOb*9vM?u8z< z1O$ozlp@7Ee?J;TS0Hd~S;Rx+w)Y7Qu2PRh_{w*fS26aNrA0#L#(I^0OyX|ZakJ*} zFk{s0k(*;k^1UXpM@O+d$c)9);y(}+MoQA=vIp+AIN6F}Aj89n@yNZ}=GaF#OB$n& zeuiOZRV$m-@N#Ro{IZdE=-Jd8&lj}J>2Exn#|AE#iu0gPG@*2V#=aV5EvyYEHWqPW zL=ia&NjL(`-?YYjiDgn4t3?3N@YkoP`v)$wW<$3DFnRy*KjqV$wDgGHG$G&?U#$Dw zXgF+~W!)KZUv@AM{Z~Zk|4MV%!UaN}{=6Xr*&sW5&94w(&`^+0q?8a%Ks=Zq?jwb% z2}56xEy~4}hA6xgHExue%p^WJt=o8#PQ}5i-__C6?E#t3qn$LrI?yBWg16_=%;E2G zxu8!=rKSbbdWOYP+}yy($-$8I->Hk}dI^biE93}lZ$Ox^!^erN>Dy4^U(GQ(5UVd` zovPBe@L2{T4;fVvhKF2^mhH2zi?D1Y!MRoGP%DuJKi6<|n=8R4v zMOCgD3uYVdzks;;Mg1VOYeWBzyM}6<8n3Vj{EtWH44!1#A0FdKE92NJaQ)7=XaCzX z6DG&PA3^J<8{eaUixeAVoIkf{md+kSdZJoFPl4$A2}6U5L6F&ocG9+OisvUqwl%by z@5?io31}|t=QjZa`JYJuD6&;yv>a{%T=$cd^knK&OyfQs*A|&8C-cvXrE&3W8L^Qg zS++#M+4#Q~Huk#z{i8)3dz`w?c=w?5ci~_v*W0GpA4hG+uhdhrJ%o2aOLNL*}%*0ZdxeNGmneJS@ED|A?MlQ`fxb-nYR;v{KLzSo(6c*k+0aBM zm8meI?gXdYbfG(4q6pjP+HsH?;SJARCJ;eaih^7R-e*Uj`c5SIc#4bJ#j#~C@q|ax*FJ6>50$Yho0=cv`KN)=- zMIL8$4DJbRX>L{`F z@MWzq_^4lXEXS#1QcG%o1gWdC)f$6&?Sjt(ekzD>)4JFa>qp+v9%^rEhW`}E;S5=> zd&o5RaGk|A=`W7n0~g*9K8!m@-^%{qL7%3Dgv6}RNntq&(g-jNw zDkb66KCF{UqnUAx$IPcZ_7;woIkuRk$YIC;6#zC%{ zQdHqc7V7crt(pIg_09+|UykJ)lx|{LE=bQF<_5cn{Q^K`+ENw06ZWZ*;RqXY#0+CP zyLG8X_t?H(n+m%KB%KQs3E8{D*#w-7y2L*6D%B=bg#a&F@6^wz;5JqIS0&r*cjS{k z4jsnhYWD0&xRdJ+#N)ALiMBg~GA>h%kCgU2^R|EX8O!C#i|ezOr{!c+V1^F1hRkl2 zLmDd3lQssjKEu!%^zBp}dRp4m#&D8uz8Y-2tk70UosB^(k7d~mzRYnORV$q~WDI87 zT;ya01hJy0?S1;fSk7ZOH5a>5DZNOYV-Go*i}hQaB;K9QtnaF32;nzh#s;@YjZ)a@ zHt(BPgUlO-WQw=#3*xpo8!owK^N8nVbW~C2jc~V?m(Fe-O)*G3dFY9a$@aY$CLUVL zCY>~cpRq$sH6OSuE#EyX+)BEP6oEpw3EybXOOS>)j*sxl^6tCMbPQCD>Sgw<^6y`( zC4sO96L3&Iy*9y89El<|5hij=UeDV1`!Wv!Z?NW@TUVZfzxyn-D9VgSP2#=^O=O)X zx_;L5UIdArqn5t?*Sldd8rBY+cp?6`gk!JGwMfG{?J@2KouQOb#;xtcmAk%8MUK3B zVqmlFMWBp(#DO>lcRJ)(nNluk{7X8Fto)4>*!2V1~blGiV1udjFWCUcmw=K zM${xA)Oykxjeng=KL>d6Zm~cmQ#At&1eOPx%j;^4cRuj-YBJ#(yb3{op5{O(Vj2)lp0~mq+ zf8;rG&VJVZgSuH6>dc*IZKjK?*9HngWisk!Z7p^shtVMXP^=b@i(dszQVE^R!p3?V z<6us?>9&i90b<{{%V<&{rIW;EYuq#=T3k)rrM!$8ivy@4YvRfIl&4j$_k&)1?duEa zOO`A8&w?!Wvx&I1xPBkJ-!59m!6n4QNd5HC{HwY5RntuF&PN$Ql+H;U#Y!St2vN*r zeTbn@xSPRMq6W?=^=Qwqv`(cwMinNZ$!cV~? z877XMgUQ-<2~c75a~@r&Wgz3goyTG?(XBs%CN|&9ymBe6IJgh1@&{65v$a1-(q$!} zwPKBEBad)W#`4o9i5-RqqpNMGbvz(3DDmq4aH-pNA%GhnQBF$;1|LPn_JBWbj1Hpn zr+u9#HE-OOvoCU>;y@en`<1|}Yc@HaJ**m^Ntx8M$-B)(p=>64nqE8iJXc?)q23k5 zW=H=urac`WFhI6QB3waI^mFVD$o~$ED??~&QO%@U z!*LS5p8;<+O^zQjj?Ivt2IJoh3f7Jn%o;rwJFH2>OcmR_vO0|1`@a3LncC1?M1TSC zuBf|)uK$F|sCOH|$nQqCZ^PTQ&7XE(5Xl^ek_ABXbpS@Ywxg;o=Le8w=f}e@BC=aV z4E2I~TvC2^UzruMmdo;Svftn3-ra1$)_Pz$O=7g7n}Z;B!Gyrj2k!pUq}u~^q2q&S z)akUR+6cOFeXtK1Prn43p8w-_Le~*)UUXcuQW@x*S9^saF+mjhXq4R$iyk|=((RDv|BF}LB zvc;!|)gz>+*{U0ZaJ;AP3O$+!D$M(Umds7mLgp@MchNuMb9r)6JP_L@Bx_KqQGW3r zkOnc9%!$DTBkPOyvpZL0SBv#Wjk^Ahrik*T&LD6+}2yP+f}vs+@HaqTMK-8``LPe z=FZ_Y_z`C?wuJt#PIT6UB%d7a%8Q&VvY|O|GHSd$drX#`gbFKqhS~6aDkV?Qg-ku-Kp=i4ZG?<#+9a@#%Sn_ zoY4M0MtOZx$U1^?9`t8?#_i_f%VM}3y$9qSF)6#81h~}yQ&Hge+Y>oYA@eo3A~vLW zcc8W@j{q-id*uHfXnlE!g>Z6I01YI@-Hbl9FDf&sF#NILJPu%^H#YyF;g1YlZE}jT zN)1ZccoUgCHb}}0T__0c{J2lr=GiT4Y)WYUN96=%yPdb6B7@)8XeIkfuNJAdRcW%U zxxMiC`K?nUVF55g8#CUS)Yl_&TsxuZ8gXf^sXqTP(ZKX=_TYS1 zokq?aJPk{A3%jdfZ*}$nsdSq?X}v zhsFOp;I+{d<0>#I-J;#iZoCZa1l5uj0iT~xL2iLt^?`3U|2FsD;msRz%gzg@-;98A zk4ZVl@m&rq*^wUBxC>WG( zwf=%ID3fi`Aoc&d^`EZkCP)K((#OAlXqIJ10fZ7-XL^fLRm0%(V81_A5o+LhNi#L` z?wZXp@+!sV3~d$oj=DjN=K5V=t*fPXTZ*T+&6@HPV8*58jJVrHYEF(AwGbq!-bGiYoa!nioO?wb~BFLz9Y{> zSechXJ~znTKc)_jwj+C-`Ub)ZodagX%c()BU&(ua<>`Of=~qTL`rmX96{hqn((JW^ zr%s@+`LQ(|9)1ju;2WQ>vHz_IY&~*MY6pI01?yMt=+E_RsNdm5#)ZunMqHCo;v?i{ z^sVd`S}kZN^>DYwQFi?~Mo%B<)e2`~Jz@BXA?*~;6;*xVB*(-{Q}rctapWb&Uf|08 zbttDKb+!P@K2^&5{zZKIW9dXg3u3|1FRvrMYhF~&-9D(kl!c$V>9^$G?3jQHg}-0m$+K1?p101dGEoT3PTgIu>e&guWJ? zvXdeUnqxr2c>RYXRgF%=0@GS^XH+%AaLwMBLH?W!%S|8YNn#C7p>w8m^*|gH-KE-f zp?NpGxsaggJ;|1HkHBm&b_IW`uLh^0* zx~>&au~L65v;O~m%kFPCMFa^k^-lvntnf$PuapEGR)v(QUg5W{oqCPl) zM<3u)zvex(w5>VecTzZ{`P0ye7V0V(+PfLI`Ps67Z?X(E_zeWkX0`LEvg zqw^JYAoMUPsLYnNIb^;$kSIpQ~q--Pca ziyy^D?K1Eb?{g5ZYQ0?J+LsxiAQF6CtYyejH?+)Glu~ETq0Nhu?wmT0E?NP2i)$SE zZ-8{Y{wblH5{3Dqwy|*8Ed~VxQekM_J@lm>wQh^&NP5WE4a+uVi{X2sd<*Swz2vjbE#gDSYKE4bX1yi@M&j_7Z{cul-Wg=OPuAc9`!%E~=u06)82r zQ-}y)-E4LzHn%rExvhQ6ULI@X+@IyPS4)k}+ z)k@})W)U2Kcnz9ApDo|3@J4VmL{3bREFSuFL`mpjkTT zamXUu&(jZf z%C~tWFYd+~B9{Q;)GdiwXFK$pGfZA~SDDgb83fR8CD23j(hCvJTCSj@#r{iijq|qh zp?93u^Uc;WZ+Wm$4uemwi%7ZmWoj?g7)`+SUAt1qNmRX?ek~)(Y=MzBor;h}d`%!ea#jafJs~XquK5Td(?TG{pNANnb zJYxsiT{?`vOu;9RtdIiC1A!?I zd3JiLcH3}!ZI|os4dT>0b}!!?^j-0oFag zQVTlBDVOuDO9{}mljYutEpZUqk$)I0)~Bc*hHFrM%LVDJ8zA}6`%e#m88>zCwgZod z7`KQR;g(BDfAE57rt|iwM>81w-c%?zg`)^qk)x9gYuq11o#cD7tmey;9=B@FKF%@& zZ(41lw=_PGQPH`RTCXqXa;w=$Y?uflazR#i~MMu zC9>gDq39}ZNN?8wdGfmfa;RD}0s3d>lGvw(d`cC++?NUOW~!_{9BgjUB*-ry1IKSy+KOAZb6%Y=)#WV38S()xg*d$X01M zIr#3%mu_?~Cpi+d7o=8>HtSNG2I7TFj2hP_(DxZ@)rPKFgj9a@*d~i+f+*>+e>; zH7U+SNnPgXFcwZTN_7EFy51RpBR3$mYSQT33ye4!M(|SJ7#)!xjtFM~u~_0^xXg;% zF�Ou^td2S-jg1v}H4K+6S*43oYKO)qQmdl!TE2t`0~11#qPscpvCL+SSi&F*?b< zK6|cFzzWgRbn#@96NtF?XhpV3Il#}g@Iu-)(vRpF)-G0QbpGa<1QT6~c@+&}n+l&*lnIByO zz7OBPV{8>I+S6d3jC;7mpfa+JzBv+Y^n-w;SpzH34# z^N{#eS|pj_=q@^p#{T~HJ(^P%NGB+RNo|6gdc?yYvP(tz=Z-9Dvrfu4a~4tK=nYGi z&o__`{vx4OrPtw0OdE2ffMdf+cBF;=ZuH(~^gFTdN|Lk#B}J{6-J!?dQFzKd2}FE5 zc*bkNGyYs3nH6>}w1!!1R6Y=4Tkf_^ULHwE%T)m>KUHT2wzgdqVtuD#aLFhjXn){d*LLGzL{x`ZhF3RE4qog zunvl0!v7CdZypZi_x}OkGsc>%32l~0_N5}iOr)$4Az8DeM6$1AMkz#*HOW#RJ6W^G zOq*Sni0s*yF!p84%yaI(zw3Ff=bx_r;=Ye_pYwjd-me8oom}Z+K|frah5{)|wgmmb z*IehbpRDz{c^cM+`xhLHtx{~ffXPmW4(%w%!44Nx84p!*K$US&tT?=Ia9 z%9#rf3qTPy%Ly<{8!$XPmcw*MU9_A?c}VclR;(YcdIj_7Kb4g_nx`=8SlDlO~C z4}>Kb*c7eHYd^l3seUuX_%lxD8|DS@+5-U6 zG4$`Z!0!CqZj6W?Pbf_aMLe=8`;^;u2&=6~ ztAHf!z$Q@r85dy5yJ_ftxK;Lkug$!%H+U_|_U3lyp!>xqw1rTd12WTB7Jt(%EH(vI z>VD}eS!i?SM@D!5UFOxxtil*B)So(?-fWn?0}ud@$f*RLXMi-V|CBy+Ttp;O`Wz`k z5v0VMtiv1-{)1%frz6IhhWc&os&s?SS}FdX+7L1OLpvJT!uYqMbDU9~6bfHtH$b!{ zX}oeV{dSgtCD)f}?9|vHl9=nHfqS5d7hJytG4mTLV^3wU@WohE7oO@8?IN>>kmzRQ zTkXT6(!nq4De2yJl0_I@Vm3mOQZ&wDY*TBQ@qAd&>D5r=l<%I7yT^R`vD6Q^dhM&1 zCB1HWj^Y++^#BF5Ms~HNNM>PiVa=}(JiEgQhv^437bW;dk{PIaJ0f{$+If`*yztUy9Qj!>cM`$FJColM9{{m+tsOtfQ#soekbJhq9U$}B1cahk;SMh2gAfk2@))n zlrUx>4k`H!pr-Zi#gjYK8mHs*-k`}9=%5>qwT7#lk(m!kwkHsB^pq$Kq||d>VuB9d z%FS=<>t;=4gwQpqS^4G@KZAF_3Yf)@0V3ku5v7Pw?cfu`Q?X~~0F&)6D|5>Qp&!KY zVk;dd&(I*y!UX1mFK@Acr>42@WT5JiWL&Fo*CL^?9gTTDeLq;8{JTprgFG^KGp=rM z_+W3*lV|vW0zGwJ6vtl6#Wd`2EyJ|XOuCsGBW;!d6+F1lE&P-mo!bUn$8TsA+p%{} zpg&28E}BF?&AE)UV-ZwSnlA&$TLX5@3s90Y-!W&@u#HUwWFU^w9V{{lKF%eVSC zh|iDp&2lVW-W_j@p}nW0#o;NSJNYNL5BYRtR0wf3xg9M6(dz3Crjo9|h*e(E5wL%ygZDL~iYias z2|t;`s>D0W`lj#Bq@!>H%mFR4=i=)AH6Ez}Df?n^K z781xHCtwA(X}yUmEXnluXx+{D8lsc$4n(3H;46$xKTgvBekik~GX?eNXGMz$(MTY8 zzv(%;EG5{EfZI#P{Z^61tJ^7fD1>sstxY|OL%3T;Da)Y;PZKWMA7EX8&3PhPKS)hYWw49u+J~+ z0Vl6}^bpa15jYFQO1nNNdV)(%#5vkfG8A`ZpFg**7gepwr;#L_wjH!?W5`~*57E#W>n3- zm~6=`DXkTACJN}jh8iH_d3JGdPLDn8Q11}I1V?(fi^#�f4kfh}Z!pl&Pe$267N- z>CJ_NzJ{!Q=an4&Qksh*U7(}og9?A+|7t$H>=NHt_aIesTi5n^B+*%mKhtZo=UU-6 z&msk|qfPwaafAxMr(%@gFpW5W8ppo=klb?k0-x~CYx4Jo)2fr8QF4E&wtZ{MS``6K zQ$-_dy;M9cV=pD+J^=RQD+fcd3OpnMOze(cQ~^yNzrL0<2#_)?MZk(rWvW&10w`I zZUpT*xFj)w%ywelwqDjI`68J4#;ooO@sNi$WcK{l3*y-T;L=yBfKjw#X~D~cew;#hKCnj zu^V}n5Mg@C?8WZ5Qz3@mQ9i6i8lz1FvfmtPuJljSM0rYff2+0POaVbBrMDGakIf%U z?2jv(ZD^@}{cT7L<6e@n!~l!^hx5W8s736qpBg!p|YhcjHw zpQF!r-oMo9f))TioZmZf!0g9Vk_x076~dpl#)-ZUWe(pQ=*@98)G@aA&6_AO{DO95 zpheMB)0iPs4^X-4xQSre;2rAx=e+Q6g8(vYlMAO@8xfTy_f7Ezu={Q4m)1- zepVPA;0Cqe578M)jRC92tnELQt%v34C)`aMr#&l6_G8jAKI76=HaxsI?OF$!yT<`P z>*h0D)ec>Pf=xYFRbBf(ezhd;qD(T)s*o!GiEf`rVB2@9w6)*0SQZPFk)alCpCd%3 zqTi3xhTuwSeI!0_9sI?N4)Fog4Q*eNfbKO`Yayrm_7meEL{Z~Y2(uu3NZNhsXFVfwh&Ebe%t!pp^5x)Qd+d4?9C3l5nxUifFu zUFPNRg^!gKj!^@4ZK+D1{v39R@p_rk^m@*7ulnSiLrJ6WhxH9}LJab63IW`=mnw%~ zs}-eG*Ye0Z+qb;E*-K+{3{4mMw%p>$G0ANpz&YQHS|;7J&X)4gvKQ_ke=drrL)tx8 z|9_90ABt+}XCJPz-XG-Q0+SDq>^db_43X>0B6ve^IYJ4OUr3Sh(PgCO%;Ze)8`+Nn z-Mn5J*KkCDYF|)xRO2E$2NoXdQTL1XlV{g6T)tN>4RKg3gmc`f%m~s|e+t-fnA-h{ zA@&=>=}<%~wuHH5;MbWhSsMx1)q3cC+z#EOi|z#aS)&$$_L~VW9t&OIgh1aFUCsaA zN)&o{>RPBjqM)Ep5@05cQX#cVgQM+=qa(nuHr&!^g@x#)R`CUGyuLu&gSQ)>w+f_ z^f9#k!`m}T7WK^_*8%?miPD%T8fe*aRHbTc6?li5wg(sr9^5l&k*vy7-Rd(*#qEaU zj0lGa^Y4+5jKfJObS8a<09b41R!T>&6H?3_(?FK&SZ z45f6EntmWMKueMeq&$~Ht(Xv_GiVeCOGDeL7j7?OPuKUq&y@m3U1e}WLN^X}aN0Y} zh4-AibD7_~xBe)6;jGtlZQuYtcmZw?``rIqRhS<70r;SfZ4>CTh|Gje5sa~T8mi?o zH24vGd=DxAdjm3a#mmnvS!6PB{O$w=0Grc^JD9ydIy-;3XqAwf0EV3AB9MxYFwsY@ z8rU=)upV@_cT9}0T1jC4A#1KL!-e_jTPE&5y`&!0CIHN_7T?z7)JMcJ;737^QM5OI z4LvjhByo#0U#6thTRa=o!K8lkeUH)PtSXU>uirBgL%xVBjy%%z!PPWR()TnDn9%oE ze#{?cg{$2+0C2zY`;aL9-!*jmJc;lKh573XPVXXc4Bkqs4QY?tpX{wN2#7voW+0ad zoa5&IvHNtNU3m!Ex05rEGuP)X*$f z$ZW3>+*TJ#aFit;BPZ1a1sPDhS-~t4uv50b6KW zLJ~{8-(_Yf`w=+V+upK+!zR|rWS;-0B^R@tw`x_*#|ImYcG`&j-;bXc)r&BFy9Cj- z^&iKKoJ;8jgPzD);QeNR!&}kWqHhP!+9u!|G?0B3nz{*o&fcM z2I7CUncUdjmU8jU=mqJ+-pRi*jC@3xdAK(&fvNFqH5hm&-q6uNo4Z5Uc1%PVU}PH1 zFwP%3q|JQbwv@njV9=g8$u9>FnlAiGdF=Q3b<0H^nIn@wJhsA(oIY_ILKy&P-sO)0 zz~1gGEUw)E9tMTGI7Fj5w%U3NaMn3zQ69r{e35^uhFl8o0{q>ykW#awDRnwfc_8Q# z5Bw^sg@Z?d{2se8fTIjNN4@@x3$!SJ4tNb9{ZLe1B$OuvE-Zj-?Jh}kdM6KxjjPyW zUU57EFpVpf#7;B%Zt{%85GxW|G|j>F2v}aogwqS2iKoS`03BblLG7ZuagML29m_Y2 z1h#U41NbHhiq`( z*SDqLk6PySW6k!ff8RasBOr9cQ4d{v*V<4V@_SfOe~KV2K%@b6!3PCO)L|o*UH0tL z&x*UtkGyjSRZkuYZ>$~<={TJMiXc`C#i!}=4Wh^u;5PC;-Su$G#PQzanJ14DPxbrL zKVqG_&WbXzmRTM!_I+MR74n{jNKs7GT()&ht_Q%J0<#2ju?1JC*g|U91J0#lvHcDC zgl6xs8gnc@W3_`3hN0^GU@%U1nh}AoM+a_|Ykb_Sym4Bt_+7DkYTVzL#E|r;P5^+VN0O+i&Y+YrF z{EqyBru;--fQgwNIk?m+%^<>V8-Sd9_!FVC{1b`qgKp?R3z}^q`YEW0(If-3e%E)d zaYchgzW_RpVHWv`rNe@E5PZ)>m_7l<&CL+#<=(^Nw~Y60U&iJsh-Q?~AF!ttmR-+x zd!67s;^1m;h588Fk9Hy$Ph{HnV@E0Uauxe8yg%$Wd+)gmR3petSUTdl98F`nmlcck zKtv~A+SR5mc>skJUxUyydwM}9^bX&AK-Yiaqj;CXY701Nb0;Y&bDDSe2Zq;Qf%~`8 z>cPFV9%iT?+`l5pWz-~3wR6mJST5pp@I9TidS;Ut&N>iUcYYN|Bd0pNRK8mKi7RGd z>M-7Bh;h?FHC|u>j4F)_=}&Mw3}4OA6Yxy>kSLg!cH%Ws-so}T8%$%@uAtY&sUr^D zg%pw9;oyzV{^w}uSM#VXbPm4&lrXN;H33=~Ja6FP&n(#wM_#JhxySmq{o>0@v}ea3 z4!YLPB((*(!a2ezWYL7nj0f1d2T+YyDn+=9gIS^rR?@;Vh}7KlK#6{*1|(_lz^)9_@%R;xZTtY0l2x-)#@XQ;>XcTqvJFiv<-P1_#t(`BtnYt{?Iny@;caq*k2&XqbP1eD2wp zBREC1!HCgoIkQL))S&X~$k%V#rQZ!5?>#Lj(A_1`1sE}7v({iDv;NCm{h6F_>tng2 zOpN!$SX>P`&XG#k2gAF|<|lCTRxf-!t=hngDC+e z8m#%41(Wm}WmJy$Ic3x{?+23Am&*2$zv%zA|9TwVTr|eL|6R8ck5vb>*3Sxq$xEE2 zhSs%c@#R=!5TNt1b@fL@y9;fcGCH2WXX(42@)@UYQ82CIu#k2F!BL+EUZWFVWC-vE zTX1KGq-qArU6+sc2*(1SjQ#^K40#Azb}~wb>ughAkuV3kr7Bc}_1neoc1a3Vfes0k z;rDe4^YH_zppJ?HEJ##14|J0Qo_TSrCuI1SBcF&N7M;~MQfA@R z@jM_D?URpvTTTqVpUQ4$fLEX4s{sEap2D-YMjfJo9bLc#{SM@??(2_yyiIvfz4~+K z$kwdl=u&IN^J^_|w;BQa7JVC>8Lu)tx0^9rDPs@{86Ey1f;lSA8S4W!oD>_g-R(13 zVXh^Hyv-JC&Bvi68kiOZZCDbvEZdAkY@tc+qk{3FHE|kH>{$*Cj4Dz;dfGVRG1p~m z8#oIpMF$Gtx9 z3cjX51En2Dgo!364{;vtK7%~vKY^%h%XB|EsD+YJASp>J1u=Ad1EUd*YV^(PE&h@|>^0Ng#N=xpS7sRX-n_|s_vQe>b-mko`6z^gU>j8|-qVzdXRCY)Nl;{cQ zo`wLCh^p410p&dd{X`U)S0=15Puv!B#HK2G(yC0v5YIMs;xN(8Al0>W51{y+vpgmK zPlXY;Zfr0gnjcb$9f86aRDst;+eGZeo2k}x-VL>FzgP}56Z1WsaBUOgTDGlWmu)Rl zf}Fl*@Q^4wx2ba0irrFgP@@ZVriDf5WUxfp8DQ9^?@_%hg(y=WY_t05kOmhrh9u2|*^v%yaY<-Wm~P}K3S-h|<}z?0%2E^XT-8(hyC$Zp zZaq!v-f_C*i`EekmoqF@ZC6HSjl4dUs_2#Uf%9PQ45Mz*B`wmbu^8{}K(llcThr;u zVZcR81~EUcn-1Bz>Y-D2IAhsTNNKCVxqBmX>I15j&7Um0?Yj9QYEOyN7qJs1{tTpK z67+I|00q7DlI~0XaC`X5<6!SsOS=3=Z*ddThEE(#mu5OUyDJ;~=TIl0R#u(3Z9lob zI<(D7T?R@J4w zzHszmn@U-YJB=skO9ZkOc7>U!WGYGX>Y6{a6CfWPA=0{Qk1g#U*a$0(K?iGsF3r1o zy=W7zfFi^4#UHw`152*Ld%9_l@5yeP(7FahN|ujW4IE7Hr;D&`T-hHVh~bmmQ}>xu zTyi&=wM@5|wP+~IJv4|jQ-35BTuH%_kxCAe7 z;_n#n5rM=z_Y3mYeeSP=7tnZhk4@1-(Yk_m>;ND3?PuEg&om8AsDK*`-n3lSka=M2mOp1XkPUJkr??{V zx7;eXbXg@uAUY~A^e?D7JOaGE6JAB%VY|U$-~Yh?#c}Epu;VE3y;v;v$@Bhct>3qq z9}l&Lf-U&Y%cSs$wy;7IZzB-IzjP_=1(uxFh~#&CxZ|(Z!q%cZSnaNu;qd7F@7$To zk$pDV8P@B(^#&PrAgF8SNo+!@d!xmN{qaX%56c3hfuMr072C7;Tij>26S-C`R>-$d z2ss|EOcVy*2}WI674v|v{`+GQPIKE3J5D)Jn{uu=e@Lt&?aiVCssEsyDbJ?x{=v+ zMM%=Ce4Txl0MN01U54=m?&4bO0OT|8p^pM}f5C^)m3#{jJVfU=08i!mtR7xTk|ZJy zAnVba(KHwn7;`5^+5Eb;$%AW>G$Xe@W~&txf)rPA1{^CE6nt>)u5&5!N9TNmDq+p^4Ivn{4#;=aWCm9Bb9{h-!KivH*Mn3fOk43Z% zB*GF5#?U1rhu656${g_5QQGCwp6nywlj#Kh_gTJdv8o9Bk7Ohlp!*7FNSZBi+;k@2sQNNB%CLJ{D3q|$`R&yFfm2a$^>FI?h@}Z`PO)>bZKV0& zP|6m}NK*zY-haZaVsUjiS$m22Ts9pNY7XST#L*-=LfMOABH4-%FIMyCK76T|a_D#B zmyv4A*nyrRm+;vU5{Tx3`-Sm@U3c~P5@M=?JRSpkDYBg6dh1?z7F6*{eH~sd>btH zmMZSofAbW_W0_6@duG0S~PFLQ#~L!SRyFMSd!5j+o7;k=d{)3vO~xaI7YBK_&{bdXVwEeXF5LZJbMh z8K}1m?g|4A3{)88I>5YO_|a#jfb01#XBYxn35gb?34GwOuYX1yo6{893G&fC1z3&%po zM`rD-#WzlQP(PW&CMLR@jAitsWCiQ}y0IQ{y5Dikc;i)z<(l=F<5)2sdGTB5p`DYDbpRD=W>7;G2HuiJE8Ntn&!^*5bSaRdIehHv!lyW)zN? z(07zN@@Dew;v;sp3N<@DC;v0Nz)g3iD-{0#ueQ1ehE=0*%xWi?(T%#+FIROM0>?zj z2@O8Xe1C}d+RG(k#*S@Ms`^C9&Zub)lj59W`~CPivk{8WPZ`3&`Yar0r5i0N;y+$M z=wt8i5^AX3`qs;9tlQu$I@bK289#)#zD^M*TMt#vQ*?T&twb<+Rn|jNGfJ}k#|3>X zktB<~=Ck3DKM=Hihzl+YfO$U;XD6{v{ODB{6paRkb|}!>WWsP?=UQMs~a6X-5za_ucmUtTz=! z>(R;zumSz-k}a+7`OaL*?CaPe?ADeuI_t2JL~Q5b+YcJt!GljSL*V3EZ_`JzRwv(X zeh+3(+=%?uSA4U0k7$qWP4$(UsWX&bNvHA2<)0Rj%+xg7@K_UjC6KOmFoRR8LZ6qHRGG0})?G12 z`lk-R9rpep>G-i|1=uX0%~q)F>bn!=eyIua2->^3edEin%=pSCve|k=(K9HIxi7K% zDl5P({%jjBwd#O}Bvb_@KVix=_*tzWZIET^V}o-K*0H*vp0K?ql`xemLvo)eEc( z(prX6*mv1|WV8K-x6F9`54-fkEvW}6#E9u4 zRl$S3+aQ$pAqciR;q%tda8`i3A&P>;L84Yb z1Ru@sFra7(_H#<{!wyCI-T2&RrZmP={ul)9+>eWzY8!!X3HNRFbsoPZKC+>7{r6u} zki|)a+y#dQ)Wmrn=pvZKXqgw2ZQm&2-34;aSA87*I>9(kw<{mWq`WEAjeC^#dzpOICe1yT>Rj#tzt8=IsIPGerco?FwD*|+$R#`s69 zne6l7cs=_4Y}s|{TAJVCiLOg$QJMZzQo?p`hu>aJRj`%A$#uz>#7?ZjeA%u`Hc+|K z2k4W~=wBuSi{W!o3Mw?Q<_9m(;st zBFX+G{#_Bvwm_5LSKuho9TVaF;@b{gm_Gq^)~9xFfZUcPr`)@6t@JD`)TZ z#Drvhvb^{ssQJ>w@LVhi##BoAOhW=2oO8W;{LwiOHRlSlW2)7X%-A`Oxnhr2W4I}?vuq|GRFWS?$4OTmZAvPk^YvI_?C@g66X3{SKre0>DrO%D`F&U4o@#;*xni|_IOZo=SI^Hk)CTYGb>W|nUKU|?TzW0;AnTFM4d z=1K3(@BdNA5fXlf+~iISG$(-3er*K_lj8g{wV`n_U@iSYg#plwPwM&ss@>laj$zFCM2Qh}16$c)ejhmR^l4wgf zrDln{R3+eHuxKs;Kk10#mWNFXm_^rrfQgx@KzeJaMphyDA*`2ud4Z0+w>EEr@Qkdi z3?8(}|6%M$K(`(*d%c|f>wi>Xk z@tiewd(-i;1``y^bo>_3G3v@_3n+^XXq_B282a{FivD0ASe<@%;B&z7zZ>X@P=73> zE=evT#qVf9XyGwP96<;;g*RgU3UVWN_3Fz+vR1S#DFApo0h*7;!>xy7gF#)?F|40|Zf>=dX$&gc z%IS4~BZ)V~L9H-z|I;K-$-S3=Bfuj;d@k-Zgt>H;FdYAZZOrFUmmll3eG$udmzi2@ z<^yfFRO)gumW2Bm_Hca!6C>joFCO&^5sHA0xPE5@)Jga9)-x=Bxo44~TD4YqTY}=r zz6~qnw#C{JUR;)B6m48f z<|Is96^lO&0mA3mYn#+$e`ymTgzM%kLyTgxiO$M+)=i3oaHfq0YPTMc8Nr^9xi}f| zkCxurEBA|Zj7f*#_4SV`k9dtlCr-UH6j4N3y}I(8grJtUv3wR9W7P zYU%c~{yWV(t5VUc(l)ovZ33KN0m}v0UQ(i2xvk|=LABGRyWU$uNgWetcL8S-!45_2 zBL+M?J#G&Am!i&uKrb4bC>P?wGkn%V2W=3mktamQF`2sq3n@S_yBn|rmaSZD6JdV+ zXNv0#hl^I`vW;{rQkiX@@qq4=AWD!?PIphh9kDMXY1E6ec;Qz6cBgX53tRa(=;wwf zZ3trvHR62`9W`|EQTD)t#1zWl!4lO|_Jj~3zP6tbLw1BwGnV^k+(*N4!TAjj_aUER@a$=Esp{4?nCCv_2d4=t!FlO7I{C{JZBymkmQEgbZvxla3^-+{+Oz&O6K#M!de+2m zW!(M@aFx zY%;;>(hOof*jZEn!Y`>~5M7)YbhAYd?+&(>wtoEM)MGC_^6#F@=oL!2n{6s|uA%wg zrLoa}xIc#@AbKsHBL$fpOhr%%TLFP{qK_kh{T77 zCX`xSRN1!ImaJ*Ls487q_F=r^19kffz~XM61q`U{bvEKT0JKM6S!265&?W9BSp4LY z5JOf5a#IwIyaFLF6MYa=D75c$JZ5A>T$Yd`QmR?^MO3b!o;B9j!ki>K(CuS2LV9|l zd(?Zz<`FCLr3Yxa77;rK?8Mv-ct}C@d|8f1F}KEE#0p)X4mvJ4Sj3j|axS;)U<^1i zA9#MzX_0g6X!yUg!<#1{Js@|F^w$=s$z3XG`%5L}tN5br)7EPg%|L6i3IGhiWx3!e z#02+)ZLidyd^loplS@zw9Xym#u7oM@^`tdo9ieA}7~Tol06G%KAP9U)tcL82*BK%% z`8}6r(2S+qUu-o23%t^u_c=?H8aSAurV1jWmbN&?~_jARTAr$q29f!PCF7LA^6n%P%ap=9g4B zvzj;bY9hkI1r_Bfl?Xy@=l6#uV@?Rlrwx(y_?rioA1(-=++T{6EW2)Y-zpdgmrSk zd$0glAx$GyW#0LMz@x59kKjl!5cO2N-%g8o>(2eUYD`sqpfqm+o%Ev{)GM3o8PpA6 zqobG2k1xwc;Z41Qgg|fEyHs`m^vP9c_JXq{`pOSIHFH7q6WY4>vn2k5TwbjvO(;Pnr8xn<8(q#I^nWzF zD48A?J&+OI@hVtOBGt18FqSF+rWsym)<2Cy&+IE>YZaS~s?uZXRDg*1^<)vZ%n z9&`w36mh-mTTD#L=+{RaEJssviYb*yctg6GjOWwsP%`%OGmNKsF*NXJc<=*wq|YqM z4*MVvwgPM6srfh>$ybBmn;UwfAYqa*U&cDGCKs{YJP?csJlAOY%J?<+`z*>5f z#b`1Uu=9@nYX*E9ww;du`^QyAwH$2e6d;CqAyGUnrG=kN2?q$7eiuBp6!^A zlm@u_%f9Q#*GZ8*4-2Yvcir^KvHz7^7|rPQ4nKq1`~)2s-h-Hz1rffmf)B*J)UI<0 zy5bLze6?=In4Kft!`jj&aw_(%MyHT_A2t&Mf3#Nxm)CVYKQpd;W|$R_4}C_nA6J8w zns=X9q~F}Qa{|?E^3w0IDOIxMlQCuHYfVegXI;W4L+l?J8I(m{*+Y1}K;B(T)NHdN z!l>>QFTjN}PXLf*Gtg~Ve5N`j5`TBS>wU>Qbltv&e%j+9OwJDK(L_y%mV&GSr9!h0 zF0l2%-87$HH`0;iF_)gnZf!+xi${ct^PQBx!gy+bwVT@MU8LP)g{8m z8!G=pfAS?o#JkioZJX-gKEQ2QEHh&@&X2xZHd2dhb>Ksh!LkT}g|wZmp=mJV->`l% z0t0~=K&r?Zcra`xPIv*V!cv%CKM#Li@^O@m2v-p&dZ^L^D?O|h11a>xATa1m%q7B3 zZvevj6v4-F5~)~k)j30cf9#}8Z*{?-kKxRi-sCk%y)YTMxs^z%A`>Kc^LTbU-DK{j zs@%DHH3Xc10(dIi=)p;*sf8ttbpe2`p4?zZVjy;oqc*FDD;97IphEw!=aM1|V5Ms| zaZ)ugP+n*0A)wSVdO44kT~qNaR!`nMk+?oQ2psx@UhCyxjGx9T=D7y3$lbS>i#LHv z@L&#b^C>5jJW*biL6D7fHgaU`+Dm)qj;6(R27<#z^YBB@;K4|rh@-Unhf)cpP5R~3x zHv%v4J?ge-e=Gll_Lyla1@bS0PjRri_kHTB%-h_jdSl{N9ImId;_nz^CYwZkt;85^ z37rmy_UK6`@JH}K4|oX}8D<8t85w#D<8KSGZjR8Mv25KMoif@{4@2}ZOt?LYX}O@+ ztA4dfV()*95;E#C@m;%hoSc4#ag4lNR0acEcP z+Jon*i`E)^U(d!gzPl1~ChLh?1Gsf^Ws~|Cs1NF@udmK8FK|l1Pt`=*{Vk6!^Qqo!U@SOof^n*_favzyRiBZsRFqP}G)i|_kmuHgh zIiHzcGv0gm_@*&~#{0qyWEQq0S$#9@=jdanN^9Q8lH}>{ zUpDR77E73BaQ8}$8qRrO9U$xI%26FcT?e+DeI_o688C%x6)=luVGP|Tt0-C;8+qWm zzs;h5pS|8Sw;qQiXy6yf$E@l$=j_WYuxz^@=MFj^xv=F(;26o zz@R>Y999m7$)t8=Rfb(7U*J-EbKD~BG$X<>w5BpXlf#{^=d|U?s(7yV@!cpPE~`}~ z9|V`1lHF#H*l#4r*+v7LR(9PH>eK6$!#B)l6>;#@T}P!Y2NkrxeGTer&f&k7e}DrB zfT+ZYU5ky5f9FPL)UFT`mV0NsPRbAoh3SQ0%oPkXP59r+8i1Dnm}`_aa@utOv<-p{ z*9R2!_OYIPUKc*joi-sk>WI79XSOd}e>rqf>`Ex)s4TNqTi*6VIXY04VW=?l?Grw{ z;C$4dlUzNT!(g=7*8dKW1Z`-zTzPY^%Cgrzp}n$Vx=N$|FTfSGIe3)r4-<7hN|h)#3|-YB_c%S|MfV@?{y+Z%Zq#y~=puMU+nGgj)f8m4ku& zNz$P~#!q@y>gneJSg{LU$c%nF@{d>$A?#NKnGpM+3?jrNG%gD00sGjE#j~{yZwKyZ zK~e=CivM@I_0J9a-Y4|vqx$-H-mGe#^)Rq^UQ!=7_l=Oc7j#ZrmEeC8+6RI?xX(Pn zIF05ohTIX_bRK)WhKRMJ^TG`Jj-1fc$lZ}T`C(T&uXNnWPjvIGy)*##79>*o3(X5n zL#k$jfTA#IoPCk>YWo>PSiSHx7JOm(vN-^Z;yw)>?LP}3KF_StRj!h+fRDo&dp($} zz>U~eh(~S}7YtSnoS_E;Q&c7$$+;O70kt5$@CY)I1UcLhk>*E@G2RQ5h4rJaj{*3r z2+Y|8le>8>ekXJA6V;2!jth)`VTp>1k3gwmoc@&^CXQt4J6B5MfSj*&6)1BQw!SBT zNg(&nrANZxNtoa#`S*Ps>haYfm_#&Y)uqR+Sn3R%t0WsorDn5>3r=XU(?NY4E+imK z{V5S3^bJy`CmqF~o#2b)6k^*v!K5PJef{h_m#&fI;M?I2kb!8Db70&X zcCiq8<-icg``4?Im+JyA<&W%>y}zFlGhWbr7sNo@TO8=UyR!7?40xVhz)bhdV&D=~ z;*2GyC#(RcaL(Ae4+&st?uZPyq_T_S=P!wndb~I|t`b)mr;o-;bFZC<&3fX`O6uWG zXKwALfBWnUi!K8gg$E4%JrvGaDuc`aTG7sS>Td+1+j&V1SlVXLT00E&al?tW5s!sR z$G2F+51($5tF`2LvXzv2Rt;~hPoSwb1X7M#Q<`O@sQ z-3U67aX%@)Dwm7pkZMQp8~8Kec7b-8#EG^#s%I^Q+S2F1%&-N~fc@z#rlg(b2|yid z^Yv^Qq7~OhYq$b()1zPPvcN;K$b!R#jOpN-t*J3tkt21EP68*f?~u#t^Ua=8Z2CM0 z1IWGy$lPMBToTMurPvJbU!(5e06#730Sqrylch zaQtG}C_i?H)cgS7jflVl4~2Wbqwo%~C=SLzg$oPGr_2P)el4r}y>Pk^x~O&E>u>GP z%Z)4LUE$-WV!NKJN$vv<)&^ovWX9owKqd|5doP|Aw6@=-pJxxHlu@4^84kKwe_&|n z44<|(9<@n=@LCzeYX#T%*6QWjS1_#D+F^0ioZaGYNxfEf;l3y5<3Npa3Yp1`GPWi1 zi{Lp>)BMC#3>PQYZ6Yn~6zP>p;PLk+OvvACJt9|w*wjcR19M3wQTPCh+JVoFs$#Cm z;(`KxSatAlYkSbT^3?-%kNxuH9JWoZTxxLi)(7c8HpFne9nUb z-_(TY0SnrPNbmu` zJa>loB)<)&aJQt^G*oNZrX`Q-+Bzl2fW-44=n@XGb|A_uFitTXJFGUgulkN1!>!zDj5zwDhA5-)t>e6piD6g$um8$?`Pqfv0Q4CMB=!Hqlg z84fkGbR<#53>spyXoZfHuc?e}W)gDqUe8$yd4^=wB$yVA7hg#RTrc}2c*((z2oZk{ z-9l;JdoK8{Z`=Cc`NAxUy&Ax59MobMx%2UAPiW+VTG~*<4*j^Y_oeUJY_TX6xf%C? zmvnXQ%67JRG}s(S4L%5?-0b&->tc+yau^}51YaI;AWJmGRI$A`DDIgV`#4TdIkjZN zGwFojOQtRqZg6R`Kbrfmq{+()`t&xr{KQm{d0sf%Y`rS|=6Ly1ec|g7!(?aGSw4js zQ`)Wl2R;0HpschFE)iux$Z0)kz5>_i{EJ@q2jagTK9`hkRGtT@F`Hdnw4I2x<4ILt zR)_Bb!H+{?qj5<*!}24@dXvXPjRs}fRz-hZ!Z0Dl*~>5%LiB>%b>)NYE=rJ9Tbr#~ zitzXrp(y?5vqopN@q9_i<;_>C-+J0BAHA)^2e!VKZFV$GCoIRZQchJ{)9O_jpN0JF z?#gVxbdT=9%KYze;pFlLc5AbIf86(0GUSVW+IV=r zc_nfzr-U55xaPrhFvl0}MF;+l#`EbQlFGg4*l@Jy=P$49xu7T)J1~AwHs%K6c21*7 zwK5N918H&c`FKQyzG)7I4N&1Ek9^*0kZxrXI4#lCwj1a!b&LdL2+Dza!Rk2-RIe(_ zb)PF@(9z$e(H^6sBkf%K9OZ;;>lk<3U17n7td*bo3?1c6<@cKGgS6Dmw6#ikMBKg; z^xQjmHs8u`oavphesw&orph*|9j8x;Vx#IE^K-ec^z7y}NUHoYFxqnh&C7I8nYIY= zNGm>WbwesjQk2iT(qB5^QOVJ=)oknbK5!;1W_$)pGrib}0DWUlD9Tm;e{aQU#1bL9 zklv?(nqC}$A7tM6{paLk!HNFt!P(y)xXc-xtsDo{rJg%;SJ(@1JrJ%ii|grtU(Km( z%9WWFqW=nYmi}9^uc3=J*V(uVcatC26xyXscDRz2fvAZ?|59X_)XiK#lv_|t=D#pAu2kI z1!~^Jc^rQ*qq@y^{=&qniELuy&3&Mxq=|u7(zLfRR7BdnI{f_~!T_9GQkY7Y%%v$E z;uU^GsfzQz6@CrR7g)P~_PRfHEq{fYU9{iao1%exu_OEp)UK?ND~QB+=&Z-1lC3(u z`k3r3u`9NEbvZyXzGN9mvCdLY^#4%N!DVcZ$6^Z`IF=KHFIa#5w3uOIt4lezTXOth z?qPk?gVyU{F>hRi%QWG5NXR?U+ja=lL{tTCBzObA@{d-=3Xh5#XxR|$mWs(j$y&%a zxTq4!FV1E{+Vvd%9EZ2MC;kEbZvS?#PvZJPg14c|8Kmw?a*IAwM|qxt*lOXpX$Z>z za3A#aNnVd0Y=KuUVZazv@$*f#;({_s34^)n7T)fVEmm}t_h)0`(3~;ZbpPDNb}1o8 zn?D&pTxwr*5YZVw65RR%4K+Y_-~Q^UD0Flb9@u(jU3JIz#_1(_+Pad>>+qqI5BAStfAG?Ej%Sd) zZGT1O^u!fPR>-Za2fW<7&3&mFxDPub@OqzU^-$9SALPS9AcJebO3w`G3L}D-a$?J< ziGwsQGMILY%jvnMd=7c@pk#Rr2$1yXXcg_a+YRyn#F^19UJ_4&Zh31yTol2yQe1!p zFs#S24a*;w!gTi9U3ktCr&A z1`sMo=6RoyZ8fZq`nKS3f8fS#+|lp#PDz^zbd+6%%6@7-9d5i%dGl8hAi+E#=CZD+X?HE1i_ibCJ%Bvm_2@@y9pAXME&yuQ za!2uv3#ON$2&r-*MIWxr%|-)y(vK@ddrn05ws?4l5dDny3y(&VB=O?yfzs@8;gbE_ zd;jy|5&ek%zLXCxBk%tnz#rF}(Q${JJ7MBYtgN2-#<2OjyCnVEyByPgBImCySHxYKz%NlL|;nw_}5;t($f!X#+&c96F2g? zFxgv9Nh%@9GIUq>E#o}SwNK(h92uxlhYuVoZ%m$Zlz@*MpsY)GVaE5q$T|YISlXR- za;5^{%yb>?Ha51so8olTZ;w{#S2FA&Qq6U>x#ECYVr8TCChqKZwhXf%es|k2gP(`P zV*B#@)V{l(3*#PZJD*-Vt7@*PsXT8dq#G3F{yKzDBX=*Bq8{blc!xfU*wNqL^T@sp zeVX%L@T=V|+5LZNy7IUrv+n=k)`mD`uDN7RPAZvcmiwAHX^Xj{p=fB#XeCPKf`a9w zQ zC>y)Y#tKoWV+AsyLlX*^AItR52W(qWa+V|~KIVCuL~yP`9q1^Kp3#u$l0<@(49>;csUR)d4$!LHuZGSIdc;dO-?f$Cfp&nT*I;$F;5Tn%5_K%cSwdD@V*^e;=JJ z>45{+pk`FY#QCQsCk}zhktDL@)|31*>)lumBY?Q2GAf|Yy%e@rRYNL8t%CDITGrtU=GDw( ztuIfc-UW-TX&oeYpm4=&vJyuSh&iI#3tB*;@{Qt*p4ZLmy@e}xXzX$dhKCh0o2;sfWqIxc|| zl+lm3xjJyR{bGl5ZeY6WT~(P;#+((9gPkNYgi{m_qg=ddTGBmB;cb*lL8}iP)#?CB z)1*Bg;KZ+9P8$op$Fc8E?R)CCr42jjl%3^75>?rkb=e)8E%qZ2?$yMi@^_@^y8Y}0 z?Egy|Y+YJZW+vyyDN{Q2CUS!Neq!!F-DOff@k;(SCmwkT46xd9NdvG`@}TTbOJ0D> zt#Hp(t-?!1{!!;D_!4o%yz8$fM16e5HEju)PVrR`fjXW@_}HZgAfza z1}qEEHQ`qdLrDDsdo<>DH3Tjf z$}9`z`ulEhKB6!>=v&#!pM1{yl_`BZPGkHulwHo z?hRVG@#~oGB3%v02mHfv8D~^jBV27pibg;)lA5G#8_&2vruVWFjHYQ1t?k+6ydx`3 z`!Avgb;3ySt!QNCv655lT+W7Im}E7V;73v=4EUQ@d5<9E1eeuhrBq|QDpip^?47BKXJI-$vKZ(O6l*O zm`uuJ6s<|)guL)QV_zl^0FqjVKbUi?#rVgO_??d)@bBBc1{?D=X__a!K&q=jy!)?z zd|PV{2U$td{~fe4gacQ?h9rZ+FTibw(^9T@xPfppk~%L1Zay9k$NJ0eO8pttws=Rr zKs1ah%{cIdHC_@9!Gi61LdqVnNPx0O)WR^sALbyZm0sRO9i9C^D5;QPFw-j&y+@m0 z1tI`A)qA6Ob(eEJxiT4{AfAz)*6P)}9@xAjd((~L6~1E>uMu!U9vY-$2M5%{b0vKe z-?L5Q2!-#~=cCKU7qN4ymYK&Ez#Fy2(1$KLJTZHMD3knpy))|Iinvd5>`4{B+#6gT ze5-_c0qhmwUBaJN^=)6Lj`G&WzGKdA@BlE7PO|-fI?0OEY?mkp3rTj2{C9r2kJnXQ zlhljMuT4BV9sCJw?elTUva7m@6!Q8wV0>|bG6Wzcw@i9A_#e$ik>H9h*W&lAV4|VC!n>!1Ktcm<9ronq> zN~ZUy+R!@DpU}S*;pfN%>DqYJ26rgO^~83odKwtKgJR>ebq?rY@6Z|>r zdq@PPz}%o}g!c2t;qbO}H5-4-U^j(FLWNJ2Nj>9_$9-Qu1Ittg4fnbYZW7(oJFq)B zpnOuRL+uf>>yNP~dFP~cJNch;Fgnsvu*kR!#nXr}NTBt#T6PTI%+sr5#7e4@DIeAKiCI5wf^@L6J&!`qRKvp~bOx zAM3C>#3nvrfxo>VG(^;vb-ikf;{s{54YNKhvo(`RTGq7kx*HU!z4|>%yKf*Fvu>dz zgaWe5g5~gvP&WI|(2;@HJ2L=hm=&kz3`7#(L%T@ z17q_8T8!1i^v+yn|FaS@mZy*C=D!C|Lk3gA zn_6*7_a(mz`oj!x)T08RXVml;fLy;7kse7syeN^!oRUQMWyewe6>Z0ao0W|K-5X{t zfn3})#sB0kfC-FO(IPoPbx`YUXxhZsAu+0XojApn3QkLhQ8oF$ooT-qnb!cU^Uaky z%p*2*ywcDJg&D!Ic3C4$-t0N*41H=!cI&K!H|;C4DM}us8^%MHmYtQ_Ho+7PmbE(M zFhZ%wP47X3sPvid!tKJ?WfWROWcYDttl_F|Io9-J=(TX#533Gk?3s%v&agWzSlV&d zH&~;o2WqQPhwaRyT&$4PRl%%0<*5!(^yuaq8J9@tZCJgmG;xV8&N}PPqcjqekds9` zefoAg`CA>f8(e>Otar%vlA+t&xTj?Q zOEUoD(*dj>xN-g9ZlPoEer{`Zn{@%tCbu^!BExk3-VbdvX%0vooFjHD_m!>cEk&wE z#tUmO%L%3COm}BT^+0Ye=@?-{e5a(6B-^ebYibrVrazKhjeI~M)rEksq~0NQcI3D_ z>vrh=lz9s8s|i<&dhjWbzna5Zp=5V;gY|}9kyJ+Pdj-m3Z1=tiTuUx9Nvkl{ET1*nRsUnuvKooZ92_8 zSDGEzyiNwYfxrsGzt9X2zA0+G>uG`%vxsY_Bd!$n9fm8sXem0$?j++DbT|Aergg!Z z+aSdwQGHXN8iS2ib^v9M%XewX-t5||1fGT|9~2V@FaPS#39QHmo%ga zKuWmzU5_62%3d=5$;vd69{yP|q1LTpvl-CooTZ>Bo=GMn|LLyBjGPKS2=aKkOj}RU zi@2r&%CR=ygMn1Qe;!r;oD*yp*|41?V$6POOg3BCRIbj`dCLjNJ8{P&Xe~1{C+Oat z)QaMp7f$n*ZV{o5e9~Y&8`kn71XSNgL1dFXZNX6;q8$)<;>QE);}yPdJu31oL9W<- z|B8HgwnIW+DIb)O-i7H!6mWk<$7dJy$BO;4%b%OW<4bdkU6liyL%c0pBCGto)O$

    K)*ur#NhhD`kksxP3VstmSgSc3WLQ-@47p; zzb=e3(hG{O4Y@UHo_-c7V&GmsxQN@>T&J7pfyfz0@b0UC%gs@%dj$Uw}6> zZ(tW*Oz=QuNFp6*aWS~WrHi4@DUMkqcXJ)0up7Vxe|)pw*F zf7aAI2!D!0%gIZrAiR-!p=uIs3}Ay zeDYMdw_mxzzc>wIQ*eWgz;XYc*C6h?rAE?fyoTakhz%o5u3%@~EV|;0_NwdJ|;GT`m{o99JOd`)uIP{zF;@HTj zT)Uzz4t!C-)D$V}ti+*3;AEw*4iLkz1McH+x#x-Z9oRlOmeiTfTCZZ$rB#V=eR!e( zFZ*jX9I^y-T`&{01qyg2Wj8Pw>gV4k?T2Fj`q0B6Vp`aSMM6j7vOU|ma-&K zp!}*R-L91w`9_YVkkOJtFZ2*B7S-%hm&4kvo^%^lTa?J|o)vTbW}jTu134PVQ|^E| zn`QCLICjwfX$blU|FWiR__(?aM?TP2WWJU_*KjZoDOYZ3t+z zCDK*tIvMRyq{!;P%rozFAKmS=sRx5U|Dq1KXqI!EBN_Xs8btq}vDy9Dg1Zyn$Sww^ zH?{9^yq(p1FP*Td2e$SAU|{^sP_-y*&!Jli_FGy^kfQhlGo}A?);tI|RR^|d=11Gi zl835Xl^53?c4Apa>-?uprOCT3e9K+x^P?hS$1Xll+mUu0+S`O&2?SeLbq`OW>BtR7 zoh_E`pz?CxZ^;=tuGf3{c~Cv@YtK(e#w2t%-mkjPJ#*e}_)k7ElMHKKeV7yg2X33sf5chKkLdYx|E|R^e&r@iJBG!p#b0Stv25_I03TbZ&BTtK0Fjl4 zhyEWKXYQ~uC-D!*{(Z}fV$>ww%{f=@qFkus5kNu!Tf@9ZSXkIw2NUK|-Ou>hPT&vS z0zycXR^{+hpUhnrHrdq#G-PjtXy|kI8t(fLDpR{`A3n^CP>XsPoMFN*AAbDDDY^lf z2fDfdFwKQJw)~G3A5YSBAEck%Xfpm*?(>(Yt|x6qXGL^NG3`J`jIJ-^2^ysvU6M9- zodD+OJK$4lbl-M3x8G)luek$=g^t@kFL7sfw0``mRVYu8KP11FTv=E-T>3AcoPiQF zWJn?XRsKo#;nD)*)T^%Fr~?KCoi-V9wcDGFhadfkq#HIe5x|iyYE9((q@f*)`x5r* zp%!=^Hs^1S?vC?&`PtMYByti6R@ zwxTl{I+|=$-1jV0N95x^PK`&d<2kvw_`4&Fz!RHpY~X-W81=tyriLGCHz-AA1Sw7H zgv8a>Hj0EC8XNSJqe$Hg{giA})c1#8b{xkak|a-Be?}pj1(^<4c&D2*v}BCchC|Gu z@=djim=EpJ#xaq<^EWItW`XqB_uEKQfD=krh;+Nc1bg+{F2R9<#<@_Le{Cwp5OeBf zR9NIv%?INRPH+`z0-_b-c12JJXl9514;Hd0!R(8^`i8O0c)tLx6Y*$*c=NHkC zup5xz^X%0*i?`G0j>}OVG(!Ku>7} ztXUrH`ZXe6MK~J{NqeQ4j6dgeh%YiS;38(uXPxCw#+4w&O?e}!5f8Ey0T}wzf(L)V zj~nMA71EICy%Dsc-#IOD%S?|@L0-6{I$(X*<)wbNrEhhgF6KWBht=}Voa^tqhH>Af zCgcmhdSqCEM0N=tH(53{`c`dmiainR$1ud_qOog7gkvr10_TgMpM&avrp=#ECWOW# zH>SZq2(q796jcmgPloYaFXq6KPV9ZPL95f-0l+)=KRR}E)wdkNjjc_*>GBh~xlKO> ze?q^o#wA-D0fZT6NTeo=TtTlY+fg-LuNIpe(+tMA5}o@LrVecFbarwZv00I_w8rly zLt_zx3&m3)&z2gu{9pvIcXyXa2QELP#v4LIBg(&tU>ErdfAh9Syi&4>x&TSr4P`Y( zkbdE*v?7*kEzH9$*n)>y6vu}1neg~}z}eOe*BDX!3r`tXn<4B3dqH_2=ywzWJc4Sp zCRxz$36y}fSO_?vD6DZW5lPR704b>w6$OpfLfKRO_}aE(z@uB8P8vxQZiMJOjDCtd zV9`7@sFw5+9P!S4?Bt|sLCJwK_c1~dfHO>^_47V@L4p#nVm`n5%tc?T*5kRP4oHKW z#jn=bqsKLXKf7{eIvO%PTC9V`!YxH$CboXHeyTbN1+W~IHUyic$>!$$9wXl6Oq(v~ z5YpE7-vH8jH91yT7&mt|Ibu|Q0&w<1!rGBGG2=Ud#GG(xTls)>Ry@r@sfA9J18QRX zRwu)4 zYi3P;wWHd}h1n`y`)Y6VkWV`T`%Hf$2cTo$bu|jo66C2rL+1`RuX7EV))96ZJAgaA zkt&RF#c^dILJJ<$^=m?8!<=T~Y=R(+%tens7B6pi!4X(C&*xA@G&E?i~aK03cVe zQ@X4W2v$Qr;a~>`oXH+T^jYy%fEq}RU6lo`(1FC826z}9l+x_I7!({x`$d*cD}@Eh zNC$W@R@UQWBf8ZVn_ffok|98aAit*^FSg?SWUi9FCSIMogUiK%J3m_yz{(X^A9}|g z+)!BiXK75OfN(jYIJ0{BCOERQ;F!v1_5faS814PT-h=L-kpzZn6YBz1p+6}htXUdc zL`Ar7!Vb|y__$@5ZpMZAM3uO_1igly{utmmWiPY_+xgn@;grziqr!m;nm=M^s+06r g@{&M+(;>0SSB1UxrOnm-0QfuUe&%?sYuJtd1I)L=^Z)<= literal 0 HcmV?d00001 diff --git a/skills/mercury-toolbox/references/command-catalog.md b/skills/mercury-toolbox/references/command-catalog.md new file mode 100644 index 0000000..7ffafc2 --- /dev/null +++ b/skills/mercury-toolbox/references/command-catalog.md @@ -0,0 +1,478 @@ +# Mercury Toolbox Command Catalog +Use this catalog when choosing among nearby Mercury commands. Keep output compact, prefer `--json` for machine handoff, prefer `--toon` for model-facing structured output, and use `toon` for external JSON producers. +## Code and Context +### `fileprobe` +Use: Classify files as text, binary, generated, minified, test, vendor, and more, including encoding, BOM, and newline diagnostics. Better than: blindly opening files when you need a fast read-worthiness, encoding, or newline check first. +Usage: `fileprobe [OPTIONS] [PATH...]` +Example: `fileprobe .\dist\bundle.min.js --json | ConvertFrom-Json` +Guided answer: Answer whether a path is text, binary, generated, minified, vendor, or worth opening. +Trust basis: Trust bounded sniffing, encoding, newline, and classification diagnostics. +Next actions: Use outline or chunkcat for readable text. | Use binmeta or stringscan for binary files. +TOON example: `fileprobe .\dist\bundle.min.js --toon` +### `outline` +Use: List top-level code or config structure. Better than: scrolling whole files when you only need the structure. +Usage: `outline [OPTIONS] ` +Example: `outline ` +Guided answer: Answer top-level structure of source or config files. +Trust basis: Trust parser-supported outlines and explicit unsupported-file diagnostics. +Next actions: Use defsnip for exact definitions. | Use refs or codeshape for broader symbol maps. +TOON example: `outline --toon ` +### `codeshape` +Use: Map a codebase into files plus AST-backed declarations and signatures from the shared `codeindex` engine. Better than: plain `tree` output when you need internal declarations and signatures without dumping full source files. +Usage: `codeshape [OPTIONS] [PATH...]` +Example: `codeshape --max-depth 1 --limit-per-file 8 . --json | ConvertFrom-Json` +Guided answer: Answer repository file/declaration shape with AST-backed summaries. +Trust basis: Trust codeindex parser results and skipped-file diagnostics. +Next actions: Use defsnip to extract definitions. | Use refs to find use sites. +TOON example: `codeshape --max-depth 1 --limit-per-file 8 . --toon` +### `refs` +Use: Find exact symbol references or caller definitions through the shared `codeindex` engine with text fallback. Better than: falling back to raw `rg` when you need exact use-sites or caller summaries for a known symbol. +Usage: `refs [OPTIONS] [PATH...] | refs [OPTIONS] --at [PATH...]` +Example: `refs helper ` +Guided answer: Answer symbol references or callers across source files. +Trust basis: Trust codeindex hits and caller grouping under the selected roots. +Next actions: Use hitsnip to expand hit context. | Use defsnip for definition bodies. +TOON example: `refs --toon helper ` +### `snip` +Use: Extract precise snippets from files or stdin. Better than: dumping whole files when you only need exact lines, matches, or symbols. +Usage: `snip [OPTIONS] [PATH...]` +Example: `snip --lines 16:27 ` +Guided answer: Answer exact bounded source/text snippets by line, match, or symbol. +Trust basis: Trust line numbers and bounded extraction; binary or missing paths are rejected. +Next actions: Use hitsnip after rg for multiple hits. | Use ctxpack to bundle snippets. +TOON example: `snip --toon --lines 16:27 ` +### `defsnip` +Use: Extract full AST-backed definitions by exact symbol name through the shared `codeindex` engine. Better than: line-number snippets when you need the full function, class, or method body even after nearby edits move the lines. +Usage: `defsnip [OPTIONS] [PATH...]` +Example: `defsnip build_report . --json | ConvertFrom-Json` +Guided answer: Answer full AST-backed definition blocks for symbols. +Trust basis: Trust codeindex definitions and language support; no match means broaden symbol/root. +Next actions: Use refs for callers/use sites. | Use ctxpack to package the definition with nearby context. +TOON example: `defsnip build_report . --toon` +### `ctxpack` +Use: Pack files, snippets, hits, diagnostics, or definition JSON into compact context blocks. Better than: hand-assembling ad-hoc prompt context from files, hits, diagnostics, and definitions. +Usage: `ctxpack [OPTIONS] [INPUT...]` +Example: `ctxpack ` +Guided answer: Answer compact prompt-ready context from files, snippets, hits, and diagnostics. +Trust basis: Trust included path and byte/line caps; review omitted/truncated sections for scope. +Next actions: Use fileprobe/codeshape before packing broad roots. | Use diagpick or hitsnip to feed focused evidence. +TOON example: `ctxpack --toon ` +### `chunkcat` +Use: Read large text files in bounded chunks, including quick tail-style log windows. Better than: huge file dumps when you need deterministic windows. +Usage: `chunkcat [OPTIONS] [PATH...]` +Example: `chunkcat .\BepInEx\LogOutput.log --max-lines 20 --tail` +Guided answer: Answer a bounded chunk or inventory of a large text file. +Trust basis: Trust chunk indices, line ranges, and binary detection. +Next actions: Use snip when you know the exact line range. | Use hitsnip to expand search hits into chunks. +TOON example: `chunkcat --toon .\BepInEx\LogOutput.log --max-lines 20 --tail` +### `hitsnip` +Use: Turn search hits into compact snippets or promote them to enclosing definitions. Better than: raw `rg` hit lists when you need merged readable context or the enclosing definition instead of a tiny window. +Usage: `hitsnip [OPTIONS] [HIT...]` +Example: `rg -nH "helper" | hitsnip --def` +Guided answer: Answer merged snippets around rg-style search hits. +Trust basis: Trust hit parsing and merge ranges; malformed hit lines are reported. +Next actions: Use rg -nH to feed hits. | Use defsnip when the hit is inside a known symbol. +TOON example: `rg -nH "helper" | hitsnip --toon --def` +### `diagpick` +Use: Extract compiler or runtime diagnostics from noisy logs, with optional snippets and enclosing definitions. Better than: reading full logs when you need the actionable errors first and want direct source or definition context. +Usage: `diagpick [OPTIONS] [PATH...]` +Example: `diagpick --def` +Guided answer: Answer actionable diagnostics extracted from compiler/build/log text. +Trust basis: Trust recognized diagnostic formats and preserved source context. +Next actions: Use snip on referenced files/lines. | Use runprobe to capture the failing command reproducibly. +TOON example: `diagpick --toon --def` +### `gitshape` +Use: Summarize Git status or diff shape into concise AI-friendly file, line, and touched-symbol reports. Better than: reading raw `git status` and diff noise when you first need compact status counters or changed-file summaries. +Usage: `gitshape [OPTIONS] [PATH] | gitshape [OPTIONS] status [PATH] | gitshape [OPTIONS] diff [REVISION_A] [REVISION_B]` +Example: `gitshape status --repo C:\src\repo --json | ConvertFrom-Json` +Guided answer: Answer git status, branch, diff, and repository change shape. +Trust basis: Trust git command output and explicit revision/path filters. +Next actions: Use reposhape to connect git changes to ecosystems. | Use ctxpack or hitsnip for changed-file handoff. +TOON example: `gitshape status --repo C:\src\repo --toon` +### `reposhape` +Use: Detect repository ecosystems, manifests, command entrypoints, and CI hints in one pass. Better than: manually browsing an unfamiliar repo when you first need the ecosystems, manifests, commands, and CI entrypoints. +Usage: `reposhape [OPTIONS] [PATH]` +Example: `reposhape . --json | ConvertFrom-Json` +Guided answer: Answer repository ecosystems, manifests, entrypoints, and project layout. +Trust basis: Trust manifest detection under depth/hidden filters and parse diagnostics. +Next actions: Use codeshape for source declarations. | Use dotnetshape/sqlshape/gitshape for ecosystem-specific detail. +TOON example: `reposhape . --toon` +### `dotnetshape` +Use: Inspect .csproj, Directory.Build.props/targets, Directory.Packages.props, project references, package versions, and .NET-specific project graph diagnostics. Better than: opening a .NET repo one file at a time when you first need project graph shape, shared MSBuild inheritance, package references, and build-shape warnings. +Usage: `dotnetshape [OPTIONS] [PATH]` +Example: `dotnetshape . --json | ConvertFrom-Json` +Guided answer: Answer .NET project graph, inherited MSBuild properties, package references, and diagnostics. +Trust basis: Trust static XML parsing without invoking MSBuild; generated/effective values are labeled. +Next actions: Use asmref diagnose on built assemblies. | Use config to inspect Directory.Build or props files directly. +TOON example: `dotnetshape . --toon` +## Data and Config +### `cjson` +Use: Compact JSON or JSONL before piping, diffing, or model input. Better than: pretty multi-line JSON when you need compact stable output and optional key sorting. +Usage: `cjson [OPTIONS] [PATH]` +Example: `cjson ` +Guided answer: Answer whether JSON/JSONL can be compacted cleanly and deterministically. +Trust basis: Trust the JSON parser result and document count; invalid JSON is reported before compaction. +Next actions: Run jsonshape on the compacted payload when you need schema shape. | Pipe to jq or toon for projection/model handoff. +TOON example: `cjson --toon ` +### `ison` +Use: Convert between JSON and ISON records for compact structured handoff. Better than: verbose JSON when a deterministic compact ISON representation is easier to scan or diff. +Usage: `ison [OPTIONS] [PATH]` +Example: `ison ` +Guided answer: Answer whether JSON can round-trip through compact ISON for handoff. +Trust basis: Trust successful parser/encoder output; use JSON mode when a tool must consume it next. +Next actions: Use isonl for line-oriented streams. | Use toon when the next consumer is an AI model. +TOON example: `ison --toon ` +### `isonl` +Use: Convert between JSONL and ISONL line records. Better than: manual JSONL splitting when line-oriented ISON records are better for streaming or model input. +Usage: `isonl [OPTIONS] [PATH]` +Example: `isonl --from jsonl --to isonl ` +Guided answer: Answer whether JSONL/ISONL records can be converted as a stream. +Trust basis: Trust per-line parser diagnostics and document counts; malformed lines stop the conversion. +Next actions: Pipe into jsonlgrep for filtering. | Use cjson or toon for downstream compact handoff. +TOON example: `isonl --toon --from jsonl --to isonl ` +### `zon` +Use: Convert JSON or JSONL to Zero Overhead Notation and back. Better than: hand-written compact table encodings when you need Zero Overhead Notation interop without Node tooling. +Usage: `zon [OPTIONS] [PATH]` +Example: `zon ` +Guided answer: Answer whether JSON/JSONL can convert to or from Zero Overhead Notation. +Trust basis: Trust parser diagnostics and wrapper metadata for document count and target format. +Next actions: Use jsonshape after decoding to validate shape. | Use toon for model-facing structured summaries. +TOON example: `zon --toon ` +### `tonl` +Use: Encode, decode, query, validate, index, stream-query, and ETL JSON-backed TONL data. Better than: one-off scripts for compact JSON ETL, validation, query, indexing, or streaming filters. +Usage: `tonl [OPTIONS] [PATH] | tonl index [OPTIONS] | tonl stream query [OPTIONS] [PATH]` +Example: `tonl query --where active=true ` +Guided answer: Answer filtered TONL records, validation status, indexes, or ETL conversion results. +Trust basis: Trust explicit validation/query diagnostics; streaming mode reports record-level failures. +Next actions: Use jsonlgrep for simple JSONL filtering before TONL conversion. | Use cjson or toon for compact handoff. +TOON example: `tonl query --toon --where active=true ` +### `jsonlgrep` +Use: Filter JSONL logs, single-line JSON arrays, or structured event streams by field. Better than: ad-hoc `jq` for simple field filters, projections, counts, and regex matches. +Usage: `jsonlgrep [OPTIONS] [PATH...]` +Example: `recent --root . --limit 20 --json | jsonlgrep 'path~=LogOutput' --pick modified_rfc3339,path --json | ConvertFrom-Json` +Guided answer: Answer which JSONL records match field, regex, or projection filters. +Trust basis: Trust parsed JSON records and explicit malformed-line diagnostics. +Next actions: Use jsonshape on matched records to inspect structure. | Use toon or cjson for compact AI handoff. +TOON example: `recent --root . --limit 20 --json | jsonlgrep 'path~=LogOutput' --pick modified_rfc3339,path --toon` +### `jsonshape` +Use: Summarize JSON or JSONL into path/type stats, or diff two shape reports. Better than: opening raw payloads when you first need the shape, key paths, or a schema diff. +Usage: `jsonshape [OPTIONS] [PATH...] | jsonshape [OPTIONS] diff ` +Example: `jsonshape diff before.json after.json --json | ConvertFrom-Json` +Guided answer: Answer the key paths, types, counts, and shape differences in JSON/JSONL. +Trust basis: Trust reported path/type counts when parsing succeeds; review diff diagnostics for drift. +Next actions: Use jsonlgrep to isolate records before reshaping. | Use cjson to normalize payloads before comparing. +TOON example: `jsonshape diff before.json after.json --toon` +### `mhash` +Use: Compute local file hashes across the OpenHashTab-class matrix, list algorithms, hash with `--all` or explicit multi-algorithm sets, produce JSONL manifests, verify manifests, or benchmark hash throughput. Better than: PowerShell Get-FileHash loops, GUI hash tabs, ad-hoc checksum scripts, or manual benchmark harnesses when you need OpenHashTab-class algorithm coverage, manifests, verification, JSON/JSONL, and throughput telemetry in one local CLI. +Usage: `mhash [OPTIONS] [PATH...] | mhash verify [OPTIONS] | mhash list-algorithms [--json] | mhash bench [OPTIONS] [PATH...]` +Example: `mhash --algorithm sha256,blake3-256,xxh3-128 .\dist\MercuryToolbox.zip --json | ConvertFrom-Json` +Guided answer: Answer file identity, checksum, manifest verification, or hash throughput questions. +Trust basis: Trust algorithm labels, manifest verification status, and per-file error rows. +Next actions: Use fileprobe first when the file type is unknown. | Use jsonlgrep or jq to filter manifest rows. +TOON example: `mhash --algorithm sha256,blake3-256,xxh3-128 .\dist\MercuryToolbox.zip --toon` +### `toon` +Use: Convert between JSON and TOON. Better than: verbose JSON when you want a denser structured text form. +Usage: `toon [OPTIONS] [PATH]` +Example: `'{"ok":true}' | toon` +Guided answer: Answer compact model-readable TOON from JSON/JSONL or decode TOON back. +Trust basis: Trust parser/encoder diagnostics and document wrapper metadata. +Next actions: Use native --toon on Mercury producers when available. | Use cjson/jsonshape before converting questionable JSON. +### `csvshape` +Use: Summarize large CSV or TSV files into compact schema/bounded stats, or diff two CSV shapes. Better than: opening huge CSV or TSV files when you first need the columns, rough types, blanks, example values, or a shape diff. +Usage: `csvshape [OPTIONS] [PATH...] | csvshape [OPTIONS] diff ` +Example: `csvshape diff before.csv after.csv --json | ConvertFrom-Json` +Guided answer: Answer CSV/TSV delimiter, columns, row counts, and schema differences. +Trust basis: Trust parsed rows under the detected or requested delimiter. +Next actions: Use jsonshape after converting CSV to JSON externally. | Use config or sqlshape when CSV describes configuration/data imports. +TOON example: `csvshape diff before.csv after.csv --toon` +### `sqliteshape` +Use: Inspect SQLite databases read-only with compact schema/table summaries, or diff two SQLite shapes. Better than: dropping into an interactive SQLite shell when you only need schema, sample rows, light table stats, or a shape diff. +Usage: `sqliteshape [OPTIONS] [PATH...] | sqliteshape [OPTIONS] diff ` +Example: `'C:\data\events.db' | sqliteshape --input-format lines --json | ConvertFrom-Json` +Guided answer: Answer SQLite tables, columns, indexes, triggers, and schema diffs. +Trust basis: Trust SQLite catalog queries against the selected database file. +Next actions: Use sqlshape for normalized cross-engine reports. | Use jsonshape/csvshape on exported data samples. +TOON example: `'C:\data\events.db' | sqliteshape --input-format lines --toon` +### `sqlshape` +Use: Inspect PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, or DuckDB schemas read-only with redacted connection sources and normalized cross-engine diffs. Better than: hand-writing per-engine catalog queries or exposing raw connection strings when you need a normalized cross-database schema report or diff. +Usage: `sqlshape [OPTIONS] --url | sqlshape [OPTIONS] diff --before-url --after-url ` +Example: `sqlshape --engine sqlite --url --json | ConvertFrom-Json` +Guided answer: Answer normalized SQL schema across SQLite, PostgreSQL, MySQL/MariaDB, SQL Server, or DuckDB. +Trust basis: Trust engine-specific introspection and redacted connection reporting. +Next actions: Use sqliteshape for SQLite-specific detail. | Use config to inspect connection/config files safely. +TOON example: `sqlshape --engine sqlite --url --toon` +### `config` +Use: Read, inspect, list, set, or delete config values in JSON, YAML, TOML, env, and INI files. Better than: fighting `jq` or hand-editing config files when you need safe slash-pointer reads or edits across common formats. +Usage: `config [OPTIONS] [PATH] | config [OPTIONS] [POINTER] [VALUE]` +Example: `config get .\package.json /version` +Guided answer: Answer get/set/delete operations over JSON/YAML/TOML/env/INI config paths. +Trust basis: Trust parser-specific diagnostics and structured edit reports. +Next actions: Use fileprobe before editing unknown config files. | Use jsonshape after JSON config changes. +TOON example: `config --toon get .\package.json /version` +## Logs, Process, and Waiting +### `logshape` +Use: Group recurring log lines into templates. Better than: reading repetitive logs line by line when you need the dominant patterns. +Usage: `logshape [OPTIONS] [PATH...]` +Example: `logshape ` +Guided answer: Answer repeated log templates, incidents, and message clusters. +Trust basis: Trust grouping counts and examples; rare lines may stay as individual templates. +Next actions: Use diagpick for actionable errors. | Use jsonlgrep when logs are structured JSONL. +TOON example: `logshape --toon ` +### `envdiff` +Use: Snapshot, diff, or run commands while reporting environment-variable changes. Better than: hand-comparing environment variables before and after a script, especially around PATH pollution and batch wrappers. +Usage: `envdiff [OPTIONS] run --shell cmd|pwsh|raw -- ` +Example: `envdiff run --shell cmd -- .\scripts\set-env.cmd` +Guided answer: Answer environment variable changes before/after a command or between snapshots. +Trust basis: Trust captured before/after maps and PATH segment deltas. +Next actions: Use pathshadow after PATH changes. | Use sysshape for broader shell/tool inventory. +TOON example: `envdiff --toon run --shell cmd -- .\scripts\set-env.cmd` +### `proctree` +Use: Inspect whole-system, rooted, matched, or command-run Windows process trees in AI-friendly form; use --match to filter by image name or command line. Better than: flat process lists when you need parent-child context, short command lines, and lingering orphan hints. +Usage: `proctree [OPTIONS] system|root |run -- ` +Example: `proctree system --match 'pwsh|Mercury' --include-cmdline` +Guided answer: Answer process trees, rooted processes, and command ancestry. +Trust basis: Trust OS process snapshots at capture time; short-lived processes may disappear. +Next actions: Use portunlock for port owners. | Use runprobe to capture command execution shape. +TOON example: `proctree --toon system --match 'pwsh|Mercury' --include-cmdline` +### `sysshape` +Use: Capture a compact local system and tool inventory for AI-friendly triage, including shell hints, path-like duplication, and probe status. Better than: asking users ad-hoc setup questions or checking versions one tool at a time when you need a compact local machine inventory first. +Usage: `sysshape [OPTIONS]` +Example: `sysshape --json --env safe --group shell | ConvertFrom-Json` +Guided answer: Answer machine, shell, environment, and tool inventory shape. +Trust basis: Trust detected command paths/versions and grouped environment metadata. +Next actions: Use pathshadow for a specific executable winner. | Use envdiff around commands that mutate the environment. +TOON example: `sysshape --toon --env safe --group shell` +### `runprobe` +Use: Run one command and capture exit code, duration, timeout status, and bounded output tails. Better than: reading fragile shell text when what you really need is the stable result object for one command run. +Usage: `runprobe [OPTIONS] -- ` +Example: `runprobe --json --shell pwsh -- '& { Write-Error boom; exit 9 }' | ConvertFrom-Json` +Guided answer: Answer command exit code, duration, stdout/stderr tails, and timeout behavior. +Trust basis: Trust captured process result and bounded output tails. +Next actions: Use diagpick on captured stderr/stdout. | Use envdiff run when environment mutation matters. +TOON example: `runprobe --toon --shell pwsh -- '& { Write-Error boom; exit 9 }'` +### `await` +Use: Wait for a path, TCP target, HTTP endpoint, or command condition with bounded polling. Better than: writing ad-hoc polling loops for files, ports, HTTP endpoints, or repeat-until-success commands. +Usage: `await [OPTIONS] path [--state exists|missing] | await [OPTIONS] port | await [OPTIONS] http [--status ] | await [OPTIONS] run [--shell ] [--cwd ] [--exit-code ] [--tail-bytes ] -- ` +Example: `await path .\target\ready.flag` +Guided answer: Answer whether a path, port, HTTP endpoint, or command became ready before timeout. +Trust basis: Trust polling attempts, final state, and timeout status. +Next actions: Use portping for one-shot endpoint detail. | Use runprobe for command readiness probes. +TOON example: `await --toon path .\target\ready.flag` +### `argv` +Use: Quote argv for `pwsh` or `cmd`, or inspect what a shell actually passed to a native process. Better than: guessing how PowerShell or cmd really passed arguments to a native process. +Usage: `argv quote --shell [VALUE...] | argv inspect [--json] --shell [VALUE...]` +Example: `'["tool.exe","two words"]' | argv quote --shell cmd` +Guided answer: Answer how arguments should be quoted or how a shell actually passes them. +Trust basis: Trust helper round-trips for the selected shell/platform. +Next actions: Use runprobe to execute the quoted command. | Use msudo status before privileged shell wrapping. +TOON example: `'["tool.exe","two words"]' | argv --toon quote --shell cmd` +### `recent` +Use: List recently changed files or directories. Better than: manual directory scans when you just need the freshest files and `.gitignore` awareness. +Usage: `recent [OPTIONS]` +Example: `recent --root . --since 2h --ext rs --name '^(lib|main)$'` +Guided answer: Answer which files or directories changed recently. +Trust basis: Trust timestamp filters and gitignore-aware discovery within the selected root. +Next actions: Pipe paths into fileprobe or outline. | Use hitsnip or ctxpack after narrowing files. +TOON example: `recent --toon --root . --since 2h --ext rs --name '^(lib|main)$'` +### `pathshadow` +Use: Show which executable wins and which ones are shadowed. Better than: guessing PATH order when the wrong executable is being picked. +Usage: `pathshadow [OPTIONS] [COMMAND...]` +Example: `pathshadow python npm --shell powershell --summary --json | ConvertFrom-Json` +Guided answer: Answer which executable wins on PATH and which candidates are shadowed. +Trust basis: Trust resolved filesystem candidates and shell-aware summary rows. +Next actions: Use sysshape --group shell for broader environment context. | Use argv quote when command invocation quoting is suspicious. +TOON example: `pathshadow python npm --shell powershell --summary --toon` +## Network and Locks +### `portping` +Use: Probe ports or health endpoints with timings. Better than: jumping between multiple network tools for quick TCP and HTTP reachability checks. +Usage: `portping [OPTIONS] [TARGET...]` +Example: `'https://example.com/health' | portping --json | ConvertFrom-Json` +Guided answer: Answer whether a TCP/HTTP/HTTPS endpoint is reachable and how long it took. +Trust basis: Trust direct connection/status timing; failures distinguish DNS, connect, and HTTP states. +Next actions: Use portunlock when a local port is unexpectedly occupied. | Use await port/http to wait for readiness. +TOON example: `'https://example.com/health' | portping --toon` +### `portunlock` +Use: Show and free local TCP or UDP port owners on Windows. Better than: guessing which process owns a port or hand-writing `netstat` and kill loops when a local listener refuses to go away. +Usage: `portunlock [OPTIONS] who | portunlock [OPTIONS] free ` +Example: `portunlock who --json --protocol any 3000 8080 | ConvertFrom-Json` +Guided answer: Answer which process owns a local TCP/UDP port and optionally free it. +Trust basis: Trust OS port snapshots and post-action verification rows. +Next actions: Use proctree on the owning PID before freeing. | Use portping after freeing or restarting a listener. +TOON example: `portunlock who --toon --protocol any 3000 8080` +### `msudo` +Use: Top-level high-risk command: inspect Windows elevation relay status, token state, active session state, and host availability before any privileged launch. Better than: guessing whether Windows elevation relay support is available, or hand-writing `Start-Process -Verb RunAs` wrappers when you first need the current privilege status. +Usage: `msudo [OPTIONS] [--] | msudo run [OPTIONS] [--] | msudo status [OPTIONS] | msudo [OPTIONS] --shell ` +Example: `msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated` +Guided answer: Answer Windows elevation, token, relay, and launch-shape questions. +Trust basis: Trust status discovery before launch; treat run actions as high risk and explicit. +Next actions: Start with msudo status --json. | Use argv quote before privileged shell wrapping. +TOON example: `msudo status --toon` +### `unlock` +Use: Identify file lockers and perform unlock-aware file actions on Windows. Better than: ad-hoc PowerShell probing when a file is busy, locked, or refuses delete, move, rename, or copy. +Usage: `unlock [OPTIONS] who | unlock [OPTIONS] free | unlock [OPTIONS] move | unlock [OPTIONS] rename | unlock [OPTIONS] delete | unlock [OPTIONS] copy ` +Example: `unlock who .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json | Select-Object -ExpandProperty results` +Guided answer: Answer which processes lock files and perform unlock-aware copy/move/delete actions. +Trust basis: Trust restart-manager/handle-scan evidence and post-action filesystem checks. +Next actions: Use proctree on blocker PIDs. | Use recent after cleanup to confirm generated artifacts. +TOON example: `unlock who .\target\debug\jsonlgrep.exe --toon` +## Managed, Unity, and Binary Inspection +### `asmtype` +Use: List managed assembly types and filter them by name, namespace, base type, interface, or matching members, with optional user-code-only screening. Better than: manual reflection or ad-hoc PowerShell when you first need the type map from a managed assembly. +Usage: `asmtype [OPTIONS] [ASSEMBLY...]` +Example: `asmtype --with-member-match 'Build|Launch' --show-matched-members --pick full_name,base_type,matched_members --json | ConvertFrom-Json` +Guided answer: Answer which managed types match name, inheritance, interface, or member intent. +Trust basis: Trust metadata enumeration from the target assembly; broaden filters when no matches return. +Next actions: Pipe JSONL into asmmember. | Use asmflow find/xref when behavior matters. +TOON example: `asmtype --with-member-match 'Build|Launch' --show-matched-members --pick full_name,base_type,matched_members --toon` +### `asmmember` +Use: Inspect managed type members with binding filters and compact signatures, including JSONL handoff from `asmtype` and user-code-only filtering. Better than: long reflection scripts when you need methods, fields, and properties from a known managed type. +Usage: `asmmember [OPTIONS] ` +Example: `asmtype --intent unity-spacecraft-workflow --json | asmmember --assembly --input-format jsonl --intent unity-spacecraft-workflow --pick type_name,name,visibility,signature --json | ConvertFrom-Json` +Guided answer: Answer which methods, fields, and properties exist on selected managed types. +Trust basis: Trust assembly metadata and binding filters; use non-public filters intentionally. +Next actions: Use asmflow body for IL details. | Use asmapi diff when comparing versions. +TOON example: `asmtype --intent unity-spacecraft-workflow --json | asmmember --assembly --input-format jsonl --intent unity-spacecraft-workflow --pick type_name,name,visibility,signature --toon` +### `asmref` +Use: Inspect managed assembly references, check whether they resolve from local directories, or run `diagnose` to report closure risks such as missing references, same-name version/token conflicts, winning DLLs, test-only leaks, and obvious MissingMethod/TypeLoad hazards. Better than: guessing missing managed dependencies when you need a fast reference, resolution, or Unity/Rocket plugin closure diagnosis pass. +Usage: `asmref [OPTIONS] [ASSEMBLY...] | asmref diagnose [OPTIONS] [ASSEMBLY...]` +Example: `asmref diagnose --resolve-dir --format toon` +Guided answer: Answer managed references, resolution status, and dependency closure risks. +Trust basis: Trust resolved candidates and risk tiers; explicit resolve dirs define the runtime universe. +Next actions: Run asmref diagnose for closure risks. | Use asmapi diff on risky version changes. +TOON example: `asmref --toon diagnose --resolve-dir --format toon` +### `asmapi` +Use: Compare two managed assemblies for added or removed public types, removed methods, signature changes, and MissingMethodException risks, with optional internal/all visibility. Better than: manual reflection diffs or brittle spreadsheet comparisons when you need to know what managed API changed between two DLL versions. +Usage: `asmapi [OPTIONS] [ARGS...]` +Example: `asmapi diff .\old\0Harmony.dll .\new\0Harmony.dll --json | ConvertFrom-Json` +Guided answer: Answer public API differences between managed assemblies. +Trust basis: Trust metadata comparison under the chosen visibility scope. +Next actions: Use asmref diagnose to connect API drift to dependency closure. | Use asmmember on removed or changed types. +TOON example: `asmapi diff .\old\0Harmony.dll .\new\0Harmony.dll --toon` +### `asmflow` +Use: Inspect managed method bodies, IL summaries, and cross-references for Unity and general .NET reverse-engineering flows. Better than: ad-hoc IL dump scripts or manual dnSpy browsing when you need callers, callees, field access, or string literals from a managed method quickly. +Usage: `asmflow [OPTIONS] [ARGS...]` +Example: `asmflow xref --assembly 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' --json | ConvertFrom-Json` +Guided answer: Answer managed IL bodies, callers, callees, field access, and string references. +Trust basis: Trust method-body metadata when the target method resolves; unknown bodies are reported. +Next actions: Use asmtype/asmmember to find exact targets. | Use ctxpack to hand off IL evidence. +TOON example: `asmflow xref --assembly 'Game.UI.Windows.Windows.SpaceCraftConstructionWindow::StartProject' --toon` +### `llvmobjdump` +Use: Inspect object sections, symbol labels, and disassembly through LLVM backend discovery. Better than: raw llvm-objdump text when you need compact section, symbol, and disassembly shape for COFF/PE or object files. +Usage: `llvmobjdump [OPTIONS] [PATH...]` +Example: `llvmobjdump .\target\release-fast\binmeta.exe --json | ConvertFrom-Json` +Guided answer: Answer object sections, symbols, and disassembly shape through LLVM. +Trust basis: Trust LLVM backend output plus parser warnings/raw blocks for unsupported regions. +Next actions: Use llvmreadobj for headers/imports/debug hints. | Use pecalls or pesig for PE-focused call/function triage. +TOON example: `llvmobjdump .\target\release-fast\binmeta.exe --toon` +### `llvmreadobj` +Use: Inspect COFF/PE headers, sections, imports, exports, and debug hints through LLVM backend discovery. Better than: heavy binary viewers or raw llvm-readobj output when you need a bounded COFF/PE header, import/export, and debug/PDB summary. +Usage: `llvmreadobj [OPTIONS] [PATH...]` +Example: `llvmreadobj .\target\release-fast\binmeta.exe --json | ConvertFrom-Json` +Guided answer: Answer COFF/PE headers, sections, imports, exports, and debug hints through LLVM. +Trust basis: Trust LLVM backend exit status and parsed blocks; raw blocks preserve unparsed details. +Next actions: Use peimports/peexports for PE-focused grouping. | Use llvmobjdump when code layout or disassembly is needed. +TOON example: `llvmreadobj .\target\release-fast\binmeta.exe --toon` +### `llvmnm` +Use: List and classify object symbols through LLVM nm output. Better than: raw symbol-table dumps when you need stable symbol rows with kind, address, size, object/archive prefix, and source-line hints. +Usage: `llvmnm [OPTIONS] [PATH...]` +Example: `llvmnm .\target\release-fast\binmeta.exe --defined-only --json | ConvertFrom-Json` +Guided answer: Answer defined/undefined symbols with stable LLVM nm rows. +Trust basis: Trust POSIX-format symbol parsing and backend stderr tails. +Next actions: Use llvmobjdump for disassembly around symbols. | Use llvmreadobj for object headers and debug hints. +TOON example: `llvmnm .\target\release-fast\binmeta.exe --defined-only --toon` +### `peexports` +Use: Inspect export surfaces, spot forwarded symbols, filter by name or ordinal, and use `report_quality`/`next_actions` to decide the next PE pass. Better than: manual PE export-table browsing when you want names, ordinals, RVA/VA, forwarders, and guided follow-up commands without opening a heavy PE debugger. +Usage: `peexports [OPTIONS] [PATH...]` +Example: `peexports C:\Windows\System32\kernel32.dll --forwarders-only --json | ConvertFrom-Json` +Guided answer: Answer PE export names, ordinals, RVAs/VAs, forwarders, and entrypoint hints. +Trust basis: Trust PE export table parsing plus report_quality evidence and limitations. +Next actions: Use peimports to inspect dependencies. | Use llvmreadobj --exports to cross-check LLVM output. +TOON example: `peexports C:\Windows\System32\kernel32.dll --forwarders-only --toon` +### `peimports` +Use: Inspect imports by library, name, or category, quickly separate filesystem, registry, device I/O, NT, and network APIs, then follow emitted `next_actions`. Better than: ad-hoc import-table triage when you need API families grouped into human-friendly categories with immediate callsite and role follow-ups. +Usage: `peimports [OPTIONS] [PATH...]` +Example: `peimports .\target\release-fast\portping.exe --category device_io --json | ConvertFrom-Json` +Guided answer: Answer imported APIs grouped by library and API category. +Trust basis: Trust PE import table parsing plus report_quality evidence and limitations. +Next actions: Use pecalls on interesting categories. | Use drvshape for driver-like/native profiles. +TOON example: `peimports .\target\release-fast\portping.exe --category device_io --toon` +### `pecalls` +Use: Extract callsites for one or more imported APIs or API categories from LLVM objdump text and inspect `report_quality.limitations` before treating it as exhaustive. Better than: guessing import callsites from raw disassembly when you want direct IAT matches plus trust metadata around best-effort disassembly parsing. +Usage: `pecalls [OPTIONS] [PATH...]` +Example: `pecalls .\target\release-fast\portping.exe --api WSAStartup --json | ConvertFrom-Json` +Guided answer: Answer direct imported API callsites found through LLVM disassembly. +Trust basis: Trust direct IAT-target matches; report_quality limitations mark disassembly truncation or gaps. +Next actions: Use peimports to choose categories. | Use pesig to place callsites inside functions. +TOON example: `pecalls .\target\release-fast\portping.exe --api WSAStartup --toon` +### `pesig` +Use: Infer function boundaries, source hints, and calling conventions from PE text and metadata, then pivot to imports, callsites, or raw objdump via `next_actions`. Better than: manual function-boundary and calling-convention inference when you need a quick v1 signal with explicit confidence and next-step context. +Usage: `pesig [OPTIONS] [PATH...]` +Example: `pesig .\target\release-fast\portping.exe --min-confidence medium --json | ConvertFrom-Json` +Guided answer: Answer likely PE function boundaries and calling-convention hints. +Trust basis: Trust confidence/evidence per function and report_quality for parse completeness. +Next actions: Use pecalls to connect functions to APIs. | Use llvmobjdump for raw disassembly context. +TOON example: `pesig .\target\release-fast\portping.exe --min-confidence medium --toon` +### `pestrrefs` +Use: Scan ASCII or UTF-16LE strings, report direct references from LLVM disassembly, and use `next_actions` to connect strings to imports, callsites, or IOCTL constants. Better than: opening a disassembler and a string viewer separately when you need string hits with direct xrefs and a clear next triage command. +Usage: `pestrrefs [OPTIONS] [PATH...]` +Example: `pestrrefs .\target\release-fast\portping.exe --contains DeviceIoControl --json | ConvertFrom-Json` +Guided answer: Answer strings and direct code references in PE files. +Trust basis: Trust direct VA/range xrefs; unresolved counts are not guessed. +Next actions: Use peimports to connect strings to APIs. | Use ioctlscan for device-control constants. +TOON example: `pestrrefs .\target\release-fast\portping.exe --contains DeviceIoControl --toon` +### `drvshape` +Use: Summarize likely WDM, KMDF, NDIS, minifilter, or native driver traits, surface heuristic driver hints, and treat `report_quality` as the trust boundary. Better than: guessing the driver role from imports and exports when you want a compact Windows driver profile with IOCTL/import/callsite follow-ups already suggested. +Usage: `drvshape [OPTIONS] [PATH...]` +Example: `drvshape C:\Windows\System32\drivers\ndis.sys --json | ConvertFrom-Json` +Guided answer: Answer likely Windows driver role, subsystem, imports, exports, and heuristic hints. +Trust basis: Trust role hints as heuristics, not vulnerability conclusions; report_quality shows evidence. +Next actions: Use ioctlscan to decode CTL_CODE candidates. | Use pecalls --category device_io for dispatch/callsite evidence. +TOON example: `drvshape C:\Windows\System32\drivers\ndis.sys --toon` +### `ioctlscan` +Use: Scan PE bytes and disassembly-adjacent data for likely IOCTL constants, decode their fields, and use `report_quality` to separate strong hits from heuristic candidates. Better than: searching for Windows IOCTL constants by hand when you want decoded CTL_CODE fields, code locations, and suggested role/callsite/string follow-ups. +Usage: `ioctlscan [OPTIONS] [PATH...]` +Example: `ioctlscan C:\Windows\System32\drivers\ndis.sys --json | ConvertFrom-Json` +Guided answer: Answer probable IOCTL constants and decoded CTL_CODE fields. +Trust basis: Trust confidence/evidence per candidate; raw byte hits are heuristic until tied to code context. +Next actions: Use drvshape to confirm driver role. | Use pestrrefs or pecalls for surrounding evidence. +TOON example: `ioctlscan C:\Windows\System32\drivers\ndis.sys --toon` +### `unityasset` +Use: Inspect Unity serialized assets, bundles, and YAML objects, then trace refs, dump fields, or extract payloads such as TextAsset, MonoScript, AudioClip, and VideoClip. Better than: opening a heavy Unity GUI tool when you first need an index, object refs, field dump, or a quick extraction path for assets, scripts, audio, or video. +Usage: `unityasset [OPTIONS] index [PATH...] | unityasset [OPTIONS] refs [PATH...] | unityasset [OPTIONS] dump [PATH...] | unityasset [OPTIONS] extract [PATH...] [--output-dir

    ]` +Example: `unityasset index 'C:\game\Game_Data' --class MonoBehaviour --script 'MarketOffer|LaunchVehicle'` +Guided answer: Answer Unity asset indexes, object dumps, references, and extraction writes. +Trust basis: Trust parsed serialized files/bundles and explicit unresolved reference rows. +Next actions: Use unitydiag for runtime/log symptoms. | Use asmtype/asmflow for managed script assemblies. +TOON example: `unityasset --toon index 'C:\game\Game_Data' --class MonoBehaviour --script 'MarketOffer|LaunchVehicle'` +### `unityprobe` +Use: Install and query a read-only BepInEx bridge for live scenes, objects, and static state in a running Mono Unity game on Windows. Better than: hand-writing BepInEx reflection plugins or one-off named-pipe helpers when you only need a read-only runtime snapshot from a Mono Unity game. +Usage: `unityprobe [OPTIONS] [ARGS...]` +Example: `unityprobe --json scenes | ConvertFrom-Json` +Guided answer: Answer read-only Unity runtime bridge status, object search, and inspection. +Trust basis: Trust explicit bridge install/status and Windows named-pipe reachability. +Next actions: Use unitydiag when runtime logs explain bridge issues. | Use unityasset for offline assets. +TOON example: `unityprobe --toon scenes` +### `unitydiag` +Use: Summarize Unity Player.log and BepInEx logs into grouped incidents, normalized messages, and domain-aware failure summaries. Better than: reading raw Player.log and BepInEx logs end to end when you need the repeated incidents, top frames, and mod-vs-framework blame split first. +Usage: `unitydiag [OPTIONS] [PATH...]` +Example: `unitydiag --game-root 'C:\game' --json | ConvertFrom-Json` +Guided answer: Answer Unity/BepInEx log incidents, grouped frames, and mod/runtime warnings. +Trust basis: Trust discovered log paths, grouping counts, and preserved stack snippets. +Next actions: Use asmref diagnose for plugin dependency issues. | Use unityprobe when live object state is needed. +TOON example: `unitydiag --game-root 'C:\game' --toon` +### `binmeta` +Use: Inspect file metadata, Windows PE basics, version/company/product identity, embedded Authenticode certificate tables, WinTrust status, catalog signing, signer certificate subjects, chains, timestamps, and expiry hints. Better than: opening heavy PE tooling or shelling out to signature utilities when you first need binary identity, PE basics, version resources, and Windows signing status. +Usage: `binmeta [OPTIONS] [PATH...]` +Example: `binmeta .\target\debug\jsonlgrep.exe --json | ConvertFrom-Json` +Guided answer: Answer file identity, PE basics, version resource fields, company/product names, signature status, embedded-vs-catalog signing, signer subject, certificate chain, timestamp, and expiry fields before deeper binary analysis. +Trust basis: Trust goblin PE/resource parsing for local structure and Windows WinTrust/catalog status on Windows; signature failures are reported as metadata, not command failures. On non-Windows, trust only embedded certificate table presence and treat WinTrust fields as unsupported. +Next actions: Use peimports or llvmreadobj for deeper PE detail after identity is known. | Use drvshape or ioctlscan for signed drivers and native/system binaries. | Use stringscan or pestrrefs when identity/version metadata is missing or suspicious. +TOON example: `binmeta .\target\debug\jsonlgrep.exe --toon` +### `stringscan` +Use: Extract and classify high-signal strings from binaries or blobs. Better than: a full reverse-engineering pass when you first need identity clues from strings. +Usage: `stringscan [OPTIONS] [PATH...]` +Example: `stringscan ` +Guided answer: Answer strings and classified hints from arbitrary blobs. +Trust basis: Trust extracted string offsets and classification labels; encoding limits are explicit. +Next actions: Use binmeta first for binary identity. | Use pestrrefs for PE strings with xrefs. +TOON example: `stringscan --toon ` diff --git a/support/unityprobe-bridge/MercuryUnityProbePlugin.cs b/support/unityprobe-bridge/MercuryUnityProbePlugin.cs new file mode 100644 index 0000000..7a0437f --- /dev/null +++ b/support/unityprobe-bridge/MercuryUnityProbePlugin.cs @@ -0,0 +1,872 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Reflection; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Threading; +using BepInEx; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Mercury.UnityProbe +{ + [BepInPlugin(MercuryUnityProbeBuild.PluginGuid, MercuryUnityProbeBuild.PluginName, MercuryUnityProbeBuild.PluginVersion)] + public sealed class MercuryUnityProbePlugin : BaseUnityPlugin + { + private Thread pipeThread; + private NamedPipeServerStream activeServer; + private SynchronizationContext unityContext; + private int mainThreadId; + private volatile bool stopRequested; + + private void Awake() + { + unityContext = SynchronizationContext.Current; + mainThreadId = Thread.CurrentThread.ManagedThreadId; + pipeThread = new Thread(PipeServerMain); + pipeThread.Name = "MercuryUnityProbePipe"; + pipeThread.IsBackground = true; + pipeThread.Start(); + Logger.LogInfo("Mercury Unity Probe pipe server thread started."); + Logger.LogInfo("Mercury Unity Probe ready on pipe " + MercuryUnityProbeBuild.PipeName + "."); + } + + private void OnDestroy() + { + Logger.LogInfo("Mercury Unity Probe BaseUnityPlugin OnDestroy fired; keeping pipe backend alive."); + } + + private void OnApplicationQuit() + { + stopRequested = true; + Logger.LogInfo("Mercury Unity Probe stopping pipe backend for application quit."); + TryDisposeActiveServer(); + } + + private void PipeServerMain() + { + while (!stopRequested) + { + NamedPipeServerStream server = null; + try + { + server = CreatePipeServer(); + activeServer = server; + server.WaitForConnection(); + ServeConnection(server); + } + catch (ObjectDisposedException) + { + if (!stopRequested) + { + Logger.LogWarning("Named pipe server disposed unexpectedly."); + } + } + catch (Exception ex) + { + if (!stopRequested) + { + Logger.LogError("Named pipe server failed: " + ex); + } + } + finally + { + TryDisposeServer(server); + + if (ReferenceEquals(activeServer, server)) + { + activeServer = null; + } + } + } + } + + private static NamedPipeServerStream CreatePipeServer() + { + PipeSecurity pipeSecurity = CreateCurrentUserPipeSecurity(); + return new NamedPipeServerStream( + MercuryUnityProbeBuild.PipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.None, + 4096, + 4096, + pipeSecurity); + } + + private static PipeSecurity CreateCurrentUserPipeSecurity() + { + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity == null ? null : identity.User; + if (user == null) + { + throw new InvalidOperationException("Could not resolve the current Windows user for Mercury Unity Probe pipe security."); + } + + PipeSecurity pipeSecurity = new PipeSecurity(); + pipeSecurity.AddAccessRule(new PipeAccessRule( + user, + PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, + AccessControlType.Allow)); + return pipeSecurity; + } + + private void TryDisposeActiveServer() + { + TryDisposeServer(activeServer); + } + + private static void TryDisposeServer(NamedPipeServerStream server) + { + if (server == null) + { + return; + } + + try + { + server.Dispose(); + } + catch + { + } + } + + private void ServeConnection(NamedPipeServerStream server) + { + StreamReader reader = null; + StreamWriter writer = null; + try + { + reader = new StreamReader(server, Encoding.UTF8, false, 4096, true); + writer = new StreamWriter(server, new UTF8Encoding(false), 4096, true); + writer.AutoFlush = true; + string requestJson = ReadBoundedRequestLine(reader); + Dictionary response = HandleRequest(requestJson); + writer.WriteLine(MercuryUnityProbeProtocol.Serialize(response)); + } + catch (FormatException ex) + { + if (writer != null) + { + Dictionary response = + MercuryUnityProbeProtocol.Error("invalid", ex.Message); + writer.WriteLine(MercuryUnityProbeProtocol.Serialize(response)); + } + } + catch (IOException ex) + { + if (!IsBrokenPipe(ex)) + { + throw; + } + } + finally + { + if (writer != null) + { + try + { + writer.Dispose(); + } + catch (IOException ex) + { + if (!IsBrokenPipe(ex)) + { + throw; + } + } + } + + if (reader != null) + { + reader.Dispose(); + } + } + } + + private static string ReadBoundedRequestLine(TextReader reader) + { + StringBuilder builder = new StringBuilder(); + while (true) + { + int value = reader.Read(); + if (value < 0) + { + return builder.ToString(); + } + + char current = (char)value; + if (current == '\n') + { + return builder.ToString(); + } + + if (current == '\r') + { + if (reader.Peek() == '\n') + { + reader.Read(); + } + return builder.ToString(); + } + + if (builder.Length >= MercuryUnityProbeProtocol.MaxRequestChars) + { + throw new FormatException("JSON request exceeds the maximum request length."); + } + + builder.Append(current); + } + } + + private Dictionary HandleRequest(string requestJson) + { + if (string.IsNullOrWhiteSpace(requestJson)) + { + return MercuryUnityProbeProtocol.Error("invalid", "Received an empty JSON request."); + } + + try + { + Dictionary request = + MercuryUnityProbeProtocol.DeserializeRequest(requestJson); + if (request == null) + { + return MercuryUnityProbeProtocol.Error("invalid", "Failed to deserialize the JSON request."); + } + + string command = MercuryUnityProbeProtocol.ReadString(request, "command"); + if (string.IsNullOrWhiteSpace(command)) + { + return MercuryUnityProbeProtocol.Error("invalid", "JSON request is missing the command field."); + } + + return InvokeOnUnityThread(delegate + { + return ExecuteCommand(command, request); + }, command); + } + catch (Exception ex) + { + return MercuryUnityProbeProtocol.Error("invalid", ex.GetType().Name + ": " + ex.Message); + } + } + + private static bool IsBrokenPipe(IOException error) + { + return error != null + && error.Message != null + && error.Message.IndexOf("pipe is broken", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private Dictionary InvokeOnUnityThread(Func> action, string reason) + { + if (Thread.CurrentThread.ManagedThreadId == mainThreadId) + { + return action(); + } + + if (unityContext != null) + { + Dictionary result = null; + Exception error = null; + using (ManualResetEventSlim done = new ManualResetEventSlim(false)) + { + unityContext.Post(delegate(object _) + { + try + { + result = action(); + } + catch (Exception ex) + { + error = ex; + } + finally + { + done.Set(); + } + }, null); + + if (!done.Wait(5000)) + { + return MercuryUnityProbeProtocol.Error(reason, "Timed out waiting for the Unity SynchronizationContext."); + } + } + + if (error != null) + { + return MercuryUnityProbeProtocol.Error(reason, error.GetType().Name + ": " + error.Message); + } + + return result; + } + + if (ThreadingHelper.Instance == null) + { + return MercuryUnityProbeProtocol.Error(reason, "No Unity SynchronizationContext or ThreadingHelper is available."); + } + + Dictionary fallbackResult = null; + Exception fallbackError = null; + using (ManualResetEventSlim fallbackDone = new ManualResetEventSlim(false)) + { + ThreadingHelper.Instance.StartSyncInvoke(delegate + { + try + { + fallbackResult = action(); + } + catch (Exception ex) + { + fallbackError = ex; + } + finally + { + fallbackDone.Set(); + } + }); + + if (!fallbackDone.Wait(5000)) + { + return MercuryUnityProbeProtocol.Error(reason, "Timed out waiting for ThreadingHelper."); + } + } + + if (fallbackError != null) + { + return MercuryUnityProbeProtocol.Error(reason, fallbackError.GetType().Name + ": " + fallbackError.Message); + } + + return fallbackResult; + } + + private Dictionary ExecuteCommand(string command, IDictionary request) + { + string normalized = command == null ? string.Empty : command.Trim().ToLowerInvariant(); + if (normalized == "status") + { + return BuildStatus(); + } + + if (normalized == "scenes") + { + return BuildScenes(); + } + + if (normalized == "find") + { + return BuildFind(MercuryUnityProbeProtocol.ReadString(request, "query"), MercuryUnityProbeProtocol.ReadLimit(request, 25)); + } + + if (normalized == "inspect") + { + return BuildInspect(MercuryUnityProbeProtocol.ReadInt(request, "instance_id")); + } + + if (normalized == "static") + { + return BuildStatic(MercuryUnityProbeProtocol.ReadString(request, "type_name")); + } + + return MercuryUnityProbeProtocol.Error(command, "Unsupported command."); + } + + private Dictionary BuildStatus() + { + List scenes = new List(); + int sceneCount = SceneManager.sceneCount; + for (int i = 0; i < sceneCount; i++) + { + Scene scene = SceneManager.GetSceneAt(i); + scenes.Add(scene.name ?? string.Empty); + } + + Dictionary data = new Dictionary(StringComparer.Ordinal); + data["pipe_name"] = MercuryUnityProbeBuild.PipeName; + data["plugin_version"] = MercuryUnityProbeBuild.PluginVersion; + data["unity_version"] = Application.unityVersion ?? string.Empty; + data["process_id"] = (uint)System.Diagnostics.Process.GetCurrentProcess().Id; + data["scene_count"] = sceneCount; + data["loaded_scene_names"] = scenes; + return MercuryUnityProbeProtocol.Success("status", data); + } + + private Dictionary BuildScenes() + { + List> scenes = new List>(); + int sceneCount = SceneManager.sceneCount; + for (int i = 0; i < sceneCount; i++) + { + Scene scene = SceneManager.GetSceneAt(i); + Dictionary record = new Dictionary(StringComparer.Ordinal); + record["name"] = scene.name ?? string.Empty; + record["path"] = scene.path ?? string.Empty; + record["build_index"] = scene.buildIndex; + record["loaded"] = scene.isLoaded; + record["root_count"] = scene.rootCount; + scenes.Add(record); + } + + Dictionary data = new Dictionary(StringComparer.Ordinal); + data["scenes"] = scenes; + return MercuryUnityProbeProtocol.Success("scenes", data); + } + + private Dictionary BuildFind(string query, int limit) + { + string needle = query == null ? string.Empty : query.Trim(); + if (needle.Length == 0) + { + return MercuryUnityProbeProtocol.Error("find", "find requires a non-empty query."); + } + + List> matches = new List>(); + UnityEngine.Object[] objects = Resources.FindObjectsOfTypeAll(); + for (int i = 0; i < objects.Length; i++) + { + UnityEngine.Object obj = objects[i]; + if (obj == null) + { + continue; + } + + string typeName = obj.GetType().FullName ?? obj.GetType().Name; + string objectName = obj.name ?? string.Empty; + if (!ContainsIgnoreCase(typeName, needle) && !ContainsIgnoreCase(objectName, needle)) + { + continue; + } + + matches.Add(DescribeObjectSummary(obj)); + if (matches.Count >= limit) + { + break; + } + } + + matches.Sort(delegate(Dictionary left, Dictionary right) + { + return string.CompareOrdinal( + Convert.ToString(left["type_name"]) + "|" + Convert.ToString(left["name"]), + Convert.ToString(right["type_name"]) + "|" + Convert.ToString(right["name"])); + }); + + Dictionary data = new Dictionary(StringComparer.Ordinal); + data["query"] = needle; + data["matches"] = matches; + return MercuryUnityProbeProtocol.Success("find", data); + } + + private Dictionary BuildInspect(int instanceId) + { + if (instanceId == 0) + { + return MercuryUnityProbeProtocol.Error("inspect", "inspect requires a non-zero instance id."); + } + + UnityEngine.Object obj = FindObjectByInstanceId(instanceId); + if (obj == null) + { + return MercuryUnityProbeProtocol.Error("inspect", "No runtime object matched the requested instance id."); + } + + Dictionary data = new Dictionary(StringComparer.Ordinal); + data["object"] = DescribeObjectSummary(obj); + data["components"] = DescribeComponents(obj); + data["fields"] = DescribeInstanceFields(obj); + data["properties"] = DescribeInstanceProperties(obj); + return MercuryUnityProbeProtocol.Success("inspect", data); + } + + private Dictionary BuildStatic(string typeName) + { + string requested = typeName == null ? string.Empty : typeName.Trim(); + if (requested.Length == 0) + { + return MercuryUnityProbeProtocol.Error("static", "static requires a managed type name."); + } + + Type type = ResolveType(requested); + if (type == null) + { + return MercuryUnityProbeProtocol.Error("static", "Could not resolve the requested managed type."); + } + + Dictionary data = new Dictionary(StringComparer.Ordinal); + data["type_name"] = type.FullName ?? type.Name; + data["assembly_name"] = type.Assembly.GetName().Name ?? string.Empty; + data["fields"] = DescribeStaticFields(type); + data["properties"] = DescribeStaticProperties(type); + return MercuryUnityProbeProtocol.Success("static", data); + } + + private static bool ContainsIgnoreCase(string haystack, string needle) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle)) + { + return false; + } + + return haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static UnityEngine.Object FindObjectByInstanceId(int instanceId) + { + UnityEngine.Object[] objects = Resources.FindObjectsOfTypeAll(); + for (int i = 0; i < objects.Length; i++) + { + UnityEngine.Object obj = objects[i]; + if (obj != null && obj.GetInstanceID() == instanceId) + { + return obj; + } + } + + return null; + } + + private static Dictionary DescribeObjectSummary(UnityEngine.Object obj) + { + Dictionary record = new Dictionary(StringComparer.Ordinal); + record["instance_id"] = obj.GetInstanceID(); + record["name"] = obj.name ?? string.Empty; + record["type_name"] = obj.GetType().FullName ?? obj.GetType().Name; + record["scene_name"] = GetSceneName(obj); + record["hierarchy_path"] = GetHierarchyPath(obj); + record["active"] = IsObjectActive(obj); + return record; + } + + private static List> DescribeComponents(UnityEngine.Object obj) + { + List> items = new List>(); + GameObject gameObject = null; + if (obj is GameObject) + { + gameObject = (GameObject)obj; + } + else if (obj is Component) + { + gameObject = ((Component)obj).gameObject; + } + + if (gameObject == null) + { + return items; + } + + Component[] components = gameObject.GetComponents(); + for (int i = 0; i < components.Length; i++) + { + Component component = components[i]; + if (component == null) + { + continue; + } + + Dictionary record = new Dictionary(StringComparer.Ordinal); + record["instance_id"] = component.GetInstanceID(); + record["type_name"] = component.GetType().FullName ?? component.GetType().Name; + items.Add(record); + } + + return items; + } + + private static List> DescribeInstanceFields(object target) + { + return DescribeMembers( + target.GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), + 24, + delegate(FieldInfo field) + { + object value = null; + try + { + value = field.GetValue(target); + } + catch + { + } + + return CreateValueMember(field.Name, field.FieldType, value); + }); + } + + private static List> DescribeInstanceProperties(object target) + { + return DescribeMembers( + target.GetType().GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), + 24, + delegate(PropertyInfo property) + { + if (property.GetIndexParameters().Length != 0 || property.GetGetMethod(true) == null) + { + return null; + } + + object value = null; + try + { + value = property.GetValue(target, null); + } + catch + { + } + + return CreateValueMember(property.Name, property.PropertyType, value); + }); + } + + private static List> DescribeStaticFields(Type type) + { + return DescribeMembers( + type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static), + 32, + delegate(FieldInfo field) + { + object value = null; + try + { + value = field.GetValue(null); + } + catch + { + } + + return CreateValueMember(field.Name, field.FieldType, value); + }); + } + + private static List> DescribeStaticProperties(Type type) + { + return DescribeMembers( + type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static), + 32, + delegate(PropertyInfo property) + { + if (property.GetIndexParameters().Length != 0 || property.GetGetMethod(true) == null) + { + return null; + } + + object value = null; + try + { + value = property.GetValue(null, null); + } + catch + { + } + + return CreateValueMember(property.Name, property.PropertyType, value); + }); + } + + private delegate Dictionary MemberFormatter(TMember member); + + private static List> DescribeMembers(TMember[] members, int limit, MemberFormatter formatter) + { + List> items = new List>(); + Array.Sort(members, delegate(TMember left, TMember right) + { + string leftName = GetMemberName(left); + string rightName = GetMemberName(right); + return string.CompareOrdinal(leftName, rightName); + }); + + for (int i = 0; i < members.Length && items.Count < limit; i++) + { + Dictionary record = formatter(members[i]); + if (record != null) + { + items.Add(record); + } + } + + return items; + } + + private static string GetMemberName(TMember member) + { + MemberInfo info = member as MemberInfo; + return info == null ? string.Empty : info.Name ?? string.Empty; + } + + private static Dictionary CreateValueMember(string name, Type declaredType, object value) + { + Dictionary record = new Dictionary(StringComparer.Ordinal); + record["name"] = name ?? string.Empty; + record["declared_type"] = declaredType == null ? string.Empty : (declaredType.FullName ?? declaredType.Name ?? string.Empty); + record["value"] = FormatValue(value); + return record; + } + + private static string FormatValue(object value) + { + if (value == null) + { + return "null"; + } + + if (value is string) + { + return TrimValue((string)value); + } + + if (value is bool || value is byte || value is sbyte || value is short || value is ushort || + value is int || value is uint || value is long || value is ulong || value is float || + value is double || value is decimal || value is char) + { + return Convert.ToString(value) ?? string.Empty; + } + + if (value is Enum) + { + return value.GetType().Name + "." + value; + } + + if (value is UnityEngine.Object) + { + UnityEngine.Object obj = (UnityEngine.Object)value; + return (obj.GetType().FullName ?? obj.GetType().Name) + "#" + obj.GetInstanceID() + ":" + (obj.name ?? string.Empty); + } + + if (value is IList) + { + IList list = (IList)value; + return "list[count=" + list.Count + "]"; + } + + if (value is IEnumerable) + { + return "enumerable"; + } + + return TrimValue(Convert.ToString(value) ?? value.GetType().Name); + } + + private static string TrimValue(string text) + { + if (text == null) + { + return string.Empty; + } + + string trimmed = text.Replace("\r", "\\r").Replace("\n", "\\n"); + return trimmed.Length <= 160 ? trimmed : trimmed.Substring(0, 157) + "..."; + } + + private static string GetSceneName(UnityEngine.Object obj) + { + if (obj is GameObject) + { + return ((GameObject)obj).scene.name ?? string.Empty; + } + + if (obj is Component) + { + Component component = (Component)obj; + return component.gameObject == null ? string.Empty : (component.gameObject.scene.name ?? string.Empty); + } + + return string.Empty; + } + + private static string GetHierarchyPath(UnityEngine.Object obj) + { + Transform transform = null; + if (obj is GameObject) + { + transform = ((GameObject)obj).transform; + } + else if (obj is Component) + { + transform = ((Component)obj).transform; + } + + if (transform == null) + { + return string.Empty; + } + + List parts = new List(); + while (transform != null) + { + parts.Add(transform.name ?? string.Empty); + transform = transform.parent; + } + + parts.Reverse(); + return "/" + string.Join("/", parts.ToArray()); + } + + private static bool IsObjectActive(UnityEngine.Object obj) + { + if (obj is Behaviour) + { + return ((Behaviour)obj).isActiveAndEnabled; + } + + if (obj is Component) + { + Component component = (Component)obj; + return component.gameObject != null && component.gameObject.activeInHierarchy; + } + + if (obj is GameObject) + { + return ((GameObject)obj).activeInHierarchy; + } + + return true; + } + + private static Type ResolveType(string requested) + { + Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < assemblies.Length; i++) + { + Type type = assemblies[i].GetType(requested, false); + if (type != null) + { + return type; + } + } + + for (int i = 0; i < assemblies.Length; i++) + { + Type[] types; + try + { + types = assemblies[i].GetTypes(); + } + catch + { + continue; + } + + for (int j = 0; j < types.Length; j++) + { + Type type = types[j]; + string fullName = type.FullName ?? string.Empty; + string name = type.Name ?? string.Empty; + if (string.Equals(fullName, requested, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, requested, StringComparison.OrdinalIgnoreCase)) + { + return type; + } + } + } + + return null; + } + } +} diff --git a/support/unityprobe-bridge/MercuryUnityProbeProtocol.cs b/support/unityprobe-bridge/MercuryUnityProbeProtocol.cs new file mode 100644 index 0000000..5f00cf3 --- /dev/null +++ b/support/unityprobe-bridge/MercuryUnityProbeProtocol.cs @@ -0,0 +1,615 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Mercury.UnityProbe +{ + internal static class MercuryUnityProbeProtocol + { + internal const int MaxRequestChars = 64 * 1024; + private const int MaxJsonDepth = 64; + private const int MaxJsonNodes = 4096; + + internal static string Serialize(object value) + { + StringBuilder builder = new StringBuilder(); + WriteJsonValue(builder, value); + return builder.ToString(); + } + + internal static Dictionary DeserializeRequest(string json) + { + if (json != null && json.Length > MaxRequestChars) + { + throw new FormatException("JSON request exceeds the maximum request length."); + } + + JsonParser parser = new JsonParser(json ?? string.Empty); + object value = parser.ParseValue(); + parser.SkipWhitespace(); + if (!parser.IsComplete) + { + throw new FormatException("Trailing characters after the JSON request."); + } + + Dictionary request = value as Dictionary; + if (request == null) + { + throw new FormatException("JSON request root must be an object."); + } + + return request; + } + + internal static Dictionary Success(string kind, object data) + { + Dictionary response = new Dictionary(StringComparer.Ordinal); + response["ok"] = true; + response["kind"] = kind ?? ""; + response["data"] = data; + response["error"] = null; + return response; + } + + internal static Dictionary Error(string kind, string error) + { + Dictionary response = new Dictionary(StringComparer.Ordinal); + response["ok"] = false; + response["kind"] = kind ?? ""; + response["data"] = null; + response["error"] = error ?? "Unknown bridge error."; + return response; + } + + internal static string ReadString(IDictionary request, string key) + { + if (request == null || key == null || !request.ContainsKey(key)) + { + return string.Empty; + } + + object raw = request[key]; + return raw == null ? string.Empty : Convert.ToString(raw, CultureInfo.InvariantCulture) ?? string.Empty; + } + + internal static int ReadInt(IDictionary request, string key) + { + if (request == null || key == null || !request.ContainsKey(key) || request[key] == null) + { + return 0; + } + + try + { + return Convert.ToInt32(request[key], CultureInfo.InvariantCulture); + } + catch + { + return 0; + } + } + + internal static int ReadLimit(IDictionary request, int fallback) + { + int value = ReadInt(request, "limit"); + if (value <= 0) + { + return fallback; + } + + if (value > 250) + { + return 250; + } + + return value; + } + + private static void WriteJsonValue(StringBuilder builder, object value) + { + if (value == null) + { + builder.Append("null"); + return; + } + + if (value is string) + { + string text = (string)value; + WriteJsonString(builder, text); + return; + } + + if (value is char) + { + char character = (char)value; + WriteJsonString(builder, character.ToString()); + return; + } + + if (value is bool) + { + bool boolean = (bool)value; + builder.Append(boolean ? "true" : "false"); + return; + } + + if (IsNumericValue(value)) + { + builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture)); + return; + } + + if (value is IDictionary) + { + IDictionary objectDictionary = (IDictionary)value; + WriteJsonObject(builder, objectDictionary); + return; + } + + if (value is IDictionary) + { + IDictionary dictionary = (IDictionary)value; + WriteJsonDictionary(builder, dictionary); + return; + } + + if (value is IEnumerable) + { + IEnumerable enumerable = (IEnumerable)value; + WriteJsonArray(builder, enumerable); + return; + } + + if (value is Enum) + { + Enum enumValue = (Enum)value; + WriteJsonString(builder, enumValue.ToString()); + return; + } + + WriteJsonString( + builder, + Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty + ); + } + + private static bool IsNumericValue(object value) + { + switch (Type.GetTypeCode(value.GetType())) + { + case TypeCode.Byte: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.SByte: + case TypeCode.Single: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + return true; + default: + return false; + } + } + + private static void WriteJsonObject(StringBuilder builder, IDictionary dictionary) + { + builder.Append('{'); + bool wroteAny = false; + foreach (KeyValuePair entry in dictionary) + { + if (wroteAny) + { + builder.Append(','); + } + + WriteJsonString(builder, entry.Key ?? string.Empty); + builder.Append(':'); + WriteJsonValue(builder, entry.Value); + wroteAny = true; + } + + builder.Append('}'); + } + + private static void WriteJsonDictionary(StringBuilder builder, IDictionary dictionary) + { + builder.Append('{'); + bool wroteAny = false; + foreach (DictionaryEntry entry in dictionary) + { + if (wroteAny) + { + builder.Append(','); + } + + WriteJsonString( + builder, + Convert.ToString(entry.Key, CultureInfo.InvariantCulture) ?? string.Empty + ); + builder.Append(':'); + WriteJsonValue(builder, entry.Value); + wroteAny = true; + } + + builder.Append('}'); + } + + private static void WriteJsonArray(StringBuilder builder, IEnumerable values) + { + builder.Append('['); + bool wroteAny = false; + foreach (object value in values) + { + if (wroteAny) + { + builder.Append(','); + } + + WriteJsonValue(builder, value); + wroteAny = true; + } + + builder.Append(']'); + } + + private static void WriteJsonString(StringBuilder builder, string value) + { + builder.Append('"'); + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + switch (character) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + builder.Append("\\\\"); + break; + case '\b': + builder.Append("\\b"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + if (character < 0x20) + { + builder.Append("\\u"); + builder.Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(character); + } + break; + } + } + + builder.Append('"'); + } + + private sealed class JsonParser + { + private readonly string text; + private int index; + private int depth; + private int nodes; + + internal JsonParser(string text) + { + this.text = text ?? string.Empty; + index = 0; + } + + internal bool IsComplete + { + get { return index >= text.Length; } + } + + internal void SkipWhitespace() + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + { + index++; + } + } + + internal object ParseValue() + { + CountNode(); + SkipWhitespace(); + if (index >= text.Length) + { + throw new FormatException("Unexpected end of JSON input."); + } + + char current = text[index]; + switch (current) + { + case '{': + return ParseObject(); + case '[': + return ParseArray(); + case '"': + return ParseString(); + case 't': + ParseLiteral("true"); + return true; + case 'f': + ParseLiteral("false"); + return false; + case 'n': + ParseLiteral("null"); + return null; + default: + if (current == '-' || char.IsDigit(current)) + { + return ParseNumber(); + } + + throw new FormatException("Unsupported JSON token at position " + index + "."); + } + } + + private Dictionary ParseObject() + { + Expect('{'); + EnterContainer(); + try + { + Dictionary dictionary = new Dictionary(StringComparer.Ordinal); + SkipWhitespace(); + if (TryConsume('}')) + { + return dictionary; + } + + while (true) + { + SkipWhitespace(); + string key = ParseString(); + SkipWhitespace(); + Expect(':'); + object value = ParseValue(); + dictionary[key] = value; + SkipWhitespace(); + if (TryConsume('}')) + { + return dictionary; + } + + Expect(','); + } + } + finally + { + LeaveContainer(); + } + } + + private List ParseArray() + { + Expect('['); + EnterContainer(); + try + { + List list = new List(); + SkipWhitespace(); + if (TryConsume(']')) + { + return list; + } + + while (true) + { + list.Add(ParseValue()); + SkipWhitespace(); + if (TryConsume(']')) + { + return list; + } + + Expect(','); + } + } + finally + { + LeaveContainer(); + } + } + + private void CountNode() + { + nodes++; + if (nodes > MaxJsonNodes) + { + throw new FormatException("JSON request exceeds the maximum node count."); + } + } + + private void EnterContainer() + { + depth++; + if (depth > MaxJsonDepth) + { + throw new FormatException("JSON request exceeds the maximum nesting depth."); + } + } + + private void LeaveContainer() + { + depth--; + } + + private string ParseString() + { + Expect('"'); + StringBuilder builder = new StringBuilder(); + while (index < text.Length) + { + char current = text[index++]; + if (current == '"') + { + return builder.ToString(); + } + + if (current != '\\') + { + builder.Append(current); + continue; + } + + if (index >= text.Length) + { + throw new FormatException("Unterminated escape sequence in JSON string."); + } + + char escaped = text[index++]; + switch (escaped) + { + case '"': + builder.Append('"'); + break; + case '\\': + builder.Append('\\'); + break; + case '/': + builder.Append('/'); + break; + case 'b': + builder.Append('\b'); + break; + case 'f': + builder.Append('\f'); + break; + case 'n': + builder.Append('\n'); + break; + case 'r': + builder.Append('\r'); + break; + case 't': + builder.Append('\t'); + break; + case 'u': + builder.Append(ParseUnicodeEscape()); + break; + default: + throw new FormatException("Unsupported escape sequence \\" + escaped + " in JSON string."); + } + } + + throw new FormatException("Unterminated JSON string."); + } + + private char ParseUnicodeEscape() + { + if (index + 4 > text.Length) + { + throw new FormatException("Incomplete unicode escape in JSON string."); + } + + string hex = text.Substring(index, 4); + index += 4; + return (char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + private object ParseNumber() + { + int start = index; + if (text[index] == '-') + { + index++; + } + + while (index < text.Length && char.IsDigit(text[index])) + { + index++; + } + + if (index < text.Length && text[index] == '.') + { + index++; + while (index < text.Length && char.IsDigit(text[index])) + { + index++; + } + } + + if (index < text.Length && (text[index] == 'e' || text[index] == 'E')) + { + index++; + if (index < text.Length && (text[index] == '+' || text[index] == '-')) + { + index++; + } + + while (index < text.Length && char.IsDigit(text[index])) + { + index++; + } + } + + string token = text.Substring(start, index - start); + if (token.IndexOfAny(new[] { '.', 'e', 'E' }) >= 0) + { + return double.Parse(token, CultureInfo.InvariantCulture); + } + + return long.Parse(token, CultureInfo.InvariantCulture); + } + + private void ParseLiteral(string literal) + { + for (int offset = 0; offset < literal.Length; offset++) + { + if (index + offset >= text.Length || text[index + offset] != literal[offset]) + { + throw new FormatException("Invalid JSON literal at position " + index + "."); + } + } + + index += literal.Length; + } + + private void Expect(char expected) + { + SkipWhitespace(); + if (index >= text.Length || text[index] != expected) + { + throw new FormatException("Expected '" + expected + "' at position " + index + "."); + } + + index++; + } + + private bool TryConsume(char value) + { + SkipWhitespace(); + if (index < text.Length && text[index] == value) + { + index++; + return true; + } + + return false; + } + } + } +} diff --git a/vendor/tree-sitter/.cargo-ok b/vendor/tree-sitter/.cargo-ok new file mode 100644 index 0000000..5f8b795 --- /dev/null +++ b/vendor/tree-sitter/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/vendor/tree-sitter/.cargo_vcs_info.json b/vendor/tree-sitter/.cargo_vcs_info.json new file mode 100644 index 0000000..59848fd --- /dev/null +++ b/vendor/tree-sitter/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406" + }, + "path_in_vcs": "lib" +} \ No newline at end of file diff --git a/vendor/tree-sitter/Cargo.lock b/vendor/tree-sitter/Cargo.lock new file mode 100644 index 0000000..5578671 --- /dev/null +++ b/vendor/tree-sitter/Cargo.lock @@ -0,0 +1,1187 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "cc" +version = "1.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" +dependencies = [ + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cobs" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" + +[[package]] +name = "cranelift-bforest" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" + +[[package]] +name = "cranelift-control" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" + +[[package]] +name = "cranelift-native" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "foldhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "foldhash", + "serde", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "log" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" + +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memfd" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64" +dependencies = [ + "rustix", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.2", + "indexmap", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "postcard" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "prettyplease" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6924ced06e1f7dfe3fa48d57b9f74f55d8915f5036121bef647ef4b204895fac" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", + "wasmtime-math", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regalloc2" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.2", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "semver" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] + +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "syn" +version = "2.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc12939a1c9b9d391e0b7135f72fd30508b73450753e28341fed159317582a77" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +dependencies = [ + "bindgen", + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", + "wasmtime-c-api-impl", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" + +[[package]] +name = "unicode-ident" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags", + "hashbrown 0.15.2", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" +dependencies = [ + "anyhow", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "hashbrown 0.14.5", + "indexmap", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rustix", + "serde", + "serde_derive", + "smallvec", + "sptr", + "target-lexicon", + "wasmparser", + "wasmtime-asm-macros", + "wasmtime-component-macro", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-icache-coherence", + "wasmtime-math", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "wasmtime-winch", + "windows-sys", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-c-api-impl" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea30cef3608f2de5797c7bbb94c1ba4f3676d9a7f81ae86ced1b512e2766ed0c" +dependencies = [ + "anyhow", + "log", + "tracing", + "wasmtime", + "wasmtime-c-api-macros", +] + +[[package]] +name = "wasmtime-c-api-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022a79ebe1124d5d384d82463d7e61c6b4dd857d81f15cb8078974eeb86db65b" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "wasmtime-component-macro" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-component-util" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" + +[[package]] +name = "wasmtime-cranelift" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.12.1", + "log", + "object", + "smallvec", + "target-lexicon", + "thiserror", + "wasmparser", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object", + "postcard", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", +] + +[[package]] +name = "wasmtime-fiber" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "wasmtime-math" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-slab" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-winch" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object", + "target-lexicon", + "wasmparser", + "wasmtime-cranelift", + "wasmtime-environ", + "winch-codegen", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winch-codegen" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror", + "wasmparser", + "wasmtime-cranelift", + "wasmtime-environ", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-parser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/vendor/tree-sitter/Cargo.toml b/vendor/tree-sitter/Cargo.toml new file mode 100644 index 0000000..9d8285f --- /dev/null +++ b/vendor/tree-sitter/Cargo.toml @@ -0,0 +1,165 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.76" +name = "tree-sitter" +version = "0.25.10" +authors = [ + "Max Brunsfeld ", + "Amaan Qureshi ", +] +build = "binding_rust/build.rs" +links = "tree-sitter" +include = [ + "/binding_rust/*", + "/Cargo.toml", + "/src/*.h", + "/src/*.c", + "/src/portable/*", + "/src/unicode/*", + "/src/wasm/*", + "/include/tree_sitter/api.h", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Rust bindings to the Tree-sitter parsing library" +homepage = "https://tree-sitter.github.io/tree-sitter" +readme = "binding_rust/README.md" +keywords = [ + "incremental", + "parsing", +] +categories = [ + "api-bindings", + "external-ffi-bindings", + "parsing", + "text-editors", +] +license = "MIT" +repository = "https://github.com/tree-sitter/tree-sitter" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-gnu", +] + +[features] +default = ["std", "regex-predicates"] +regex-predicates = ["dep:regex"] +std = [] +wasm = [ + "std", + "regex-predicates", + "wasmtime-c-api", +] + +[lib] +name = "tree_sitter" +path = "binding_rust/lib.rs" + +[dependencies.regex] +version = "1.11.1" +features = ["unicode"] +default-features = false +optional = true + +[dependencies.streaming-iterator] +version = "0.1.9" + +[dependencies.tree-sitter-language] +version = "0.1" + +[dependencies.wasmtime-c-api] +version = "29.0.1" +features = [ + "cranelift", + "gc-drc", +] +optional = true +default-features = false +package = "wasmtime-c-api-impl" + +[build-dependencies.bindgen] +version = "0.71.1" +optional = true + +[build-dependencies.cc] +version = "1.2.10" + +[build-dependencies.serde_json] +version = "1.0.137" +features = ["preserve_order"] + +[lints.clippy] +branches_sharing_code = "allow" +cast_lossless = "allow" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +checked_conversions = "allow" +cognitive_complexity = "allow" +collection_is_never_read = "allow" +dbg_macro = "deny" +fallible_impl_from = "allow" +fn_params_excessive_bools = "allow" +if_not_else = "allow" +inline_always = "allow" +items_after_statements = "allow" +match_wildcard_for_single_variants = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +multiple_crate_versions = "allow" +needless_for_each = "allow" +obfuscated_if_else = "allow" +option_if_let_else = "allow" +or_fun_call = "allow" +range_plus_one = "allow" +redundant_clone = "allow" +redundant_closure_for_method_calls = "allow" +ref_option = "allow" +similar_names = "allow" +string_lit_as_bytes = "allow" +struct_excessive_bools = "allow" +struct_field_names = "allow" +todo = "deny" +too_many_lines = "allow" +transmute_undefined_repr = "allow" +unnecessary_wraps = "allow" +unused_self = "allow" +used_underscore_items = "allow" + +[lints.clippy.cargo] +level = "warn" +priority = -1 + +[lints.clippy.nursery] +level = "warn" +priority = -1 + +[lints.clippy.pedantic] +level = "warn" +priority = -1 + +[lints.rust] +mismatched_lifetime_syntaxes = "allow" diff --git a/vendor/tree-sitter/Cargo.toml.orig b/vendor/tree-sitter/Cargo.toml.orig new file mode 100644 index 0000000..9b4a335 --- /dev/null +++ b/vendor/tree-sitter/Cargo.toml.orig @@ -0,0 +1,66 @@ +[package] +name = "tree-sitter" +version.workspace = true +description = "Rust bindings to the Tree-sitter parsing library" +authors.workspace = true +edition.workspace = true +rust-version = "1.76" +readme = "binding_rust/README.md" +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories = [ + "api-bindings", + "external-ffi-bindings", + "parsing", + "text-editors", +] + +build = "binding_rust/build.rs" +links = "tree-sitter" + +include = [ + "/binding_rust/*", + "/Cargo.toml", + "/src/*.h", + "/src/*.c", + "/src/portable/*", + "/src/unicode/*", + "/src/wasm/*", + "/include/tree_sitter/api.h", +] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] +targets = ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu"] + +[lints] +workspace = true + +[features] +default = ["std"] +std = ["regex/std", "regex/perf", "regex-syntax/unicode"] +wasm = ["std", "wasmtime-c-api"] + +[dependencies] +regex = { version = "1.11.1", default-features = false, features = ["unicode"] } +regex-syntax = { version = "0.8.5", default-features = false } +tree-sitter-language = { version = "0.1", path = "language" } +streaming-iterator = "0.1.9" + +[dependencies.wasmtime-c-api] +version = "29.0.1" +optional = true +package = "wasmtime-c-api-impl" +default-features = false +features = ["cranelift", "gc-drc"] + +[build-dependencies] +bindgen = { version = "0.71.1", optional = true } +cc.workspace = true +serde_json.workspace = true + +[lib] +path = "binding_rust/lib.rs" diff --git a/vendor/tree-sitter/README.md b/vendor/tree-sitter/README.md new file mode 100644 index 0000000..dfbe6d1 --- /dev/null +++ b/vendor/tree-sitter/README.md @@ -0,0 +1,31 @@ +# Vendored Tree-sitter + +This directory is intentionally tracked and wired through the workspace +`[patch.crates-io]` entry for `tree-sitter`. + +## Why This Exists + +Mercury Toolbox uses Tree-sitter for code-reading tools, and the local vendor copy +keeps the Rust binding and native C source available for reproducible Windows +builds while the workspace pins the exact dependency surface. + +## Maintainer Rules + +- Do not edit generated or upstream Tree-sitter source by hand unless the patch is + intentionally local and documented in this file. +- Keep `Cargo.toml.orig` beside the active `Cargo.toml` when updating so the local + changes are reviewable. +- After re-vendoring, verify that the root `Cargo.toml` still points + `[patch.crates-io].tree-sitter` at `vendor/tree-sitter`. +- Run the smallest code-reading gate that covers the change first, then run the + broader Jade gate before claiming the repository is clean. + +## Update Sketch + +1. Replace this directory with the desired upstream `tree-sitter` crate contents. +2. Preserve or refresh `Cargo.toml.orig`. +3. Reapply any local manifest adjustment needed by the workspace. +4. Run `cargo check --all-targets --all-features` or a narrower code-reading + package check while iterating. +5. Run `pwsh -NoProfile -File .\scripts\check-jade.ps1` for final repository + proof. diff --git a/vendor/tree-sitter/binding_rust/README.md b/vendor/tree-sitter/binding_rust/README.md new file mode 100644 index 0000000..602f647 --- /dev/null +++ b/vendor/tree-sitter/binding_rust/README.md @@ -0,0 +1,116 @@ +# Rust Tree-sitter + +[![crates.io badge]][crates.io] + +[crates.io]: https://crates.io/crates/tree-sitter +[crates.io badge]: https://img.shields.io/crates/v/tree-sitter.svg?color=%23B48723 + +Rust bindings to the [Tree-sitter][] parsing library. + +## Basic Usage + +First, create a parser: + +```rust +use tree_sitter::{InputEdit, Language, Parser, Point}; + +let mut parser = Parser::new(); +``` + +Add the `cc` crate to your `Cargo.toml` under `[build-dependencies]`: + +```toml +[build-dependencies] +cc="*" +``` + +Then, add a language as a dependency: + +```toml +[dependencies] +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +``` + +To then use a language, you assign them to the parser. + +```rust +parser.set_language(&tree_sitter_rust::LANGUAGE.into()).expect("Error loading Rust grammar"); +``` + +Now you can parse source code: + +```rust +let source_code = "fn test() {}"; +let mut tree = parser.parse(source_code, None).unwrap(); +let root_node = tree.root_node(); + +assert_eq!(root_node.kind(), "source_file"); +assert_eq!(root_node.start_position().column, 0); +assert_eq!(root_node.end_position().column, 12); +``` + +### Editing + +Once you have a syntax tree, you can update it when your source code changes. +Passing in the previous edited tree makes `parse` run much more quickly: + +```rust +let new_source_code = "fn test(a: u32) {}"; + +tree.edit(&InputEdit { + start_byte: 8, + old_end_byte: 8, + new_end_byte: 14, + start_position: Point::new(0, 8), + old_end_position: Point::new(0, 8), + new_end_position: Point::new(0, 14), +}); + +let new_tree = parser.parse(new_source_code, Some(&tree)); +``` + +### Text Input + +The source code to parse can be provided either as a string, a slice, a vector, +or as a function that returns a slice. The text can be encoded as either UTF8 or UTF16: + +```rust +// Store some source code in an array of lines. +let lines = &[ + "pub fn foo() {", + " 1", + "}", +]; + +// Parse the source code using a custom callback. The callback is called +// with both a byte offset and a row/column offset. +let tree = parser.parse_with(&mut |_byte: usize, position: Point| -> &[u8] { + let row = position.row as usize; + let column = position.column as usize; + if row < lines.len() { + if column < lines[row].as_bytes().len() { + &lines[row].as_bytes()[column..] + } else { + b"\n" + } + } else { + &[] + } +}, None).unwrap(); + +assert_eq!( + tree.root_node().to_sexp(), + "(source_file (function_item (visibility_modifier) (identifier) (parameters) (block (number_literal))))" +); +``` + +[tree-sitter]: https://github.com/tree-sitter/tree-sitter + +## Features + +- **std** - This feature is enabled by default and allows `tree-sitter` to use the standard library. + - Error types implement the `std::error:Error` trait. + - `regex` performance optimizations are enabled. + - The DOT graph methods are enabled. +- **wasm** - This feature allows `tree-sitter` to be built for Wasm targets using the `wasmtime-c-api` crate. diff --git a/vendor/tree-sitter/binding_rust/bindings.rs b/vendor/tree-sitter/binding_rust/bindings.rs new file mode 100644 index 0000000..8323bd0 --- /dev/null +++ b/vendor/tree-sitter/binding_rust/bindings.rs @@ -0,0 +1,963 @@ +/* automatically generated by rust-bindgen 0.71.1 */ + +pub const TREE_SITTER_LANGUAGE_VERSION: u32 = 15; +pub const TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION: u32 = 13; +pub type TSStateId = u16; +pub type TSSymbol = u16; +pub type TSFieldId = u16; +#[repr(C)] +#[derive(Debug)] +pub struct TSLanguage { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug)] +pub struct TSParser { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug)] +pub struct TSTree { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug)] +pub struct TSQuery { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug)] +pub struct TSQueryCursor { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug)] +pub struct TSLookaheadIterator { + _unused: [u8; 0], +} +pub type DecodeFunction = ::core::option::Option< + unsafe extern "C" fn(string: *const u8, length: u32, code_point: *mut i32) -> u32, +>; +pub const TSInputEncodingUTF8: TSInputEncoding = 0; +pub const TSInputEncodingUTF16LE: TSInputEncoding = 1; +pub const TSInputEncodingUTF16BE: TSInputEncoding = 2; +pub const TSInputEncodingCustom: TSInputEncoding = 3; +pub type TSInputEncoding = ::core::ffi::c_uint; +pub const TSSymbolTypeRegular: TSSymbolType = 0; +pub const TSSymbolTypeAnonymous: TSSymbolType = 1; +pub const TSSymbolTypeSupertype: TSSymbolType = 2; +pub const TSSymbolTypeAuxiliary: TSSymbolType = 3; +pub type TSSymbolType = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSPoint { + pub row: u32, + pub column: u32, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSRange { + pub start_point: TSPoint, + pub end_point: TSPoint, + pub start_byte: u32, + pub end_byte: u32, +} +#[repr(C)] +#[derive(Debug)] +pub struct TSInput { + pub payload: *mut ::core::ffi::c_void, + pub read: ::core::option::Option< + unsafe extern "C" fn( + payload: *mut ::core::ffi::c_void, + byte_index: u32, + position: TSPoint, + bytes_read: *mut u32, + ) -> *const ::core::ffi::c_char, + >, + pub encoding: TSInputEncoding, + pub decode: DecodeFunction, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSParseState { + pub payload: *mut ::core::ffi::c_void, + pub current_byte_offset: u32, + pub has_error: bool, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSParseOptions { + pub payload: *mut ::core::ffi::c_void, + pub progress_callback: + ::core::option::Option bool>, +} +pub const TSLogTypeParse: TSLogType = 0; +pub const TSLogTypeLex: TSLogType = 1; +pub type TSLogType = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug)] +pub struct TSLogger { + pub payload: *mut ::core::ffi::c_void, + pub log: ::core::option::Option< + unsafe extern "C" fn( + payload: *mut ::core::ffi::c_void, + log_type: TSLogType, + buffer: *const ::core::ffi::c_char, + ), + >, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSInputEdit { + pub start_byte: u32, + pub old_end_byte: u32, + pub new_end_byte: u32, + pub start_point: TSPoint, + pub old_end_point: TSPoint, + pub new_end_point: TSPoint, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSNode { + pub context: [u32; 4usize], + pub id: *const ::core::ffi::c_void, + pub tree: *const TSTree, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSTreeCursor { + pub tree: *const ::core::ffi::c_void, + pub id: *const ::core::ffi::c_void, + pub context: [u32; 3usize], +} +#[repr(C)] +#[derive(Debug)] +pub struct TSQueryCapture { + pub node: TSNode, + pub index: u32, +} +pub const TSQuantifierZero: TSQuantifier = 0; +pub const TSQuantifierZeroOrOne: TSQuantifier = 1; +pub const TSQuantifierZeroOrMore: TSQuantifier = 2; +pub const TSQuantifierOne: TSQuantifier = 3; +pub const TSQuantifierOneOrMore: TSQuantifier = 4; +pub type TSQuantifier = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug)] +pub struct TSQueryMatch { + pub id: u32, + pub pattern_index: u16, + pub capture_count: u16, + pub captures: *const TSQueryCapture, +} +pub const TSQueryPredicateStepTypeDone: TSQueryPredicateStepType = 0; +pub const TSQueryPredicateStepTypeCapture: TSQueryPredicateStepType = 1; +pub const TSQueryPredicateStepTypeString: TSQueryPredicateStepType = 2; +pub type TSQueryPredicateStepType = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug)] +pub struct TSQueryPredicateStep { + pub type_: TSQueryPredicateStepType, + pub value_id: u32, +} +pub const TSQueryErrorNone: TSQueryError = 0; +pub const TSQueryErrorSyntax: TSQueryError = 1; +pub const TSQueryErrorNodeType: TSQueryError = 2; +pub const TSQueryErrorField: TSQueryError = 3; +pub const TSQueryErrorCapture: TSQueryError = 4; +pub const TSQueryErrorStructure: TSQueryError = 5; +pub const TSQueryErrorLanguage: TSQueryError = 6; +pub type TSQueryError = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSQueryCursorState { + pub payload: *mut ::core::ffi::c_void, + pub current_byte_offset: u32, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSQueryCursorOptions { + pub payload: *mut ::core::ffi::c_void, + pub progress_callback: + ::core::option::Option bool>, +} +#[doc = " The metadata associated with a language.\n\n Currently, this metadata can be used to check the [Semantic Version](https://semver.org/)\n of the language. This version information should be used to signal if a given parser might\n be incompatible with existing queries when upgrading between major versions, or minor versions\n if it's in zerover."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSLanguageMetadata { + pub major_version: u8, + pub minor_version: u8, + pub patch_version: u8, +} +extern "C" { + #[doc = " Create a new parser."] + pub fn ts_parser_new() -> *mut TSParser; +} +extern "C" { + #[doc = " Delete the parser, freeing all of the memory that it used."] + pub fn ts_parser_delete(self_: *mut TSParser); +} +extern "C" { + #[doc = " Get the parser's current language."] + pub fn ts_parser_language(self_: *const TSParser) -> *const TSLanguage; +} +extern "C" { + #[doc = " Set the language that the parser should use for parsing.\n\n Returns a boolean indicating whether or not the language was successfully\n assigned. True means assignment succeeded. False means there was a version\n mismatch: the language was generated with an incompatible version of the\n Tree-sitter CLI. Check the language's ABI version using [`ts_language_abi_version`]\n and compare it to this library's [`TREE_SITTER_LANGUAGE_VERSION`] and\n [`TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION`] constants."] + pub fn ts_parser_set_language(self_: *mut TSParser, language: *const TSLanguage) -> bool; +} +extern "C" { + #[doc = " Set the ranges of text that the parser should include when parsing.\n\n By default, the parser will always include entire documents. This function\n allows you to parse only a *portion* of a document but still return a syntax\n tree whose ranges match up with the document as a whole. You can also pass\n multiple disjoint ranges.\n\n The second and third parameters specify the location and length of an array\n of ranges. The parser does *not* take ownership of these ranges; it copies\n the data, so it doesn't matter how these ranges are allocated.\n\n If `count` is zero, then the entire document will be parsed. Otherwise,\n the given ranges must be ordered from earliest to latest in the document,\n and they must not overlap. That is, the following must hold for all:\n\n `i < count - 1`: `ranges[i].end_byte <= ranges[i + 1].start_byte`\n\n If this requirement is not satisfied, the operation will fail, the ranges\n will not be assigned, and this function will return `false`. On success,\n this function returns `true`"] + pub fn ts_parser_set_included_ranges( + self_: *mut TSParser, + ranges: *const TSRange, + count: u32, + ) -> bool; +} +extern "C" { + #[doc = " Get the ranges of text that the parser will include when parsing.\n\n The returned pointer is owned by the parser. The caller should not free it\n or write to it. The length of the array will be written to the given\n `count` pointer."] + pub fn ts_parser_included_ranges(self_: *const TSParser, count: *mut u32) -> *const TSRange; +} +extern "C" { + #[doc = " Use the parser to parse some source code and create a syntax tree.\n\n If you are parsing this document for the first time, pass `NULL` for the\n `old_tree` parameter. Otherwise, if you have already parsed an earlier\n version of this document and the document has since been edited, pass the\n previous syntax tree so that the unchanged parts of it can be reused.\n This will save time and memory. For this to work correctly, you must have\n already edited the old syntax tree using the [`ts_tree_edit`] function in a\n way that exactly matches the source code changes.\n\n The [`TSInput`] parameter lets you specify how to read the text. It has the\n following three fields:\n 1. [`read`]: A function to retrieve a chunk of text at a given byte offset\n and (row, column) position. The function should return a pointer to the\n text and write its length to the [`bytes_read`] pointer. The parser does\n not take ownership of this buffer; it just borrows it until it has\n finished reading it. The function should write a zero value to the\n [`bytes_read`] pointer to indicate the end of the document.\n 2. [`payload`]: An arbitrary pointer that will be passed to each invocation\n of the [`read`] function.\n 3. [`encoding`]: An indication of how the text is encoded. Either\n `TSInputEncodingUTF8` or `TSInputEncodingUTF16`.\n\n This function returns a syntax tree on success, and `NULL` on failure. There\n are four possible reasons for failure:\n 1. The parser does not have a language assigned. Check for this using the\n[`ts_parser_language`] function.\n 2. Parsing was cancelled due to a timeout that was set by an earlier call to\n the [`ts_parser_set_timeout_micros`] function. You can resume parsing from\n where the parser left out by calling [`ts_parser_parse`] again with the\n same arguments. Or you can start parsing from scratch by first calling\n [`ts_parser_reset`].\n 3. Parsing was cancelled using a cancellation flag that was set by an\n earlier call to [`ts_parser_set_cancellation_flag`]. You can resume parsing\n from where the parser left out by calling [`ts_parser_parse`] again with\n the same arguments.\n 4. Parsing was cancelled due to the progress callback returning true. This callback\n is passed in [`ts_parser_parse_with_options`] inside the [`TSParseOptions`] struct.\n\n [`read`]: TSInput::read\n [`payload`]: TSInput::payload\n [`encoding`]: TSInput::encoding\n [`bytes_read`]: TSInput::read"] + pub fn ts_parser_parse( + self_: *mut TSParser, + old_tree: *const TSTree, + input: TSInput, + ) -> *mut TSTree; +} +extern "C" { + #[doc = " Use the parser to parse some source code and create a syntax tree, with some options.\n\n See [`ts_parser_parse`] for more details.\n\n See [`TSParseOptions`] for more details on the options."] + pub fn ts_parser_parse_with_options( + self_: *mut TSParser, + old_tree: *const TSTree, + input: TSInput, + parse_options: TSParseOptions, + ) -> *mut TSTree; +} +extern "C" { + #[doc = " Use the parser to parse some source code stored in one contiguous buffer.\n The first two parameters are the same as in the [`ts_parser_parse`] function\n above. The second two parameters indicate the location of the buffer and its\n length in bytes."] + pub fn ts_parser_parse_string( + self_: *mut TSParser, + old_tree: *const TSTree, + string: *const ::core::ffi::c_char, + length: u32, + ) -> *mut TSTree; +} +extern "C" { + #[doc = " Use the parser to parse some source code stored in one contiguous buffer with\n a given encoding. The first four parameters work the same as in the\n [`ts_parser_parse_string`] method above. The final parameter indicates whether\n the text is encoded as UTF8 or UTF16."] + pub fn ts_parser_parse_string_encoding( + self_: *mut TSParser, + old_tree: *const TSTree, + string: *const ::core::ffi::c_char, + length: u32, + encoding: TSInputEncoding, + ) -> *mut TSTree; +} +extern "C" { + #[doc = " Instruct the parser to start the next parse from the beginning.\n\n If the parser previously failed because of a timeout or a cancellation, then\n by default, it will resume where it left off on the next call to\n [`ts_parser_parse`] or other parsing functions. If you don't want to resume,\n and instead intend to use this parser to parse some other document, you must\n call [`ts_parser_reset`] first."] + pub fn ts_parser_reset(self_: *mut TSParser); +} +extern "C" { + #[doc = " @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26.\n\n Set the maximum duration in microseconds that parsing should be allowed to\n take before halting.\n\n If parsing takes longer than this, it will halt early, returning NULL.\n See [`ts_parser_parse`] for more information."] + pub fn ts_parser_set_timeout_micros(self_: *mut TSParser, timeout_micros: u64); +} +extern "C" { + #[doc = " @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26.\n\n Get the duration in microseconds that parsing is allowed to take."] + pub fn ts_parser_timeout_micros(self_: *const TSParser) -> u64; +} +extern "C" { + #[doc = " @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26.\n\n Set the parser's current cancellation flag pointer.\n\n If a non-null pointer is assigned, then the parser will periodically read\n from this pointer during parsing. If it reads a non-zero value, it will\n halt early, returning NULL. See [`ts_parser_parse`] for more information."] + pub fn ts_parser_set_cancellation_flag(self_: *mut TSParser, flag: *const usize); +} +extern "C" { + #[doc = " @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26.\n\n Get the parser's current cancellation flag pointer."] + pub fn ts_parser_cancellation_flag(self_: *const TSParser) -> *const usize; +} +extern "C" { + #[doc = " Set the logger that a parser should use during parsing.\n\n The parser does not take ownership over the logger payload. If a logger was\n previously assigned, the caller is responsible for releasing any memory\n owned by the previous logger."] + pub fn ts_parser_set_logger(self_: *mut TSParser, logger: TSLogger); +} +extern "C" { + #[doc = " Get the parser's current logger."] + pub fn ts_parser_logger(self_: *const TSParser) -> TSLogger; +} +extern "C" { + #[doc = " Set the file descriptor to which the parser should write debugging graphs\n during parsing. The graphs are formatted in the DOT language. You may want\n to pipe these graphs directly to a `dot(1)` process in order to generate\n SVG output. You can turn off this logging by passing a negative number."] + pub fn ts_parser_print_dot_graphs(self_: *mut TSParser, fd: ::core::ffi::c_int); +} +extern "C" { + #[doc = " Create a shallow copy of the syntax tree. This is very fast.\n\n You need to copy a syntax tree in order to use it on more than one thread at\n a time, as syntax trees are not thread safe."] + pub fn ts_tree_copy(self_: *const TSTree) -> *mut TSTree; +} +extern "C" { + #[doc = " Delete the syntax tree, freeing all of the memory that it used."] + pub fn ts_tree_delete(self_: *mut TSTree); +} +extern "C" { + #[doc = " Get the root node of the syntax tree."] + pub fn ts_tree_root_node(self_: *const TSTree) -> TSNode; +} +extern "C" { + #[doc = " Get the root node of the syntax tree, but with its position\n shifted forward by the given offset."] + pub fn ts_tree_root_node_with_offset( + self_: *const TSTree, + offset_bytes: u32, + offset_extent: TSPoint, + ) -> TSNode; +} +extern "C" { + #[doc = " Get the language that was used to parse the syntax tree."] + pub fn ts_tree_language(self_: *const TSTree) -> *const TSLanguage; +} +extern "C" { + #[doc = " Get the array of included ranges that was used to parse the syntax tree.\n\n The returned pointer must be freed by the caller."] + pub fn ts_tree_included_ranges(self_: *const TSTree, length: *mut u32) -> *mut TSRange; +} +extern "C" { + #[doc = " Edit the syntax tree to keep it in sync with source code that has been\n edited.\n\n You must describe the edit both in terms of byte offsets and in terms of\n (row, column) coordinates."] + pub fn ts_tree_edit(self_: *mut TSTree, edit: *const TSInputEdit); +} +extern "C" { + #[doc = " Compare an old edited syntax tree to a new syntax tree representing the same\n document, returning an array of ranges whose syntactic structure has changed.\n\n For this to work correctly, the old syntax tree must have been edited such\n that its ranges match up to the new tree. Generally, you'll want to call\n this function right after calling one of the [`ts_parser_parse`] functions.\n You need to pass the old tree that was passed to parse, as well as the new\n tree that was returned from that function.\n\n The returned ranges indicate areas where the hierarchical structure of syntax\n nodes (from root to leaf) has changed between the old and new trees. Characters\n outside these ranges have identical ancestor nodes in both trees.\n\n Note that the returned ranges may be slightly larger than the exact changed areas,\n but Tree-sitter attempts to make them as small as possible.\n\n The returned array is allocated using `malloc` and the caller is responsible\n for freeing it using `free`. The length of the array will be written to the\n given `length` pointer."] + pub fn ts_tree_get_changed_ranges( + old_tree: *const TSTree, + new_tree: *const TSTree, + length: *mut u32, + ) -> *mut TSRange; +} +extern "C" { + #[doc = " Write a DOT graph describing the syntax tree to the given file."] + pub fn ts_tree_print_dot_graph(self_: *const TSTree, file_descriptor: ::core::ffi::c_int); +} +extern "C" { + #[doc = " Get the node's type as a null-terminated string."] + pub fn ts_node_type(self_: TSNode) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the node's type as a numerical id."] + pub fn ts_node_symbol(self_: TSNode) -> TSSymbol; +} +extern "C" { + #[doc = " Get the node's language."] + pub fn ts_node_language(self_: TSNode) -> *const TSLanguage; +} +extern "C" { + #[doc = " Get the node's type as it appears in the grammar ignoring aliases as a\n null-terminated string."] + pub fn ts_node_grammar_type(self_: TSNode) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the node's type as a numerical id as it appears in the grammar ignoring\n aliases. This should be used in [`ts_language_next_state`] instead of\n [`ts_node_symbol`]."] + pub fn ts_node_grammar_symbol(self_: TSNode) -> TSSymbol; +} +extern "C" { + #[doc = " Get the node's start byte."] + pub fn ts_node_start_byte(self_: TSNode) -> u32; +} +extern "C" { + #[doc = " Get the node's start position in terms of rows and columns."] + pub fn ts_node_start_point(self_: TSNode) -> TSPoint; +} +extern "C" { + #[doc = " Get the node's end byte."] + pub fn ts_node_end_byte(self_: TSNode) -> u32; +} +extern "C" { + #[doc = " Get the node's end position in terms of rows and columns."] + pub fn ts_node_end_point(self_: TSNode) -> TSPoint; +} +extern "C" { + #[doc = " Get an S-expression representing the node as a string.\n\n This string is allocated with `malloc` and the caller is responsible for\n freeing it using `free`."] + pub fn ts_node_string(self_: TSNode) -> *mut ::core::ffi::c_char; +} +extern "C" { + #[doc = " Check if the node is null. Functions like [`ts_node_child`] and\n [`ts_node_next_sibling`] will return a null node to indicate that no such node\n was found."] + pub fn ts_node_is_null(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Check if the node is *named*. Named nodes correspond to named rules in the\n grammar, whereas *anonymous* nodes correspond to string literals in the\n grammar."] + pub fn ts_node_is_named(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Check if the node is *missing*. Missing nodes are inserted by the parser in\n order to recover from certain kinds of syntax errors."] + pub fn ts_node_is_missing(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Check if the node is *extra*. Extra nodes represent things like comments,\n which are not required the grammar, but can appear anywhere."] + pub fn ts_node_is_extra(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Check if a syntax node has been edited."] + pub fn ts_node_has_changes(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Check if the node is a syntax error or contains any syntax errors."] + pub fn ts_node_has_error(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Check if the node is a syntax error."] + pub fn ts_node_is_error(self_: TSNode) -> bool; +} +extern "C" { + #[doc = " Get this node's parse state."] + pub fn ts_node_parse_state(self_: TSNode) -> TSStateId; +} +extern "C" { + #[doc = " Get the parse state after this node."] + pub fn ts_node_next_parse_state(self_: TSNode) -> TSStateId; +} +extern "C" { + #[doc = " Get the node's immediate parent.\n Prefer [`ts_node_child_with_descendant`] for\n iterating over the node's ancestors."] + pub fn ts_node_parent(self_: TSNode) -> TSNode; +} +extern "C" { + #[doc = " Get the node that contains `descendant`.\n\n Note that this can return `descendant` itself."] + pub fn ts_node_child_with_descendant(self_: TSNode, descendant: TSNode) -> TSNode; +} +extern "C" { + #[doc = " Get the node's child at the given index, where zero represents the first\n child."] + pub fn ts_node_child(self_: TSNode, child_index: u32) -> TSNode; +} +extern "C" { + #[doc = " Get the field name for node's child at the given index, where zero represents\n the first child. Returns NULL, if no field is found."] + pub fn ts_node_field_name_for_child( + self_: TSNode, + child_index: u32, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the field name for node's named child at the given index, where zero\n represents the first named child. Returns NULL, if no field is found."] + pub fn ts_node_field_name_for_named_child( + self_: TSNode, + named_child_index: u32, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the node's number of children."] + pub fn ts_node_child_count(self_: TSNode) -> u32; +} +extern "C" { + #[doc = " Get the node's *named* child at the given index.\n\n See also [`ts_node_is_named`]."] + pub fn ts_node_named_child(self_: TSNode, child_index: u32) -> TSNode; +} +extern "C" { + #[doc = " Get the node's number of *named* children.\n\n See also [`ts_node_is_named`]."] + pub fn ts_node_named_child_count(self_: TSNode) -> u32; +} +extern "C" { + #[doc = " Get the node's child with the given field name."] + pub fn ts_node_child_by_field_name( + self_: TSNode, + name: *const ::core::ffi::c_char, + name_length: u32, + ) -> TSNode; +} +extern "C" { + #[doc = " Get the node's child with the given numerical field id.\n\n You can convert a field name to an id using the\n [`ts_language_field_id_for_name`] function."] + pub fn ts_node_child_by_field_id(self_: TSNode, field_id: TSFieldId) -> TSNode; +} +extern "C" { + #[doc = " Get the node's next / previous sibling."] + pub fn ts_node_next_sibling(self_: TSNode) -> TSNode; +} +extern "C" { + pub fn ts_node_prev_sibling(self_: TSNode) -> TSNode; +} +extern "C" { + #[doc = " Get the node's next / previous *named* sibling."] + pub fn ts_node_next_named_sibling(self_: TSNode) -> TSNode; +} +extern "C" { + pub fn ts_node_prev_named_sibling(self_: TSNode) -> TSNode; +} +extern "C" { + #[doc = " Get the node's first child that contains or starts after the given byte offset."] + pub fn ts_node_first_child_for_byte(self_: TSNode, byte: u32) -> TSNode; +} +extern "C" { + #[doc = " Get the node's first named child that contains or starts after the given byte offset."] + pub fn ts_node_first_named_child_for_byte(self_: TSNode, byte: u32) -> TSNode; +} +extern "C" { + #[doc = " Get the node's number of descendants, including one for the node itself."] + pub fn ts_node_descendant_count(self_: TSNode) -> u32; +} +extern "C" { + #[doc = " Get the smallest node within this node that spans the given range of bytes\n or (row, column) positions."] + pub fn ts_node_descendant_for_byte_range(self_: TSNode, start: u32, end: u32) -> TSNode; +} +extern "C" { + pub fn ts_node_descendant_for_point_range( + self_: TSNode, + start: TSPoint, + end: TSPoint, + ) -> TSNode; +} +extern "C" { + #[doc = " Get the smallest named node within this node that spans the given range of\n bytes or (row, column) positions."] + pub fn ts_node_named_descendant_for_byte_range(self_: TSNode, start: u32, end: u32) -> TSNode; +} +extern "C" { + pub fn ts_node_named_descendant_for_point_range( + self_: TSNode, + start: TSPoint, + end: TSPoint, + ) -> TSNode; +} +extern "C" { + #[doc = " Edit the node to keep it in-sync with source code that has been edited.\n\n This function is only rarely needed. When you edit a syntax tree with the\n [`ts_tree_edit`] function, all of the nodes that you retrieve from the tree\n afterward will already reflect the edit. You only need to use [`ts_node_edit`]\n when you have a [`TSNode`] instance that you want to keep and continue to use\n after an edit."] + pub fn ts_node_edit(self_: *mut TSNode, edit: *const TSInputEdit); +} +extern "C" { + #[doc = " Check if two nodes are identical."] + pub fn ts_node_eq(self_: TSNode, other: TSNode) -> bool; +} +extern "C" { + #[doc = " Create a new tree cursor starting from the given node.\n\n A tree cursor allows you to walk a syntax tree more efficiently than is\n possible using the [`TSNode`] functions. It is a mutable object that is always\n on a certain syntax node, and can be moved imperatively to different nodes.\n\n Note that the given node is considered the root of the cursor,\n and the cursor cannot walk outside this node."] + pub fn ts_tree_cursor_new(node: TSNode) -> TSTreeCursor; +} +extern "C" { + #[doc = " Delete a tree cursor, freeing all of the memory that it used."] + pub fn ts_tree_cursor_delete(self_: *mut TSTreeCursor); +} +extern "C" { + #[doc = " Re-initialize a tree cursor to start at the original node that the cursor was\n constructed with."] + pub fn ts_tree_cursor_reset(self_: *mut TSTreeCursor, node: TSNode); +} +extern "C" { + #[doc = " Re-initialize a tree cursor to the same position as another cursor.\n\n Unlike [`ts_tree_cursor_reset`], this will not lose parent information and\n allows reusing already created cursors."] + pub fn ts_tree_cursor_reset_to(dst: *mut TSTreeCursor, src: *const TSTreeCursor); +} +extern "C" { + #[doc = " Get the tree cursor's current node."] + pub fn ts_tree_cursor_current_node(self_: *const TSTreeCursor) -> TSNode; +} +extern "C" { + #[doc = " Get the field name of the tree cursor's current node.\n\n This returns `NULL` if the current node doesn't have a field.\n See also [`ts_node_child_by_field_name`]."] + pub fn ts_tree_cursor_current_field_name( + self_: *const TSTreeCursor, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the field id of the tree cursor's current node.\n\n This returns zero if the current node doesn't have a field.\n See also [`ts_node_child_by_field_id`], [`ts_language_field_id_for_name`]."] + pub fn ts_tree_cursor_current_field_id(self_: *const TSTreeCursor) -> TSFieldId; +} +extern "C" { + #[doc = " Move the cursor to the parent of its current node.\n\n This returns `true` if the cursor successfully moved, and returns `false`\n if there was no parent node (the cursor was already on the root node).\n\n Note that the node the cursor was constructed with is considered the root\n of the cursor, and the cursor cannot walk outside this node."] + pub fn ts_tree_cursor_goto_parent(self_: *mut TSTreeCursor) -> bool; +} +extern "C" { + #[doc = " Move the cursor to the next sibling of its current node.\n\n This returns `true` if the cursor successfully moved, and returns `false`\n if there was no next sibling node.\n\n Note that the node the cursor was constructed with is considered the root\n of the cursor, and the cursor cannot walk outside this node."] + pub fn ts_tree_cursor_goto_next_sibling(self_: *mut TSTreeCursor) -> bool; +} +extern "C" { + #[doc = " Move the cursor to the previous sibling of its current node.\n\n This returns `true` if the cursor successfully moved, and returns `false` if\n there was no previous sibling node.\n\n Note, that this function may be slower than\n [`ts_tree_cursor_goto_next_sibling`] due to how node positions are stored. In\n the worst case, this will need to iterate through all the children up to the\n previous sibling node to recalculate its position. Also note that the node the cursor\n was constructed with is considered the root of the cursor, and the cursor cannot\n walk outside this node."] + pub fn ts_tree_cursor_goto_previous_sibling(self_: *mut TSTreeCursor) -> bool; +} +extern "C" { + #[doc = " Move the cursor to the first child of its current node.\n\n This returns `true` if the cursor successfully moved, and returns `false`\n if there were no children."] + pub fn ts_tree_cursor_goto_first_child(self_: *mut TSTreeCursor) -> bool; +} +extern "C" { + #[doc = " Move the cursor to the last child of its current node.\n\n This returns `true` if the cursor successfully moved, and returns `false` if\n there were no children.\n\n Note that this function may be slower than [`ts_tree_cursor_goto_first_child`]\n because it needs to iterate through all the children to compute the child's\n position."] + pub fn ts_tree_cursor_goto_last_child(self_: *mut TSTreeCursor) -> bool; +} +extern "C" { + #[doc = " Move the cursor to the node that is the nth descendant of\n the original node that the cursor was constructed with, where\n zero represents the original node itself."] + pub fn ts_tree_cursor_goto_descendant(self_: *mut TSTreeCursor, goal_descendant_index: u32); +} +extern "C" { + #[doc = " Get the index of the cursor's current node out of all of the\n descendants of the original node that the cursor was constructed with."] + pub fn ts_tree_cursor_current_descendant_index(self_: *const TSTreeCursor) -> u32; +} +extern "C" { + #[doc = " Get the depth of the cursor's current node relative to the original\n node that the cursor was constructed with."] + pub fn ts_tree_cursor_current_depth(self_: *const TSTreeCursor) -> u32; +} +extern "C" { + #[doc = " Move the cursor to the first child of its current node that contains or starts after\n the given byte offset or point.\n\n This returns the index of the child node if one was found, and returns -1\n if no such child was found."] + pub fn ts_tree_cursor_goto_first_child_for_byte( + self_: *mut TSTreeCursor, + goal_byte: u32, + ) -> i64; +} +extern "C" { + pub fn ts_tree_cursor_goto_first_child_for_point( + self_: *mut TSTreeCursor, + goal_point: TSPoint, + ) -> i64; +} +extern "C" { + pub fn ts_tree_cursor_copy(cursor: *const TSTreeCursor) -> TSTreeCursor; +} +extern "C" { + #[doc = " Create a new query from a string containing one or more S-expression\n patterns. The query is associated with a particular language, and can\n only be run on syntax nodes parsed with that language.\n\n If all of the given patterns are valid, this returns a [`TSQuery`].\n If a pattern is invalid, this returns `NULL`, and provides two pieces\n of information about the problem:\n 1. The byte offset of the error is written to the `error_offset` parameter.\n 2. The type of error is written to the `error_type` parameter."] + pub fn ts_query_new( + language: *const TSLanguage, + source: *const ::core::ffi::c_char, + source_len: u32, + error_offset: *mut u32, + error_type: *mut TSQueryError, + ) -> *mut TSQuery; +} +extern "C" { + #[doc = " Delete a query, freeing all of the memory that it used."] + pub fn ts_query_delete(self_: *mut TSQuery); +} +extern "C" { + #[doc = " Get the number of patterns, captures, or string literals in the query."] + pub fn ts_query_pattern_count(self_: *const TSQuery) -> u32; +} +extern "C" { + pub fn ts_query_capture_count(self_: *const TSQuery) -> u32; +} +extern "C" { + pub fn ts_query_string_count(self_: *const TSQuery) -> u32; +} +extern "C" { + #[doc = " Get the byte offset where the given pattern starts in the query's source.\n\n This can be useful when combining queries by concatenating their source\n code strings."] + pub fn ts_query_start_byte_for_pattern(self_: *const TSQuery, pattern_index: u32) -> u32; +} +extern "C" { + #[doc = " Get the byte offset where the given pattern ends in the query's source.\n\n This can be useful when combining queries by concatenating their source\n code strings."] + pub fn ts_query_end_byte_for_pattern(self_: *const TSQuery, pattern_index: u32) -> u32; +} +extern "C" { + #[doc = " Get all of the predicates for the given pattern in the query.\n\n The predicates are represented as a single array of steps. There are three\n types of steps in this array, which correspond to the three legal values for\n the `type` field:\n - `TSQueryPredicateStepTypeCapture` - Steps with this type represent names\n of captures. Their `value_id` can be used with the\n [`ts_query_capture_name_for_id`] function to obtain the name of the capture.\n - `TSQueryPredicateStepTypeString` - Steps with this type represent literal\n strings. Their `value_id` can be used with the\n [`ts_query_string_value_for_id`] function to obtain their string value.\n - `TSQueryPredicateStepTypeDone` - Steps with this type are *sentinels*\n that represent the end of an individual predicate. If a pattern has two\n predicates, then there will be two steps with this `type` in the array."] + pub fn ts_query_predicates_for_pattern( + self_: *const TSQuery, + pattern_index: u32, + step_count: *mut u32, + ) -> *const TSQueryPredicateStep; +} +extern "C" { + pub fn ts_query_is_pattern_rooted(self_: *const TSQuery, pattern_index: u32) -> bool; +} +extern "C" { + pub fn ts_query_is_pattern_non_local(self_: *const TSQuery, pattern_index: u32) -> bool; +} +extern "C" { + pub fn ts_query_is_pattern_guaranteed_at_step(self_: *const TSQuery, byte_offset: u32) -> bool; +} +extern "C" { + #[doc = " Get the name and length of one of the query's captures, or one of the\n query's string literals. Each capture and string is associated with a\n numeric id based on the order that it appeared in the query's source."] + pub fn ts_query_capture_name_for_id( + self_: *const TSQuery, + index: u32, + length: *mut u32, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the quantifier of the query's captures. Each capture is * associated\n with a numeric id based on the order that it appeared in the query's source."] + pub fn ts_query_capture_quantifier_for_id( + self_: *const TSQuery, + pattern_index: u32, + capture_index: u32, + ) -> TSQuantifier; +} +extern "C" { + pub fn ts_query_string_value_for_id( + self_: *const TSQuery, + index: u32, + length: *mut u32, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Disable a certain capture within a query.\n\n This prevents the capture from being returned in matches, and also avoids\n any resource usage associated with recording the capture. Currently, there\n is no way to undo this."] + pub fn ts_query_disable_capture( + self_: *mut TSQuery, + name: *const ::core::ffi::c_char, + length: u32, + ); +} +extern "C" { + #[doc = " Disable a certain pattern within a query.\n\n This prevents the pattern from matching and removes most of the overhead\n associated with the pattern. Currently, there is no way to undo this."] + pub fn ts_query_disable_pattern(self_: *mut TSQuery, pattern_index: u32); +} +extern "C" { + #[doc = " Create a new cursor for executing a given query.\n\n The cursor stores the state that is needed to iteratively search\n for matches. To use the query cursor, first call [`ts_query_cursor_exec`]\n to start running a given query on a given syntax node. Then, there are\n two options for consuming the results of the query:\n 1. Repeatedly call [`ts_query_cursor_next_match`] to iterate over all of the\n *matches* in the order that they were found. Each match contains the\n index of the pattern that matched, and an array of captures. Because\n multiple patterns can match the same set of nodes, one match may contain\n captures that appear *before* some of the captures from a previous match.\n 2. Repeatedly call [`ts_query_cursor_next_capture`] to iterate over all of the\n individual *captures* in the order that they appear. This is useful if\n don't care about which pattern matched, and just want a single ordered\n sequence of captures.\n\n If you don't care about consuming all of the results, you can stop calling\n [`ts_query_cursor_next_match`] or [`ts_query_cursor_next_capture`] at any point.\n You can then start executing another query on another node by calling\n [`ts_query_cursor_exec`] again."] + pub fn ts_query_cursor_new() -> *mut TSQueryCursor; +} +extern "C" { + #[doc = " Delete a query cursor, freeing all of the memory that it used."] + pub fn ts_query_cursor_delete(self_: *mut TSQueryCursor); +} +extern "C" { + #[doc = " Start running a given query on a given node."] + pub fn ts_query_cursor_exec(self_: *mut TSQueryCursor, query: *const TSQuery, node: TSNode); +} +extern "C" { + #[doc = " Start running a given query on a given node, with some options."] + pub fn ts_query_cursor_exec_with_options( + self_: *mut TSQueryCursor, + query: *const TSQuery, + node: TSNode, + query_options: *const TSQueryCursorOptions, + ); +} +extern "C" { + #[doc = " Manage the maximum number of in-progress matches allowed by this query\n cursor.\n\n Query cursors have an optional maximum capacity for storing lists of\n in-progress captures. If this capacity is exceeded, then the\n earliest-starting match will silently be dropped to make room for further\n matches. This maximum capacity is optional — by default, query cursors allow\n any number of pending matches, dynamically allocating new space for them as\n needed as the query is executed."] + pub fn ts_query_cursor_did_exceed_match_limit(self_: *const TSQueryCursor) -> bool; +} +extern "C" { + pub fn ts_query_cursor_match_limit(self_: *const TSQueryCursor) -> u32; +} +extern "C" { + pub fn ts_query_cursor_set_match_limit(self_: *mut TSQueryCursor, limit: u32); +} +extern "C" { + #[doc = " @deprecated use [`ts_query_cursor_exec_with_options`] and pass in a callback instead, this will be removed in 0.26.\n\n Set the maximum duration in microseconds that query execution should be allowed to\n take before halting.\n\n If query execution takes longer than this, it will halt early, returning NULL.\n See [`ts_query_cursor_next_match`] or [`ts_query_cursor_next_capture`] for more information."] + pub fn ts_query_cursor_set_timeout_micros(self_: *mut TSQueryCursor, timeout_micros: u64); +} +extern "C" { + #[doc = " @deprecated use [`ts_query_cursor_exec_with_options`] and pass in a callback instead, this will be removed in 0.26.\n\n Get the duration in microseconds that query execution is allowed to take.\n\n This is set via [`ts_query_cursor_set_timeout_micros`]."] + pub fn ts_query_cursor_timeout_micros(self_: *const TSQueryCursor) -> u64; +} +extern "C" { + #[doc = " Set the range of bytes in which the query will be executed.\n\n The query cursor will return matches that intersect with the given point range.\n This means that a match may be returned even if some of its captures fall\n outside the specified range, as long as at least part of the match\n overlaps with the range.\n\n For example, if a query pattern matches a node that spans a larger area\n than the specified range, but part of that node intersects with the range,\n the entire match will be returned.\n\n This will return `false` if the start byte is greater than the end byte, otherwise\n it will return `true`."] + pub fn ts_query_cursor_set_byte_range( + self_: *mut TSQueryCursor, + start_byte: u32, + end_byte: u32, + ) -> bool; +} +extern "C" { + #[doc = " Set the range of (row, column) positions in which the query will be executed.\n\n The query cursor will return matches that intersect with the given point range.\n This means that a match may be returned even if some of its captures fall\n outside the specified range, as long as at least part of the match\n overlaps with the range.\n\n For example, if a query pattern matches a node that spans a larger area\n than the specified range, but part of that node intersects with the range,\n the entire match will be returned.\n\n This will return `false` if the start point is greater than the end point, otherwise\n it will return `true`."] + pub fn ts_query_cursor_set_point_range( + self_: *mut TSQueryCursor, + start_point: TSPoint, + end_point: TSPoint, + ) -> bool; +} +extern "C" { + #[doc = " Advance to the next match of the currently running query.\n\n If there is a match, write it to `*match` and return `true`.\n Otherwise, return `false`."] + pub fn ts_query_cursor_next_match(self_: *mut TSQueryCursor, match_: *mut TSQueryMatch) + -> bool; +} +extern "C" { + pub fn ts_query_cursor_remove_match(self_: *mut TSQueryCursor, match_id: u32); +} +extern "C" { + #[doc = " Advance to the next capture of the currently running query.\n\n If there is a capture, write its match to `*match` and its index within\n the match's capture list to `*capture_index`. Otherwise, return `false`."] + pub fn ts_query_cursor_next_capture( + self_: *mut TSQueryCursor, + match_: *mut TSQueryMatch, + capture_index: *mut u32, + ) -> bool; +} +extern "C" { + #[doc = " Set the maximum start depth for a query cursor.\n\n This prevents cursors from exploring children nodes at a certain depth.\n Note if a pattern includes many children, then they will still be checked.\n\n The zero max start depth value can be used as a special behavior and\n it helps to destructure a subtree by staying on a node and using captures\n for interested parts. Note that the zero max start depth only limit a search\n depth for a pattern's root node but other nodes that are parts of the pattern\n may be searched at any depth what defined by the pattern structure.\n\n Set to `UINT32_MAX` to remove the maximum start depth."] + pub fn ts_query_cursor_set_max_start_depth(self_: *mut TSQueryCursor, max_start_depth: u32); +} +extern "C" { + #[doc = " Get another reference to the given language."] + pub fn ts_language_copy(self_: *const TSLanguage) -> *const TSLanguage; +} +extern "C" { + #[doc = " Free any dynamically-allocated resources for this language, if\n this is the last reference."] + pub fn ts_language_delete(self_: *const TSLanguage); +} +extern "C" { + #[doc = " Get the number of distinct node types in the language."] + pub fn ts_language_symbol_count(self_: *const TSLanguage) -> u32; +} +extern "C" { + #[doc = " Get the number of valid states in this language."] + pub fn ts_language_state_count(self_: *const TSLanguage) -> u32; +} +extern "C" { + #[doc = " Get the numerical id for the given node type string."] + pub fn ts_language_symbol_for_name( + self_: *const TSLanguage, + string: *const ::core::ffi::c_char, + length: u32, + is_named: bool, + ) -> TSSymbol; +} +extern "C" { + #[doc = " Get the number of distinct field names in the language."] + pub fn ts_language_field_count(self_: *const TSLanguage) -> u32; +} +extern "C" { + #[doc = " Get the field name string for the given numerical id."] + pub fn ts_language_field_name_for_id( + self_: *const TSLanguage, + id: TSFieldId, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Get the numerical id for the given field name string."] + pub fn ts_language_field_id_for_name( + self_: *const TSLanguage, + name: *const ::core::ffi::c_char, + name_length: u32, + ) -> TSFieldId; +} +extern "C" { + #[doc = " Get a list of all supertype symbols for the language."] + pub fn ts_language_supertypes(self_: *const TSLanguage, length: *mut u32) -> *const TSSymbol; +} +extern "C" { + #[doc = " Get a list of all subtype symbol ids for a given supertype symbol.\n\n See [`ts_language_supertypes`] for fetching all supertype symbols."] + pub fn ts_language_subtypes( + self_: *const TSLanguage, + supertype: TSSymbol, + length: *mut u32, + ) -> *const TSSymbol; +} +extern "C" { + #[doc = " Get a node type string for the given numerical id."] + pub fn ts_language_symbol_name( + self_: *const TSLanguage, + symbol: TSSymbol, + ) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Check whether the given node type id belongs to named nodes, anonymous nodes,\n or a hidden nodes.\n\n See also [`ts_node_is_named`]. Hidden nodes are never returned from the API."] + pub fn ts_language_symbol_type(self_: *const TSLanguage, symbol: TSSymbol) -> TSSymbolType; +} +extern "C" { + #[doc = " @deprecated use [`ts_language_abi_version`] instead, this will be removed in 0.26.\n\n Get the ABI version number for this language. This version number is used\n to ensure that languages were generated by a compatible version of\n Tree-sitter.\n\n See also [`ts_parser_set_language`]."] + pub fn ts_language_version(self_: *const TSLanguage) -> u32; +} +extern "C" { + #[doc = " Get the ABI version number for this language. This version number is used\n to ensure that languages were generated by a compatible version of\n Tree-sitter.\n\n See also [`ts_parser_set_language`]."] + pub fn ts_language_abi_version(self_: *const TSLanguage) -> u32; +} +extern "C" { + #[doc = " Get the metadata for this language. This information is generated by the\n CLI, and relies on the language author providing the correct metadata in\n the language's `tree-sitter.json` file.\n\n See also [`TSMetadata`]."] + pub fn ts_language_metadata(self_: *const TSLanguage) -> *const TSLanguageMetadata; +} +extern "C" { + #[doc = " Get the next parse state. Combine this with lookahead iterators to generate\n completion suggestions or valid symbols in error nodes. Use\n [`ts_node_grammar_symbol`] for valid symbols."] + pub fn ts_language_next_state( + self_: *const TSLanguage, + state: TSStateId, + symbol: TSSymbol, + ) -> TSStateId; +} +extern "C" { + #[doc = " Get the name of this language. This returns `NULL` in older parsers."] + pub fn ts_language_name(self_: *const TSLanguage) -> *const ::core::ffi::c_char; +} +extern "C" { + #[doc = " Create a new lookahead iterator for the given language and parse state.\n\n This returns `NULL` if state is invalid for the language.\n\n Repeatedly using [`ts_lookahead_iterator_next`] and\n [`ts_lookahead_iterator_current_symbol`] will generate valid symbols in the\n given parse state. Newly created lookahead iterators will contain the `ERROR`\n symbol.\n\n Lookahead iterators can be useful to generate suggestions and improve syntax\n error diagnostics. To get symbols valid in an ERROR node, use the lookahead\n iterator on its first leaf node state. For `MISSING` nodes, a lookahead\n iterator created on the previous non-extra leaf node may be appropriate."] + pub fn ts_lookahead_iterator_new( + self_: *const TSLanguage, + state: TSStateId, + ) -> *mut TSLookaheadIterator; +} +extern "C" { + #[doc = " Delete a lookahead iterator freeing all the memory used."] + pub fn ts_lookahead_iterator_delete(self_: *mut TSLookaheadIterator); +} +extern "C" { + #[doc = " Reset the lookahead iterator to another state.\n\n This returns `true` if the iterator was reset to the given state and `false`\n otherwise."] + pub fn ts_lookahead_iterator_reset_state( + self_: *mut TSLookaheadIterator, + state: TSStateId, + ) -> bool; +} +extern "C" { + #[doc = " Reset the lookahead iterator.\n\n This returns `true` if the language was set successfully and `false`\n otherwise."] + pub fn ts_lookahead_iterator_reset( + self_: *mut TSLookaheadIterator, + language: *const TSLanguage, + state: TSStateId, + ) -> bool; +} +extern "C" { + #[doc = " Get the current language of the lookahead iterator."] + pub fn ts_lookahead_iterator_language(self_: *const TSLookaheadIterator) -> *const TSLanguage; +} +extern "C" { + #[doc = " Advance the lookahead iterator to the next symbol.\n\n This returns `true` if there is a new symbol and `false` otherwise."] + pub fn ts_lookahead_iterator_next(self_: *mut TSLookaheadIterator) -> bool; +} +extern "C" { + #[doc = " Get the current symbol of the lookahead iterator;"] + pub fn ts_lookahead_iterator_current_symbol(self_: *const TSLookaheadIterator) -> TSSymbol; +} +extern "C" { + #[doc = " Get the current symbol type of the lookahead iterator as a null terminated\n string."] + pub fn ts_lookahead_iterator_current_symbol_name( + self_: *const TSLookaheadIterator, + ) -> *const ::core::ffi::c_char; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct wasm_engine_t { + _unused: [u8; 0], +} +pub type TSWasmEngine = wasm_engine_t; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSWasmStore { + _unused: [u8; 0], +} +pub const TSWasmErrorKindNone: TSWasmErrorKind = 0; +pub const TSWasmErrorKindParse: TSWasmErrorKind = 1; +pub const TSWasmErrorKindCompile: TSWasmErrorKind = 2; +pub const TSWasmErrorKindInstantiate: TSWasmErrorKind = 3; +pub const TSWasmErrorKindAllocate: TSWasmErrorKind = 4; +pub type TSWasmErrorKind = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TSWasmError { + pub kind: TSWasmErrorKind, + pub message: *mut ::core::ffi::c_char, +} +extern "C" { + #[doc = " Create a Wasm store."] + pub fn ts_wasm_store_new( + engine: *mut TSWasmEngine, + error: *mut TSWasmError, + ) -> *mut TSWasmStore; +} +extern "C" { + #[doc = " Free the memory associated with the given Wasm store."] + pub fn ts_wasm_store_delete(arg1: *mut TSWasmStore); +} +extern "C" { + #[doc = " Create a language from a buffer of Wasm. The resulting language behaves\n like any other Tree-sitter language, except that in order to use it with\n a parser, that parser must have a Wasm store. Note that the language\n can be used with any Wasm store, it doesn't need to be the same store that\n was used to originally load it."] + pub fn ts_wasm_store_load_language( + arg1: *mut TSWasmStore, + name: *const ::core::ffi::c_char, + wasm: *const ::core::ffi::c_char, + wasm_len: u32, + error: *mut TSWasmError, + ) -> *const TSLanguage; +} +extern "C" { + #[doc = " Get the number of languages instantiated in the given wasm store."] + pub fn ts_wasm_store_language_count(arg1: *const TSWasmStore) -> usize; +} +extern "C" { + #[doc = " Check if the language came from a Wasm module. If so, then in order to use\n this language with a Parser, that parser must have a Wasm store assigned."] + pub fn ts_language_is_wasm(arg1: *const TSLanguage) -> bool; +} +extern "C" { + #[doc = " Assign the given Wasm store to the parser. A parser must have a Wasm store\n in order to use Wasm languages."] + pub fn ts_parser_set_wasm_store(arg1: *mut TSParser, arg2: *mut TSWasmStore); +} +extern "C" { + #[doc = " Remove the parser's current Wasm store and return it. This returns NULL if\n the parser doesn't have a Wasm store."] + pub fn ts_parser_take_wasm_store(arg1: *mut TSParser) -> *mut TSWasmStore; +} +extern "C" { + #[doc = " Set the allocation functions used by the library.\n\n By default, Tree-sitter uses the standard libc allocation functions,\n but aborts the process when an allocation fails. This function lets\n you supply alternative allocation functions at runtime.\n\n If you pass `NULL` for any parameter, Tree-sitter will switch back to\n its default implementation of that function.\n\n If you call this function after the library has already been used, then\n you must ensure that either:\n 1. All the existing objects have been freed.\n 2. The new allocator shares its state with the old one, so it is capable\n of freeing memory that was allocated by the old allocator."] + pub fn ts_set_allocator( + new_malloc: ::core::option::Option< + unsafe extern "C" fn(arg1: usize) -> *mut ::core::ffi::c_void, + >, + new_calloc: ::core::option::Option< + unsafe extern "C" fn(arg1: usize, arg2: usize) -> *mut ::core::ffi::c_void, + >, + new_realloc: ::core::option::Option< + unsafe extern "C" fn( + arg1: *mut ::core::ffi::c_void, + arg2: usize, + ) -> *mut ::core::ffi::c_void, + >, + new_free: ::core::option::Option, + ); +} diff --git a/vendor/tree-sitter/binding_rust/build.rs b/vendor/tree-sitter/binding_rust/build.rs new file mode 100644 index 0000000..a566419 --- /dev/null +++ b/vendor/tree-sitter/binding_rust/build.rs @@ -0,0 +1,122 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + + #[cfg(feature = "bindgen")] + generate_bindings(&out_dir); + + fs::copy( + "src/wasm/stdlib-symbols.txt", + out_dir.join("stdlib-symbols.txt"), + ) + .unwrap(); + + let mut config = cc::Build::new(); + + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_WASM"); + if env::var("CARGO_FEATURE_WASM").is_ok() { + config + .define("TREE_SITTER_FEATURE_WASM", "") + .define("static_assert(...)", "") + .include(env::var("DEP_WASMTIME_C_API_INCLUDE").unwrap()); + } + + let manifest_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let include_path = manifest_path.join("include"); + let src_path = manifest_path.join("src"); + let wasm_path = src_path.join("wasm"); + for entry in fs::read_dir(&src_path).unwrap() { + let entry = entry.unwrap(); + let path = src_path.join(entry.file_name()); + println!("cargo:rerun-if-changed={}", path.to_str().unwrap()); + } + + config + .flag_if_supported("-std=c11") + .flag_if_supported("-fvisibility=hidden") + .flag_if_supported("-Wshadow") + .flag_if_supported("-Wno-unused-parameter") + .flag_if_supported("-Wno-incompatible-pointer-types") + .include(&src_path) + .include(&wasm_path) + .include(&include_path) + .define("_POSIX_C_SOURCE", "200112L") + .define("_DEFAULT_SOURCE", None) + .define("_DARWIN_C_SOURCE", None) + .warnings(false) + .file(src_path.join("lib.c")) + .compile("tree-sitter"); + + println!("cargo:include={}", include_path.display()); +} + +#[cfg(feature = "bindgen")] +fn generate_bindings(out_dir: &std::path::Path) { + use std::{process::Command, str::FromStr}; + + use bindgen::RustTarget; + + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1"]) + .output() + .unwrap(); + + let metadata = serde_json::from_slice::(&output.stdout).unwrap(); + + let Some(rust_version) = metadata + .get("packages") + .and_then(|packages| packages.as_array()) + .and_then(|packages| { + packages.iter().find_map(|package| { + if package["name"] == "tree-sitter" { + package.get("rust_version").and_then(|v| v.as_str()) + } else { + None + } + }) + }) + else { + panic!("Failed to find tree-sitter package in cargo metadata"); + }; + + const HEADER_PATH: &str = "include/tree_sitter/api.h"; + + println!("cargo:rerun-if-changed={HEADER_PATH}"); + + let no_copy = [ + "TSInput", + "TSLanguage", + "TSLogger", + "TSLookaheadIterator", + "TSParser", + "TSTree", + "TSQuery", + "TSQueryCursor", + "TSQueryCapture", + "TSQueryMatch", + "TSQueryPredicateStep", + ]; + + let bindings = bindgen::Builder::default() + .header(HEADER_PATH) + .layout_tests(false) + .allowlist_type("^TS.*") + .allowlist_function("^ts_.*") + .allowlist_var("^TREE_SITTER.*") + .no_copy(no_copy.join("|")) + .prepend_enum_name(false) + .use_core() + .clang_arg("-D TREE_SITTER_FEATURE_WASM") + .rust_target(RustTarget::from_str(rust_version).unwrap()) + .generate() + .expect("Failed to generate bindings"); + + let bindings_rs = out_dir.join("bindings.rs"); + bindings.write_to_file(&bindings_rs).unwrap_or_else(|_| { + panic!( + "Failed to write bindings into path: {}", + bindings_rs.display() + ) + }); +} diff --git a/vendor/tree-sitter/binding_rust/ffi.rs b/vendor/tree-sitter/binding_rust/ffi.rs new file mode 100644 index 0000000..4c68a63 --- /dev/null +++ b/vendor/tree-sitter/binding_rust/ffi.rs @@ -0,0 +1,216 @@ +#![allow(dead_code)] +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(clippy::missing_const_for_fn)] + +#[cfg(feature = "bindgen")] +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); + +#[cfg(not(feature = "bindgen"))] +include!("./bindings.rs"); + +#[cfg(unix)] +#[cfg(feature = "std")] +extern "C" { + pub(crate) fn _ts_dup(fd: std::os::raw::c_int) -> std::os::raw::c_int; +} + +#[cfg(windows)] +#[cfg(feature = "std")] +extern "C" { + pub(crate) fn _ts_dup(handle: *mut std::os::raw::c_void) -> std::os::raw::c_int; +} + +use core::{marker::PhantomData, mem::ManuallyDrop, ptr::NonNull, str}; + +use crate::{ + Language, LookaheadIterator, Node, ParseState, Parser, Query, QueryCursor, QueryCursorState, + QueryError, Tree, TreeCursor, +}; + +impl Language { + /// Reconstructs a [`Language`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *const TSLanguage) -> Self { + Self(ptr) + } + + /// Consumes the [`Language`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *const TSLanguage { + ManuallyDrop::new(self).0 + } +} + +impl Parser { + /// Reconstructs a [`Parser`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *mut TSParser) -> Self { + Self(NonNull::new_unchecked(ptr)) + } + + /// Consumes the [`Parser`], returning a raw pointer to the underlying C structure. + /// + /// # Safety + /// + /// It's a caller responsibility to adjust parser's state + /// like disable logging or dot graphs printing if this + /// may cause issues like use after free. + #[must_use] + pub fn into_raw(self) -> *mut TSParser { + ManuallyDrop::new(self).0.as_ptr() + } +} + +impl ParseState { + /// Reconstructs a [`ParseState`] from a raw pointer + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *mut TSParseState) -> Self { + Self(NonNull::new_unchecked(ptr)) + } + + /// Consumes the [`ParseState`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *mut TSParseState { + ManuallyDrop::new(self).0.as_ptr() + } +} + +impl Tree { + /// Reconstructs a [`Tree`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *mut TSTree) -> Self { + Self(NonNull::new_unchecked(ptr)) + } + + /// Consumes the [`Tree`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *mut TSTree { + ManuallyDrop::new(self).0.as_ptr() + } +} + +impl Node<'_> { + /// Reconstructs a [`Node`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(raw: TSNode) -> Self { + Self(raw, PhantomData) + } + + /// Consumes the [`Node`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> TSNode { + ManuallyDrop::new(self).0 + } +} + +impl TreeCursor<'_> { + /// Reconstructs a [`TreeCursor`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(raw: TSTreeCursor) -> Self { + Self(raw, PhantomData) + } + + /// Consumes the [`TreeCursor`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> TSTreeCursor { + ManuallyDrop::new(self).0 + } +} + +impl Query { + /// Reconstructs a [`Query`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + pub unsafe fn from_raw(ptr: *mut TSQuery, source: &str) -> Result { + Self::from_raw_parts(ptr, source) + } + + /// Consumes the [`Query`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *mut TSQuery { + ManuallyDrop::new(self).ptr.as_ptr() + } +} + +impl QueryCursor { + /// Reconstructs a [`QueryCursor`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *mut TSQueryCursor) -> Self { + Self { + ptr: NonNull::new_unchecked(ptr), + } + } + + /// Consumes the [`QueryCursor`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *mut TSQueryCursor { + ManuallyDrop::new(self).ptr.as_ptr() + } +} + +impl QueryCursorState { + /// Reconstructs a [`QueryCursorState`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *mut TSQueryCursorState) -> Self { + Self(NonNull::new_unchecked(ptr)) + } + + /// Consumes the [`QueryCursorState`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *mut TSQueryCursorState { + ManuallyDrop::new(self).0.as_ptr() + } +} + +impl LookaheadIterator { + /// Reconstructs a [`LookaheadIterator`] from a raw pointer. + /// + /// # Safety + /// + /// `ptr` must be non-null. + #[must_use] + pub const unsafe fn from_raw(ptr: *mut TSLookaheadIterator) -> Self { + Self(NonNull::new_unchecked(ptr)) + } + + /// Consumes the [`LookaheadIterator`], returning a raw pointer to the underlying C structure. + #[must_use] + pub fn into_raw(self) -> *mut TSLookaheadIterator { + ManuallyDrop::new(self).0.as_ptr() + } +} diff --git a/vendor/tree-sitter/binding_rust/lib.rs b/vendor/tree-sitter/binding_rust/lib.rs new file mode 100644 index 0000000..e5b0616 --- /dev/null +++ b/vendor/tree-sitter/binding_rust/lib.rs @@ -0,0 +1,3948 @@ +#![doc = include_str!("./README.md")] +#![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub mod ffi; +mod util; + +#[cfg(not(feature = "std"))] +extern crate alloc; +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, format, string::String, string::ToString, vec::Vec}; +use core::{ + ffi::{c_char, c_void, CStr}, + fmt::{self, Write}, + hash, iter, + marker::PhantomData, + mem::MaybeUninit, + num::NonZeroU16, + ops::{self, Deref}, + ptr::{self, NonNull}, + slice, str, + sync::atomic::AtomicUsize, +}; +#[cfg(feature = "std")] +use std::error; +#[cfg(all(unix, feature = "std"))] +use std::os::fd::AsRawFd; +#[cfg(all(windows, feature = "std"))] +use std::os::windows::io::AsRawHandle; + +pub use streaming_iterator::{StreamingIterator, StreamingIteratorMut}; +use tree_sitter_language::LanguageFn; + +#[cfg(feature = "wasm")] +mod wasm_language; +#[cfg(feature = "wasm")] +#[cfg_attr(docsrs, doc(cfg(feature = "wasm")))] +pub use wasm_language::*; + +/// The latest ABI version that is supported by the current version of the +/// library. +/// +/// When Languages are generated by the Tree-sitter CLI, they are +/// assigned an ABI version number that corresponds to the current CLI version. +/// The Tree-sitter library is generally backwards-compatible with languages +/// generated using older CLI versions, but is not forwards-compatible. +#[doc(alias = "TREE_SITTER_LANGUAGE_VERSION")] +pub const LANGUAGE_VERSION: usize = ffi::TREE_SITTER_LANGUAGE_VERSION as usize; + +/// The earliest ABI version that is supported by the current version of the +/// library. +#[doc(alias = "TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION")] +pub const MIN_COMPATIBLE_LANGUAGE_VERSION: usize = + ffi::TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION as usize; + +pub const PARSER_HEADER: &str = include_str!("../src/parser.h"); + +/// An opaque object that defines how to parse a particular language. The code +/// for each `Language` is generated by the Tree-sitter CLI. +#[doc(alias = "TSLanguage")] +#[derive(Debug, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct Language(*const ffi::TSLanguage); + +pub struct LanguageRef<'a>(*const ffi::TSLanguage, PhantomData<&'a ()>); + +/// The metadata associated with a language. +/// +/// Currently, this metadata can be used to check the [Semantic Version](https://semver.org/) +/// of the language. This version information should be used to signal if a given parser might +/// be incompatible with existing queries when upgrading between major versions, or minor versions +/// if it's in zerover. +#[doc(alias = "TSLanguageMetadata")] +pub struct LanguageMetadata { + pub major_version: u8, + pub minor_version: u8, + pub patch_version: u8, +} + +impl From for LanguageMetadata { + fn from(val: ffi::TSLanguageMetadata) -> Self { + Self { + major_version: val.major_version, + minor_version: val.minor_version, + patch_version: val.patch_version, + } + } +} + +/// A tree that represents the syntactic structure of a source code file. +#[doc(alias = "TSTree")] +pub struct Tree(NonNull); + +/// A position in a multi-line text document, in terms of rows and columns. +/// +/// Rows and columns are zero-based. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Point { + pub row: usize, + pub column: usize, +} + +/// A range of positions in a multi-line text document, both in terms of bytes +/// and of rows and columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Range { + pub start_byte: usize, + pub end_byte: usize, + pub start_point: Point, + pub end_point: Point, +} + +/// A summary of a change to a text document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputEdit { + pub start_byte: usize, + pub old_end_byte: usize, + pub new_end_byte: usize, + pub start_position: Point, + pub old_end_position: Point, + pub new_end_position: Point, +} + +/// A single node within a syntax [`Tree`]. +#[doc(alias = "TSNode")] +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct Node<'tree>(ffi::TSNode, PhantomData<&'tree ()>); + +/// A stateful object that this is used to produce a [`Tree`] based on some +/// source code. +#[doc(alias = "TSParser")] +pub struct Parser(NonNull); + +/// A stateful object that is used to look up symbols valid in a specific parse +/// state +#[doc(alias = "TSLookaheadIterator")] +pub struct LookaheadIterator(NonNull); +struct LookaheadNamesIterator<'a>(&'a mut LookaheadIterator); + +/// A stateful object that is passed into a [`ParseProgressCallback`] +/// to pass in the current state of the parser. +pub struct ParseState(NonNull); + +impl ParseState { + #[must_use] + pub const fn current_byte_offset(&self) -> usize { + unsafe { self.0.as_ref() }.current_byte_offset as usize + } + + #[must_use] + pub const fn has_error(&self) -> bool { + unsafe { self.0.as_ref() }.has_error + } +} + +/// A stateful object that is passed into a [`QueryProgressCallback`] +/// to pass in the current state of the query execution. +pub struct QueryCursorState(NonNull); + +impl QueryCursorState { + #[must_use] + pub const fn current_byte_offset(&self) -> usize { + unsafe { self.0.as_ref() }.current_byte_offset as usize + } +} + +#[derive(Default)] +pub struct ParseOptions<'a> { + pub progress_callback: Option>, +} + +impl<'a> ParseOptions<'a> { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn progress_callback bool>(mut self, callback: &'a mut F) -> Self { + self.progress_callback = Some(callback); + self + } +} + +#[derive(Default)] +pub struct QueryCursorOptions<'a> { + pub progress_callback: Option>, +} + +impl<'a> QueryCursorOptions<'a> { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn progress_callback bool>( + mut self, + callback: &'a mut F, + ) -> Self { + self.progress_callback = Some(callback); + self + } +} + +struct QueryCursorOptionsDrop(*mut ffi::TSQueryCursorOptions); + +impl Drop for QueryCursorOptionsDrop { + fn drop(&mut self) { + unsafe { + if !(*self.0).payload.is_null() { + drop(Box::from_raw( + (*self.0).payload.cast::(), + )); + } + drop(Box::from_raw(self.0)); + } + } +} + +/// A type of log message. +#[derive(Debug, PartialEq, Eq)] +pub enum LogType { + Parse, + Lex, +} + +type FieldId = NonZeroU16; + +/// A callback that receives log messages during parsing. +type Logger<'a> = Box; + +/// A callback that receives the parse state during parsing. +type ParseProgressCallback<'a> = &'a mut dyn FnMut(&ParseState) -> bool; + +/// A callback that receives the query state during query execution. +type QueryProgressCallback<'a> = &'a mut dyn FnMut(&QueryCursorState) -> bool; + +pub trait Decode { + /// A callback that decodes the next code point from the input slice. It should return the code + /// point, and how many bytes were decoded. + fn decode(bytes: &[u8]) -> (i32, u32); +} + +/// A stateful object for walking a syntax [`Tree`] efficiently. +#[doc(alias = "TSTreeCursor")] +pub struct TreeCursor<'cursor>(ffi::TSTreeCursor, PhantomData<&'cursor ()>); + +/// A set of patterns that match nodes in a syntax tree. +#[doc(alias = "TSQuery")] +#[derive(Debug)] +#[allow(clippy::type_complexity)] +pub struct Query { + ptr: NonNull, + capture_names: Box<[&'static str]>, + capture_quantifiers: Box<[Box<[CaptureQuantifier]>]>, + text_predicates: Box<[Box<[TextPredicateCapture]>]>, + property_settings: Box<[Box<[QueryProperty]>]>, + property_predicates: Box<[Box<[(QueryProperty, bool)]>]>, + general_predicates: Box<[Box<[QueryPredicate]>]>, +} + +/// A quantifier for captures +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum CaptureQuantifier { + Zero, + ZeroOrOne, + ZeroOrMore, + One, + OneOrMore, +} + +impl From for CaptureQuantifier { + fn from(value: ffi::TSQuantifier) -> Self { + match value { + ffi::TSQuantifierZero => Self::Zero, + ffi::TSQuantifierZeroOrOne => Self::ZeroOrOne, + ffi::TSQuantifierZeroOrMore => Self::ZeroOrMore, + ffi::TSQuantifierOne => Self::One, + ffi::TSQuantifierOneOrMore => Self::OneOrMore, + _ => panic!("Unrecognized quantifier: {value}"), + } + } +} + +/// A stateful object for executing a [`Query`] on a syntax [`Tree`]. +#[doc(alias = "TSQueryCursor")] +pub struct QueryCursor { + ptr: NonNull, +} + +/// A key-value pair associated with a particular pattern in a [`Query`]. +#[derive(Debug, PartialEq, Eq)] +pub struct QueryProperty { + pub key: Box, + pub value: Option>, + pub capture_id: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum QueryPredicateArg { + Capture(u32), + String(Box), +} + +/// A key-value pair associated with a particular pattern in a [`Query`]. +#[derive(Debug, PartialEq, Eq)] +pub struct QueryPredicate { + pub operator: Box, + pub args: Box<[QueryPredicateArg]>, +} + +/// A match of a [`Query`] to a particular set of [`Node`]s. +pub struct QueryMatch<'cursor, 'tree> { + pub pattern_index: usize, + pub captures: &'cursor [QueryCapture<'tree>], + id: u32, + cursor: *mut ffi::TSQueryCursor, +} + +/// A sequence of [`QueryMatch`]es associated with a given [`QueryCursor`]. +pub struct QueryMatches<'query, 'tree: 'query, T: TextProvider, I: AsRef<[u8]>> { + ptr: *mut ffi::TSQueryCursor, + query: &'query Query, + text_provider: T, + buffer1: Vec, + buffer2: Vec, + current_match: Option>, + _options: Option, + _phantom: PhantomData<(&'tree (), I)>, +} + +/// A sequence of [`QueryCapture`]s associated with a given [`QueryCursor`]. +/// +/// During iteration, each element contains a [`QueryMatch`] and index. The index can +/// be used to access the new capture inside of the [`QueryMatch::captures`]'s [`captures`]. +pub struct QueryCaptures<'query, 'tree: 'query, T: TextProvider, I: AsRef<[u8]>> { + ptr: *mut ffi::TSQueryCursor, + query: &'query Query, + text_provider: T, + buffer1: Vec, + buffer2: Vec, + current_match: Option<(QueryMatch<'query, 'tree>, usize)>, + _options: Option, + _phantom: PhantomData<(&'tree (), I)>, +} + +pub trait TextProvider +where + I: AsRef<[u8]>, +{ + type I: Iterator; + fn text(&mut self, node: Node) -> Self::I; +} + +/// A particular [`Node`] that has been captured with a particular name within a +/// [`Query`]. +#[derive(Clone, Copy, Debug)] +#[repr(C)] +pub struct QueryCapture<'tree> { + pub node: Node<'tree>, + pub index: u32, +} + +/// An error that occurred when trying to assign an incompatible [`Language`] to +/// a [`Parser`]. +#[derive(Debug, PartialEq, Eq)] +pub struct LanguageError { + version: usize, +} + +/// An error that occurred in [`Parser::set_included_ranges`]. +#[derive(Debug, PartialEq, Eq)] +pub struct IncludedRangesError(pub usize); + +/// An error that occurred when trying to create a [`Query`]. +#[derive(Debug, PartialEq, Eq)] +pub struct QueryError { + pub row: usize, + pub column: usize, + pub offset: usize, + pub message: String, + pub kind: QueryErrorKind, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum QueryErrorKind { + Syntax, + NodeType, + Field, + Capture, + Predicate, + Structure, + Language, +} + +#[derive(Debug)] +/// The first item is the capture index +/// The next is capture specific, depending on what item is expected +/// The first bool is if the capture is positive +/// The last item is a bool signifying whether or not it's meant to match +/// any or all captures +enum TextPredicateCapture { + EqString(u32, Box, bool, bool), + EqCapture(u32, u32, bool, bool), + #[cfg(feature = "regex-predicates")] + MatchString(u32, regex::bytes::Regex, bool, bool), + AnyString(u32, Box<[Box]>, bool), +} + +// TODO: Remove this struct at some point. If `core::str::lossy::Utf8Lossy` +// is ever stabilized. +pub struct LossyUtf8<'a> { + bytes: &'a [u8], + in_replacement: bool, +} + +impl Language { + #[must_use] + pub fn new(builder: LanguageFn) -> Self { + Self(unsafe { builder.into_raw()().cast() }) + } + + /// Get the name of this language. This returns `None` in older parsers. + #[doc(alias = "ts_language_name")] + #[must_use] + pub fn name(&self) -> Option<&'static str> { + let ptr = unsafe { ffi::ts_language_name(self.0) }; + (!ptr.is_null()).then(|| unsafe { CStr::from_ptr(ptr) }.to_str().unwrap()) + } + + /// Get the ABI version number that indicates which version of the + /// Tree-sitter CLI that was used to generate this [`Language`]. + #[doc(alias = "ts_language_version")] + #[deprecated(since = "0.25.0", note = "Use abi_version instead")] + #[must_use] + pub fn version(&self) -> usize { + unsafe { ffi::ts_language_version(self.0) as usize } + } + + /// Get the ABI version number that indicates which version of the + /// Tree-sitter CLI that was used to generate this [`Language`]. + #[doc(alias = "ts_language_abi_version")] + #[must_use] + pub fn abi_version(&self) -> usize { + unsafe { ffi::ts_language_abi_version(self.0) as usize } + } + + /// Get the metadata for this language. This information is generated by the + /// CLI, and relies on the language author providing the correct metadata in + /// the language's `tree-sitter.json` file. + /// + /// See also [`LanguageMetadata`]. + #[doc(alias = "ts_language_metadata")] + #[must_use] + pub fn metadata(&self) -> Option { + unsafe { + let ptr = ffi::ts_language_metadata(self.0); + (!ptr.is_null()).then(|| (*ptr).into()) + } + } + + /// Get the number of distinct node types in this language. + #[doc(alias = "ts_language_symbol_count")] + #[must_use] + pub fn node_kind_count(&self) -> usize { + unsafe { ffi::ts_language_symbol_count(self.0) as usize } + } + + /// Get the number of valid states in this language. + #[doc(alias = "ts_language_state_count")] + #[must_use] + pub fn parse_state_count(&self) -> usize { + unsafe { ffi::ts_language_state_count(self.0) as usize } + } + + /// Get a list of all supertype symbols for the language. + #[doc(alias = "ts_language_supertypes")] + #[must_use] + pub fn supertypes(&self) -> &[u16] { + let mut length = 0u32; + unsafe { + let ptr = ffi::ts_language_supertypes(self.0, core::ptr::addr_of_mut!(length)); + if length == 0 { + &[] + } else { + slice::from_raw_parts(ptr.cast_mut(), length as usize) + } + } + } + + /// Get a list of all subtype symbols for a given supertype symbol. + #[doc(alias = "ts_language_supertype_map")] + #[must_use] + pub fn subtypes_for_supertype(&self, supertype: u16) -> &[u16] { + unsafe { + let mut length = 0u32; + let ptr = ffi::ts_language_subtypes(self.0, supertype, core::ptr::addr_of_mut!(length)); + if length == 0 { + &[] + } else { + slice::from_raw_parts(ptr.cast_mut(), length as usize) + } + } + } + + /// Get the name of the node kind for the given numerical id. + #[doc(alias = "ts_language_symbol_name")] + #[must_use] + pub fn node_kind_for_id(&self, id: u16) -> Option<&'static str> { + let ptr = unsafe { ffi::ts_language_symbol_name(self.0, id) }; + (!ptr.is_null()).then(|| unsafe { CStr::from_ptr(ptr) }.to_str().unwrap()) + } + + /// Get the numeric id for the given node kind. + #[doc(alias = "ts_language_symbol_for_name")] + #[must_use] + pub fn id_for_node_kind(&self, kind: &str, named: bool) -> u16 { + unsafe { + ffi::ts_language_symbol_for_name( + self.0, + kind.as_bytes().as_ptr().cast::(), + kind.len() as u32, + named, + ) + } + } + + /// Check if the node type for the given numerical id is named (as opposed + /// to an anonymous node type). + #[must_use] + pub fn node_kind_is_named(&self, id: u16) -> bool { + unsafe { ffi::ts_language_symbol_type(self.0, id) == ffi::TSSymbolTypeRegular } + } + + /// Check if the node type for the given numerical id is visible (as opposed + /// to a hidden node type). + #[must_use] + pub fn node_kind_is_visible(&self, id: u16) -> bool { + unsafe { ffi::ts_language_symbol_type(self.0, id) <= ffi::TSSymbolTypeAnonymous } + } + + /// Check if the node type for the given numerical id is a supertype. + #[must_use] + pub fn node_kind_is_supertype(&self, id: u16) -> bool { + unsafe { ffi::ts_language_symbol_type(self.0, id) == ffi::TSSymbolTypeSupertype } + } + + /// Get the number of distinct field names in this language. + #[doc(alias = "ts_language_field_count")] + #[must_use] + pub fn field_count(&self) -> usize { + unsafe { ffi::ts_language_field_count(self.0) as usize } + } + + /// Get the field name for the given numerical id. + #[doc(alias = "ts_language_field_name_for_id")] + #[must_use] + pub fn field_name_for_id(&self, field_id: u16) -> Option<&'static str> { + let ptr = unsafe { ffi::ts_language_field_name_for_id(self.0, field_id) }; + (!ptr.is_null()).then(|| unsafe { CStr::from_ptr(ptr) }.to_str().unwrap()) + } + + /// Get the numerical id for the given field name. + #[doc(alias = "ts_language_field_id_for_name")] + #[must_use] + pub fn field_id_for_name(&self, field_name: impl AsRef<[u8]>) -> Option { + let field_name = field_name.as_ref(); + let id = unsafe { + ffi::ts_language_field_id_for_name( + self.0, + field_name.as_ptr().cast::(), + field_name.len() as u32, + ) + }; + FieldId::new(id) + } + + /// Get the next parse state. Combine this with + /// [`lookahead_iterator`](Language::lookahead_iterator) to + /// generate completion suggestions or valid symbols in error nodes. + /// + /// Example: + /// ``` + /// let state = language.next_state(node.parse_state(), node.grammar_id()); + /// ``` + #[doc(alias = "ts_language_next_state")] + #[must_use] + pub fn next_state(&self, state: u16, id: u16) -> u16 { + unsafe { ffi::ts_language_next_state(self.0, state, id) } + } + + /// Create a new lookahead iterator for this language and parse state. + /// + /// This returns `None` if state is invalid for this language. + /// + /// Iterating [`LookaheadIterator`] will yield valid symbols in the given + /// parse state. Newly created lookahead iterators will return the `ERROR` + /// symbol from [`LookaheadIterator::current_symbol`]. + /// + /// Lookahead iterators can be useful to generate suggestions and improve + /// syntax error diagnostics. To get symbols valid in an `ERROR` node, use the + /// lookahead iterator on its first leaf node state. For `MISSING` nodes, a + /// lookahead iterator created on the previous non-extra leaf node may be + /// appropriate. + #[doc(alias = "ts_lookahead_iterator_new")] + #[must_use] + pub fn lookahead_iterator(&self, state: u16) -> Option { + let ptr = unsafe { ffi::ts_lookahead_iterator_new(self.0, state) }; + (!ptr.is_null()).then(|| unsafe { LookaheadIterator::from_raw(ptr) }) + } +} + +impl From for Language { + fn from(value: LanguageFn) -> Self { + Self::new(value) + } +} + +impl Clone for Language { + fn clone(&self) -> Self { + unsafe { Self(ffi::ts_language_copy(self.0)) } + } +} + +impl Drop for Language { + fn drop(&mut self) { + unsafe { ffi::ts_language_delete(self.0) } + } +} + +impl Deref for LanguageRef<'_> { + type Target = Language; + + fn deref(&self) -> &Self::Target { + unsafe { &*(core::ptr::addr_of!(self.0).cast::()) } + } +} + +impl Default for Parser { + fn default() -> Self { + Self::new() + } +} + +impl Parser { + /// Create a new parser. + #[doc(alias = "ts_parser_new")] + #[must_use] + pub fn new() -> Self { + unsafe { + let parser = ffi::ts_parser_new(); + Self(NonNull::new_unchecked(parser)) + } + } + + /// Set the language that the parser should use for parsing. + /// + /// Returns a Result indicating whether or not the language was successfully + /// assigned. True means assignment succeeded. False means there was a + /// version mismatch: the language was generated with an incompatible + /// version of the Tree-sitter CLI. Check the language's version using + /// [`Language::version`] and compare it to this library's + /// [`LANGUAGE_VERSION`] and [`MIN_COMPATIBLE_LANGUAGE_VERSION`] constants. + #[doc(alias = "ts_parser_set_language")] + pub fn set_language(&mut self, language: &Language) -> Result<(), LanguageError> { + let version = language.abi_version(); + if (MIN_COMPATIBLE_LANGUAGE_VERSION..=LANGUAGE_VERSION).contains(&version) { + unsafe { + ffi::ts_parser_set_language(self.0.as_ptr(), language.0); + } + Ok(()) + } else { + Err(LanguageError { version }) + } + } + + /// Get the parser's current language. + #[doc(alias = "ts_parser_language")] + #[must_use] + pub fn language(&self) -> Option> { + let ptr = unsafe { ffi::ts_parser_language(self.0.as_ptr()) }; + (!ptr.is_null()).then_some(LanguageRef(ptr, PhantomData)) + } + + /// Get the parser's current logger. + #[doc(alias = "ts_parser_logger")] + #[must_use] + pub fn logger(&self) -> Option<&Logger> { + let logger = unsafe { ffi::ts_parser_logger(self.0.as_ptr()) }; + unsafe { logger.payload.cast::().as_ref() } + } + + /// Set the logging callback that the parser should use during parsing. + #[doc(alias = "ts_parser_set_logger")] + pub fn set_logger(&mut self, logger: Option) { + let prev_logger = unsafe { ffi::ts_parser_logger(self.0.as_ptr()) }; + if !prev_logger.payload.is_null() { + drop(unsafe { Box::from_raw(prev_logger.payload.cast::()) }); + } + + let c_logger = if let Some(logger) = logger { + let container = Box::new(logger); + + unsafe extern "C" fn log( + payload: *mut c_void, + c_log_type: ffi::TSLogType, + c_message: *const c_char, + ) { + let callback = payload.cast::().as_mut().unwrap(); + if let Ok(message) = CStr::from_ptr(c_message).to_str() { + let log_type = if c_log_type == ffi::TSLogTypeParse { + LogType::Parse + } else { + LogType::Lex + }; + callback(log_type, message); + } + } + + let raw_container = Box::into_raw(container); + + ffi::TSLogger { + payload: raw_container.cast::(), + log: Some(log), + } + } else { + ffi::TSLogger { + payload: ptr::null_mut(), + log: None, + } + }; + + unsafe { ffi::ts_parser_set_logger(self.0.as_ptr(), c_logger) }; + } + + /// Set the destination to which the parser should write debugging graphs + /// during parsing. The graphs are formatted in the DOT language. You may + /// want to pipe these graphs directly to a `dot(1)` process in order to + /// generate SVG output. + #[doc(alias = "ts_parser_print_dot_graphs")] + #[cfg(not(target_os = "wasi"))] + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn print_dot_graphs( + &mut self, + #[cfg(unix)] file: &impl AsRawFd, + #[cfg(windows)] file: &impl AsRawHandle, + ) { + #[cfg(unix)] + { + let fd = file.as_raw_fd(); + unsafe { + ffi::ts_parser_print_dot_graphs(self.0.as_ptr(), ffi::_ts_dup(fd)); + } + } + + #[cfg(windows)] + { + let handle = file.as_raw_handle(); + unsafe { + ffi::ts_parser_print_dot_graphs(self.0.as_ptr(), ffi::_ts_dup(handle)); + } + } + } + + /// Stop the parser from printing debugging graphs while parsing. + #[doc(alias = "ts_parser_print_dot_graphs")] + #[cfg(not(target_os = "wasi"))] + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn stop_printing_dot_graphs(&mut self) { + unsafe { ffi::ts_parser_print_dot_graphs(self.0.as_ptr(), -1) } + } + + /// Parse a slice of UTF8 text. + /// + /// # Arguments: + /// * `text` The UTF8-encoded text to parse. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + /// + /// Returns a [`Tree`] if parsing succeeded, or `None` if: + /// * The parser has not yet had a language assigned with [`Parser::set_language`] + /// * The timeout set with [`Parser::set_timeout_micros`] expired (deprecated) + /// * The cancellation flag set with [`Parser::set_cancellation_flag`] was flipped (deprecated) + #[doc(alias = "ts_parser_parse")] + pub fn parse(&mut self, text: impl AsRef<[u8]>, old_tree: Option<&Tree>) -> Option { + let bytes = text.as_ref(); + let len = bytes.len(); + self.parse_with_options( + &mut |i, _| (i < len).then(|| &bytes[i..]).unwrap_or_default(), + old_tree, + None, + ) + } + + /// Parse a slice of UTF16 text. + /// + /// # Arguments: + /// * `text` The UTF16-encoded text to parse. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + #[deprecated(since = "0.25.0", note = "Prefer parse_utf16_le instead")] + pub fn parse_utf16( + &mut self, + input: impl AsRef<[u16]>, + old_tree: Option<&Tree>, + ) -> Option { + let code_points = input.as_ref(); + let len = code_points.len(); + self.parse_utf16_le_with_options( + &mut |i, _| (i < len).then(|| &code_points[i..]).unwrap_or_default(), + old_tree, + None, + ) + } + + /// Parse UTF8 text provided in chunks by a callback. + /// + /// # Arguments: + /// * `callback` A function that takes a byte offset and position and returns a slice of + /// UTF8-encoded text starting at that byte offset and position. The slices can be of any + /// length. If the given position is at the end of the text, the callback should return an + /// empty slice. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + #[deprecated(since = "0.25.0", note = "Prefer `parse_with_options` instead")] + pub fn parse_with, F: FnMut(usize, Point) -> T>( + &mut self, + callback: &mut F, + old_tree: Option<&Tree>, + ) -> Option { + self.parse_with_options(callback, old_tree, None) + } + + /// Parse text provided in chunks by a callback. + /// + /// # Arguments: + /// * `callback` A function that takes a byte offset and position and returns a slice of + /// UTF8-encoded text starting at that byte offset and position. The slices can be of any + /// length. If the given position is at the end of the text, the callback should return an + /// empty slice. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + /// * `options` Options for parsing the text. This can be used to set a progress callback. + pub fn parse_with_options, F: FnMut(usize, Point) -> T>( + &mut self, + callback: &mut F, + old_tree: Option<&Tree>, + options: Option, + ) -> Option { + type Payload<'a, F, T> = (&'a mut F, Option); + + // This C function is passed to Tree-sitter as the progress callback. + unsafe extern "C" fn progress(state: *mut ffi::TSParseState) -> bool { + let callback = (*state) + .payload + .cast::() + .as_mut() + .unwrap(); + callback(&ParseState::from_raw(state)) + } + + // This C function is passed to Tree-sitter as the input callback. + unsafe extern "C" fn read, F: FnMut(usize, Point) -> T>( + payload: *mut c_void, + byte_offset: u32, + position: ffi::TSPoint, + bytes_read: *mut u32, + ) -> *const c_char { + let (callback, text) = payload.cast::>().as_mut().unwrap(); + *text = Some(callback(byte_offset as usize, position.into())); + let slice = text.as_ref().unwrap().as_ref(); + *bytes_read = slice.len() as u32; + slice.as_ptr().cast::() + } + + let empty_options = ffi::TSParseOptions { + payload: ptr::null_mut(), + progress_callback: None, + }; + + let mut callback_ptr; + let parse_options = if let Some(options) = options { + if let Some(cb) = options.progress_callback { + callback_ptr = cb; + ffi::TSParseOptions { + payload: core::ptr::addr_of_mut!(callback_ptr).cast::(), + progress_callback: Some(progress), + } + } else { + empty_options + } + } else { + empty_options + }; + + // A pointer to this payload is passed on every call to the `read` C function. + // The payload contains two things: + // 1. A reference to the rust `callback`. + // 2. The text that was returned from the previous call to `callback`. This allows the + // callback to return owned values like vectors. + let mut payload: Payload = (callback, None); + + let c_input = ffi::TSInput { + payload: ptr::addr_of_mut!(payload).cast::(), + read: Some(read::), + encoding: ffi::TSInputEncodingUTF8, + decode: None, + }; + + let c_old_tree = old_tree.map_or(ptr::null_mut(), |t| t.0.as_ptr()); + unsafe { + let c_new_tree = ffi::ts_parser_parse_with_options( + self.0.as_ptr(), + c_old_tree, + c_input, + parse_options, + ); + + NonNull::new(c_new_tree).map(Tree) + } + } + + /// Parse UTF16 text provided in chunks by a callback. + /// + /// # Arguments: + /// * `callback` A function that takes a code point offset and position and returns a slice of + /// UTF16-encoded text starting at that byte offset and position. The slices can be of any + /// length. If the given position is at the end of the text, the callback should return an + /// empty slice. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + #[deprecated( + since = "0.25.0", + note = "Prefer `parse_utf16_le_with_options` instead" + )] + pub fn parse_utf16_with, F: FnMut(usize, Point) -> T>( + &mut self, + callback: &mut F, + old_tree: Option<&Tree>, + ) -> Option { + self.parse_utf16_le_with_options(callback, old_tree, None) + } + + /// Parse a slice of UTF16 little-endian text. + /// + /// # Arguments: + /// * `text` The UTF16-encoded text to parse. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + pub fn parse_utf16_le( + &mut self, + input: impl AsRef<[u16]>, + old_tree: Option<&Tree>, + ) -> Option { + let code_points = input.as_ref(); + let len = code_points.len(); + self.parse_utf16_le_with_options( + &mut |i, _| (i < len).then(|| &code_points[i..]).unwrap_or_default(), + old_tree, + None, + ) + } + + /// Parse UTF16 little-endian text provided in chunks by a callback. + /// + /// # Arguments: + /// * `callback` A function that takes a code point offset and position and returns a slice of + /// UTF16-encoded text starting at that byte offset and position. The slices can be of any + /// length. If the given position is at the end of the text, the callback should return an + /// empty slice. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + /// * `options` Options for parsing the text. This can be used to set a progress callback. + pub fn parse_utf16_le_with_options, F: FnMut(usize, Point) -> T>( + &mut self, + callback: &mut F, + old_tree: Option<&Tree>, + options: Option, + ) -> Option { + type Payload<'a, F, T> = (&'a mut F, Option); + + unsafe extern "C" fn progress(state: *mut ffi::TSParseState) -> bool { + let callback = (*state) + .payload + .cast::() + .as_mut() + .unwrap(); + callback(&ParseState::from_raw(state)) + } + + // This C function is passed to Tree-sitter as the input callback. + unsafe extern "C" fn read, F: FnMut(usize, Point) -> T>( + payload: *mut c_void, + byte_offset: u32, + position: ffi::TSPoint, + bytes_read: *mut u32, + ) -> *const c_char { + let (callback, text) = payload.cast::>().as_mut().unwrap(); + *text = Some(callback( + (byte_offset / 2) as usize, + Point { + row: position.row as usize, + column: position.column as usize / 2, + }, + )); + let slice = text.as_ref().unwrap().as_ref(); + *bytes_read = slice.len() as u32 * 2; + slice.as_ptr().cast::() + } + + let empty_options = ffi::TSParseOptions { + payload: ptr::null_mut(), + progress_callback: None, + }; + + let mut callback_ptr; + let parse_options = if let Some(options) = options { + if let Some(cb) = options.progress_callback { + callback_ptr = cb; + ffi::TSParseOptions { + payload: core::ptr::addr_of_mut!(callback_ptr).cast::(), + progress_callback: Some(progress), + } + } else { + empty_options + } + } else { + empty_options + }; + + // A pointer to this payload is passed on every call to the `read` C function. + // The payload contains two things: + // 1. A reference to the rust `callback`. + // 2. The text that was returned from the previous call to `callback`. This allows the + // callback to return owned values like vectors. + let mut payload: Payload = (callback, None); + + let c_input = ffi::TSInput { + payload: core::ptr::addr_of_mut!(payload).cast::(), + read: Some(read::), + encoding: ffi::TSInputEncodingUTF16LE, + decode: None, + }; + + let c_old_tree = old_tree.map_or(ptr::null_mut(), |t| t.0.as_ptr()); + unsafe { + let c_new_tree = ffi::ts_parser_parse_with_options( + self.0.as_ptr(), + c_old_tree, + c_input, + parse_options, + ); + + NonNull::new(c_new_tree).map(Tree) + } + } + + /// Parse a slice of UTF16 big-endian text. + /// + /// # Arguments: + /// * `text` The UTF16-encoded text to parse. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + pub fn parse_utf16_be( + &mut self, + input: impl AsRef<[u16]>, + old_tree: Option<&Tree>, + ) -> Option { + let code_points = input.as_ref(); + let len = code_points.len(); + self.parse_utf16_be_with_options( + &mut |i, _| if i < len { &code_points[i..] } else { &[] }, + old_tree, + None, + ) + } + + /// Parse UTF16 big-endian text provided in chunks by a callback. + /// + /// # Arguments: + /// * `callback` A function that takes a code point offset and position and returns a slice of + /// UTF16-encoded text starting at that byte offset and position. The slices can be of any + /// length. If the given position is at the end of the text, the callback should return an + /// empty slice. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + /// * `options` Options for parsing the text. This can be used to set a progress callback. + pub fn parse_utf16_be_with_options, F: FnMut(usize, Point) -> T>( + &mut self, + callback: &mut F, + old_tree: Option<&Tree>, + options: Option, + ) -> Option { + type Payload<'a, F, T> = (&'a mut F, Option); + + // This C function is passed to Tree-sitter as the progress callback. + unsafe extern "C" fn progress(state: *mut ffi::TSParseState) -> bool { + let callback = (*state) + .payload + .cast::() + .as_mut() + .unwrap(); + callback(&ParseState::from_raw(state)) + } + + // This C function is passed to Tree-sitter as the input callback. + unsafe extern "C" fn read, F: FnMut(usize, Point) -> T>( + payload: *mut c_void, + byte_offset: u32, + position: ffi::TSPoint, + bytes_read: *mut u32, + ) -> *const c_char { + let (callback, text) = payload.cast::>().as_mut().unwrap(); + *text = Some(callback( + (byte_offset / 2) as usize, + Point { + row: position.row as usize, + column: position.column as usize / 2, + }, + )); + let slice = text.as_ref().unwrap().as_ref(); + *bytes_read = slice.len() as u32 * 2; + slice.as_ptr().cast::() + } + + let empty_options = ffi::TSParseOptions { + payload: ptr::null_mut(), + progress_callback: None, + }; + + let mut callback_ptr; + let parse_options = if let Some(options) = options { + if let Some(cb) = options.progress_callback { + callback_ptr = cb; + ffi::TSParseOptions { + payload: core::ptr::addr_of_mut!(callback_ptr).cast::(), + progress_callback: Some(progress), + } + } else { + empty_options + } + } else { + empty_options + }; + + // A pointer to this payload is passed on every call to the `read` C function. + // The payload contains two things: + // 1. A reference to the rust `callback`. + // 2. The text that was returned from the previous call to `callback`. This allows the + // callback to return owned values like vectors. + let mut payload: Payload = (callback, None); + + let c_input = ffi::TSInput { + payload: core::ptr::addr_of_mut!(payload).cast::(), + read: Some(read::), + encoding: ffi::TSInputEncodingUTF16BE, + decode: None, + }; + + let c_old_tree = old_tree.map_or(ptr::null_mut(), |t| t.0.as_ptr()); + unsafe { + let c_new_tree = ffi::ts_parser_parse_with_options( + self.0.as_ptr(), + c_old_tree, + c_input, + parse_options, + ); + + NonNull::new(c_new_tree).map(Tree) + } + } + + /// Parse text provided in chunks by a callback using a custom encoding. + /// This is useful for parsing text in encodings that are not UTF-8 or UTF-16. + /// + /// # Arguments: + /// * `callback` A function that takes a byte offset and position and returns a slice of text + /// starting at that byte offset and position. The slices can be of any length. If the given + /// position is at the end of the text, the callback should return an empty slice. + /// * `old_tree` A previous syntax tree parsed from the same document. If the text of the + /// document has changed since `old_tree` was created, then you must edit `old_tree` to match + /// the new text using [`Tree::edit`]. + /// * `options` Options for parsing the text. This can be used to set a progress callback. + /// + /// Additionally, you must set the generic parameter [`D`] to a type that implements the + /// [`Decode`] trait. This trait has a single method, [`decode`](Decode::decode), which takes a + /// slice of bytes and returns a tuple of the code point and the number of bytes consumed. + /// The `decode` method should return `-1` for the code point if decoding fails. + pub fn parse_custom_encoding, F: FnMut(usize, Point) -> T>( + &mut self, + callback: &mut F, + old_tree: Option<&Tree>, + options: Option, + ) -> Option { + type Payload<'a, F, T> = (&'a mut F, Option); + + unsafe extern "C" fn progress(state: *mut ffi::TSParseState) -> bool { + let callback = (*state) + .payload + .cast::() + .as_mut() + .unwrap(); + callback(&ParseState::from_raw(state)) + } + + // At compile time, create a C-compatible callback that calls the custom `decode` method. + unsafe extern "C" fn decode_fn( + data: *const u8, + len: u32, + code_point: *mut i32, + ) -> u32 { + let (c, len) = D::decode(core::slice::from_raw_parts(data, len as usize)); + if let Some(code_point) = code_point.as_mut() { + *code_point = c; + } + len + } + + // This C function is passed to Tree-sitter as the input callback. + unsafe extern "C" fn read, F: FnMut(usize, Point) -> T>( + payload: *mut c_void, + byte_offset: u32, + position: ffi::TSPoint, + bytes_read: *mut u32, + ) -> *const c_char { + let (callback, text) = payload.cast::>().as_mut().unwrap(); + *text = Some(callback(byte_offset as usize, position.into())); + let slice = text.as_ref().unwrap().as_ref(); + *bytes_read = slice.len() as u32; + slice.as_ptr().cast::() + } + + let empty_options = ffi::TSParseOptions { + payload: ptr::null_mut(), + progress_callback: None, + }; + + let mut callback_ptr; + let parse_options = if let Some(options) = options { + if let Some(cb) = options.progress_callback { + callback_ptr = cb; + ffi::TSParseOptions { + payload: core::ptr::addr_of_mut!(callback_ptr).cast::(), + progress_callback: Some(progress), + } + } else { + empty_options + } + } else { + empty_options + }; + + // A pointer to this payload is passed on every call to the `read` C function. + // The payload contains two things: + // 1. A reference to the rust `callback`. + // 2. The text that was returned from the previous call to `callback`. This allows the + // callback to return owned values like vectors. + let mut payload: Payload = (callback, None); + + let c_input = ffi::TSInput { + payload: core::ptr::addr_of_mut!(payload).cast::(), + read: Some(read::), + encoding: ffi::TSInputEncodingCustom, + // Use this custom decode callback + decode: Some(decode_fn::), + }; + + let c_old_tree = old_tree.map_or(ptr::null_mut(), |t| t.0.as_ptr()); + unsafe { + let c_new_tree = ffi::ts_parser_parse_with_options( + self.0.as_ptr(), + c_old_tree, + c_input, + parse_options, + ); + + NonNull::new(c_new_tree).map(Tree) + } + } + + /// Instruct the parser to start the next parse from the beginning. + /// + /// If the parser previously failed because of a timeout, cancellation, + /// or callback, then by default, it will resume where it left off on the + /// next call to [`parse`](Parser::parse) or other parsing functions. + /// If you don't want to resume, and instead intend to use this parser to + /// parse some other document, you must call `reset` first. + #[doc(alias = "ts_parser_reset")] + pub fn reset(&mut self) { + unsafe { ffi::ts_parser_reset(self.0.as_ptr()) } + } + + /// Get the duration in microseconds that parsing is allowed to take. + /// + /// This is set via [`set_timeout_micros`](Parser::set_timeout_micros). + #[doc(alias = "ts_parser_timeout_micros")] + #[deprecated( + since = "0.25.0", + note = "Prefer using `parse_with_options` and using a callback" + )] + #[must_use] + pub fn timeout_micros(&self) -> u64 { + unsafe { ffi::ts_parser_timeout_micros(self.0.as_ptr()) } + } + + /// Set the maximum duration in microseconds that parsing should be allowed + /// to take before halting. + /// + /// If parsing takes longer than this, it will halt early, returning `None`. + /// See [`parse`](Parser::parse) for more information. + #[doc(alias = "ts_parser_set_timeout_micros")] + #[deprecated( + since = "0.25.0", + note = "Prefer using `parse_with_options` and using a callback" + )] + pub fn set_timeout_micros(&mut self, timeout_micros: u64) { + unsafe { ffi::ts_parser_set_timeout_micros(self.0.as_ptr(), timeout_micros) } + } + + /// Set the ranges of text that the parser should include when parsing. + /// + /// By default, the parser will always include entire documents. This + /// function allows you to parse only a *portion* of a document but + /// still return a syntax tree whose ranges match up with the document + /// as a whole. You can also pass multiple disjoint ranges. + /// + /// If `ranges` is empty, then the entire document will be parsed. + /// Otherwise, the given ranges must be ordered from earliest to latest + /// in the document, and they must not overlap. That is, the following + /// must hold for all `i` < `length - 1`: + /// ```text + /// ranges[i].end_byte <= ranges[i + 1].start_byte + /// ``` + /// If this requirement is not satisfied, method will return + /// [`IncludedRangesError`] error with an offset in the passed ranges + /// slice pointing to a first incorrect range. + #[doc(alias = "ts_parser_set_included_ranges")] + pub fn set_included_ranges(&mut self, ranges: &[Range]) -> Result<(), IncludedRangesError> { + let ts_ranges = ranges.iter().copied().map(Into::into).collect::>(); + let result = unsafe { + ffi::ts_parser_set_included_ranges( + self.0.as_ptr(), + ts_ranges.as_ptr(), + ts_ranges.len() as u32, + ) + }; + + if result { + Ok(()) + } else { + let mut prev_end_byte = 0; + for (i, range) in ranges.iter().enumerate() { + if range.start_byte < prev_end_byte || range.end_byte < range.start_byte { + return Err(IncludedRangesError(i)); + } + prev_end_byte = range.end_byte; + } + Err(IncludedRangesError(0)) + } + } + + /// Get the ranges of text that the parser will include when parsing. + #[doc(alias = "ts_parser_included_ranges")] + #[must_use] + pub fn included_ranges(&self) -> Vec { + let mut count = 0u32; + unsafe { + let ptr = + ffi::ts_parser_included_ranges(self.0.as_ptr(), core::ptr::addr_of_mut!(count)); + let ranges = slice::from_raw_parts(ptr, count as usize); + let result = ranges.iter().copied().map(Into::into).collect(); + result + } + } + + /// Get the parser's current cancellation flag pointer. + /// + /// # Safety + /// + /// It uses FFI + #[doc(alias = "ts_parser_cancellation_flag")] + #[deprecated( + since = "0.25.0", + note = "Prefer using `parse_with_options` and using a callback" + )] + #[must_use] + pub unsafe fn cancellation_flag(&self) -> Option<&AtomicUsize> { + ffi::ts_parser_cancellation_flag(self.0.as_ptr()) + .cast::() + .as_ref() + } + + /// Set the parser's current cancellation flag pointer. + /// + /// If a pointer is assigned, then the parser will periodically read from + /// this pointer during parsing. If it reads a non-zero value, it will halt + /// early, returning `None`. See [`parse`](Parser::parse) for more + /// information. + /// + /// # Safety + /// + /// It uses FFI + #[doc(alias = "ts_parser_set_cancellation_flag")] + #[deprecated( + since = "0.25.0", + note = "Prefer using `parse_with_options` and using a callback" + )] + pub unsafe fn set_cancellation_flag(&mut self, flag: Option<&AtomicUsize>) { + if let Some(flag) = flag { + ffi::ts_parser_set_cancellation_flag( + self.0.as_ptr(), + core::ptr::from_ref::(flag).cast::(), + ); + } else { + ffi::ts_parser_set_cancellation_flag(self.0.as_ptr(), ptr::null()); + } + } +} + +impl Drop for Parser { + fn drop(&mut self) { + #[cfg(feature = "std")] + #[cfg(not(target_os = "wasi"))] + { + self.stop_printing_dot_graphs(); + } + self.set_logger(None); + unsafe { ffi::ts_parser_delete(self.0.as_ptr()) } + } +} + +#[cfg(windows)] +extern "C" { + fn _open_osfhandle(osfhandle: isize, flags: core::ffi::c_int) -> core::ffi::c_int; +} + +impl Tree { + /// Get the root node of the syntax tree. + #[doc(alias = "ts_tree_root_node")] + #[must_use] + pub fn root_node(&self) -> Node { + Node::new(unsafe { ffi::ts_tree_root_node(self.0.as_ptr()) }).unwrap() + } + + /// Get the root node of the syntax tree, but with its position shifted + /// forward by the given offset. + #[doc(alias = "ts_tree_root_node_with_offset")] + #[must_use] + pub fn root_node_with_offset(&self, offset_bytes: usize, offset_extent: Point) -> Node { + Node::new(unsafe { + ffi::ts_tree_root_node_with_offset( + self.0.as_ptr(), + offset_bytes as u32, + offset_extent.into(), + ) + }) + .unwrap() + } + + /// Get the language that was used to parse the syntax tree. + #[doc(alias = "ts_tree_language")] + #[must_use] + pub fn language(&self) -> LanguageRef { + LanguageRef( + unsafe { ffi::ts_tree_language(self.0.as_ptr()) }, + PhantomData, + ) + } + + /// Edit the syntax tree to keep it in sync with source code that has been + /// edited. + /// + /// You must describe the edit both in terms of byte offsets and in terms of + /// row/column coordinates. + #[doc(alias = "ts_tree_edit")] + pub fn edit(&mut self, edit: &InputEdit) { + let edit = edit.into(); + unsafe { ffi::ts_tree_edit(self.0.as_ptr(), &edit) }; + } + + /// Create a new [`TreeCursor`] starting from the root of the tree. + #[must_use] + pub fn walk(&self) -> TreeCursor { + self.root_node().walk() + } + + /// Compare this old edited syntax tree to a new syntax tree representing + /// the same document, returning a sequence of ranges whose syntactic + /// structure has changed. + /// + /// For this to work correctly, this syntax tree must have been edited such + /// that its ranges match up to the new tree. Generally, you'll want to + /// call this method right after calling one of the [`Parser::parse`] + /// functions. Call it on the old tree that was passed to parse, and + /// pass the new tree that was returned from `parse`. + #[doc(alias = "ts_tree_get_changed_ranges")] + #[must_use] + pub fn changed_ranges(&self, other: &Self) -> impl ExactSizeIterator { + let mut count = 0u32; + unsafe { + let ptr = ffi::ts_tree_get_changed_ranges( + self.0.as_ptr(), + other.0.as_ptr(), + core::ptr::addr_of_mut!(count), + ); + util::CBufferIter::new(ptr, count as usize).map(Into::into) + } + } + + /// Get the included ranges that were used to parse the syntax tree. + #[doc(alias = "ts_tree_included_ranges")] + #[must_use] + pub fn included_ranges(&self) -> Vec { + let mut count = 0u32; + unsafe { + let ptr = ffi::ts_tree_included_ranges(self.0.as_ptr(), core::ptr::addr_of_mut!(count)); + let ranges = slice::from_raw_parts(ptr, count as usize); + let result = ranges.iter().copied().map(Into::into).collect(); + (FREE_FN)(ptr.cast::()); + result + } + } + + /// Print a graph of the tree to the given file descriptor. + /// The graph is formatted in the DOT language. You may want to pipe this + /// graph directly to a `dot(1)` process in order to generate SVG + /// output. + #[doc(alias = "ts_tree_print_dot_graph")] + #[cfg(not(target_os = "wasi"))] + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn print_dot_graph( + &self, + #[cfg(unix)] file: &impl AsRawFd, + #[cfg(windows)] file: &impl AsRawHandle, + ) { + #[cfg(unix)] + { + let fd = file.as_raw_fd(); + unsafe { ffi::ts_tree_print_dot_graph(self.0.as_ptr(), fd) } + } + + #[cfg(windows)] + { + let handle = file.as_raw_handle(); + let fd = unsafe { _open_osfhandle(handle as isize, 0) }; + unsafe { ffi::ts_tree_print_dot_graph(self.0.as_ptr(), fd) } + } + } +} + +impl fmt::Debug for Tree { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{{Tree {:?}}}", self.root_node()) + } +} + +impl Drop for Tree { + fn drop(&mut self) { + unsafe { ffi::ts_tree_delete(self.0.as_ptr()) } + } +} + +impl Clone for Tree { + fn clone(&self) -> Self { + unsafe { Self(NonNull::new_unchecked(ffi::ts_tree_copy(self.0.as_ptr()))) } + } +} + +impl<'tree> Node<'tree> { + fn new(node: ffi::TSNode) -> Option { + (!node.id.is_null()).then_some(Node(node, PhantomData)) + } + + /// Get a numeric id for this node that is unique. + /// + /// Within a given syntax tree, no two nodes have the same id. However: + /// + /// - If a new tree is created based on an older tree, and a node from the old tree is reused in + /// the process, then that node will have the same id in both trees. + /// + /// - A node not marked as having changes does not guarantee it was reused. + /// + /// - If a node is marked as having changed in the old tree, it will not be reused. + #[must_use] + pub fn id(&self) -> usize { + self.0.id as usize + } + + /// Get this node's type as a numerical id. + #[doc(alias = "ts_node_symbol")] + #[must_use] + pub fn kind_id(&self) -> u16 { + unsafe { ffi::ts_node_symbol(self.0) } + } + + /// Get the node's type as a numerical id as it appears in the grammar + /// ignoring aliases. + #[doc(alias = "ts_node_grammar_symbol")] + #[must_use] + pub fn grammar_id(&self) -> u16 { + unsafe { ffi::ts_node_grammar_symbol(self.0) } + } + + /// Get this node's type as a string. + #[doc(alias = "ts_node_type")] + #[must_use] + pub fn kind(&self) -> &'static str { + unsafe { CStr::from_ptr(ffi::ts_node_type(self.0)) } + .to_str() + .unwrap() + } + + /// Get this node's symbol name as it appears in the grammar ignoring + /// aliases as a string. + #[doc(alias = "ts_node_grammar_type")] + #[must_use] + pub fn grammar_name(&self) -> &'static str { + unsafe { CStr::from_ptr(ffi::ts_node_grammar_type(self.0)) } + .to_str() + .unwrap() + } + + /// Get the [`Language`] that was used to parse this node's syntax tree. + #[doc(alias = "ts_node_language")] + #[must_use] + pub fn language(&self) -> LanguageRef { + LanguageRef(unsafe { ffi::ts_node_language(self.0) }, PhantomData) + } + + /// Check if this node is *named*. + /// + /// Named nodes correspond to named rules in the grammar, whereas + /// *anonymous* nodes correspond to string literals in the grammar. + #[doc(alias = "ts_node_is_named")] + #[must_use] + pub fn is_named(&self) -> bool { + unsafe { ffi::ts_node_is_named(self.0) } + } + + /// Check if this node is *extra*. + /// + /// Extra nodes represent things like comments, which are not required by the + /// grammar, but can appear anywhere. + #[doc(alias = "ts_node_is_extra")] + #[must_use] + pub fn is_extra(&self) -> bool { + unsafe { ffi::ts_node_is_extra(self.0) } + } + + /// Check if this node has been edited. + #[doc(alias = "ts_node_has_changes")] + #[must_use] + pub fn has_changes(&self) -> bool { + unsafe { ffi::ts_node_has_changes(self.0) } + } + + /// Check if this node represents a syntax error or contains any syntax + /// errors anywhere within it. + #[doc(alias = "ts_node_has_error")] + #[must_use] + pub fn has_error(&self) -> bool { + unsafe { ffi::ts_node_has_error(self.0) } + } + + /// Check if this node represents a syntax error. + /// + /// Syntax errors represent parts of the code that could not be incorporated + /// into a valid syntax tree. + #[doc(alias = "ts_node_is_error")] + #[must_use] + pub fn is_error(&self) -> bool { + unsafe { ffi::ts_node_is_error(self.0) } + } + + /// Get this node's parse state. + #[doc(alias = "ts_node_parse_state")] + #[must_use] + pub fn parse_state(&self) -> u16 { + unsafe { ffi::ts_node_parse_state(self.0) } + } + + /// Get the parse state after this node. + #[doc(alias = "ts_node_next_parse_state")] + #[must_use] + pub fn next_parse_state(&self) -> u16 { + unsafe { ffi::ts_node_next_parse_state(self.0) } + } + + /// Check if this node is *missing*. + /// + /// Missing nodes are inserted by the parser in order to recover from + /// certain kinds of syntax errors. + #[doc(alias = "ts_node_is_missing")] + #[must_use] + pub fn is_missing(&self) -> bool { + unsafe { ffi::ts_node_is_missing(self.0) } + } + + /// Get the byte offset where this node starts. + #[doc(alias = "ts_node_start_byte")] + #[must_use] + pub fn start_byte(&self) -> usize { + unsafe { ffi::ts_node_start_byte(self.0) as usize } + } + + /// Get the byte offset where this node ends. + #[doc(alias = "ts_node_end_byte")] + #[must_use] + pub fn end_byte(&self) -> usize { + unsafe { ffi::ts_node_end_byte(self.0) as usize } + } + + /// Get the byte range of source code that this node represents. + #[must_use] + pub fn byte_range(&self) -> core::ops::Range { + self.start_byte()..self.end_byte() + } + + /// Get the range of source code that this node represents, both in terms of + /// raw bytes and of row/column coordinates. + #[must_use] + pub fn range(&self) -> Range { + Range { + start_byte: self.start_byte(), + end_byte: self.end_byte(), + start_point: self.start_position(), + end_point: self.end_position(), + } + } + + /// Get this node's start position in terms of rows and columns. + #[doc(alias = "ts_node_start_point")] + #[must_use] + pub fn start_position(&self) -> Point { + let result = unsafe { ffi::ts_node_start_point(self.0) }; + result.into() + } + + /// Get this node's end position in terms of rows and columns. + #[doc(alias = "ts_node_end_point")] + #[must_use] + pub fn end_position(&self) -> Point { + let result = unsafe { ffi::ts_node_end_point(self.0) }; + result.into() + } + + /// Get the node's child at the given index, where zero represents the first + /// child. + /// + /// This method is fairly fast, but its cost is technically log(i), so if + /// you might be iterating over a long list of children, you should use + /// [`Node::children`] instead. + #[doc(alias = "ts_node_child")] + #[must_use] + pub fn child(&self, i: usize) -> Option { + Self::new(unsafe { ffi::ts_node_child(self.0, i as u32) }) + } + + /// Get this node's number of children. + #[doc(alias = "ts_node_child_count")] + #[must_use] + pub fn child_count(&self) -> usize { + unsafe { ffi::ts_node_child_count(self.0) as usize } + } + + /// Get this node's *named* child at the given index. + /// + /// See also [`Node::is_named`]. + /// This method is fairly fast, but its cost is technically log(i), so if + /// you might be iterating over a long list of children, you should use + /// [`Node::named_children`] instead. + #[doc(alias = "ts_node_named_child")] + #[must_use] + pub fn named_child(&self, i: usize) -> Option { + Self::new(unsafe { ffi::ts_node_named_child(self.0, i as u32) }) + } + + /// Get this node's number of *named* children. + /// + /// See also [`Node::is_named`]. + #[doc(alias = "ts_node_named_child_count")] + #[must_use] + pub fn named_child_count(&self) -> usize { + unsafe { ffi::ts_node_named_child_count(self.0) as usize } + } + + /// Get the first child with the given field name. + /// + /// If multiple children may have the same field name, access them using + /// [`children_by_field_name`](Node::children_by_field_name) + #[doc(alias = "ts_node_child_by_field_name")] + #[must_use] + pub fn child_by_field_name(&self, field_name: impl AsRef<[u8]>) -> Option { + let field_name = field_name.as_ref(); + Self::new(unsafe { + ffi::ts_node_child_by_field_name( + self.0, + field_name.as_ptr().cast::(), + field_name.len() as u32, + ) + }) + } + + /// Get this node's child with the given numerical field id. + /// + /// See also [`child_by_field_name`](Node::child_by_field_name). You can + /// convert a field name to an id using [`Language::field_id_for_name`]. + #[doc(alias = "ts_node_child_by_field_id")] + #[must_use] + pub fn child_by_field_id(&self, field_id: u16) -> Option { + Self::new(unsafe { ffi::ts_node_child_by_field_id(self.0, field_id) }) + } + + /// Get the field name of this node's child at the given index. + #[doc(alias = "ts_node_field_name_for_child")] + #[must_use] + pub fn field_name_for_child(&self, child_index: u32) -> Option<&'static str> { + unsafe { + let ptr = ffi::ts_node_field_name_for_child(self.0, child_index); + (!ptr.is_null()).then(|| CStr::from_ptr(ptr).to_str().unwrap()) + } + } + + /// Get the field name of this node's named child at the given index. + #[must_use] + pub fn field_name_for_named_child(&self, named_child_index: u32) -> Option<&'static str> { + unsafe { + let ptr = ffi::ts_node_field_name_for_named_child(self.0, named_child_index); + (!ptr.is_null()).then(|| CStr::from_ptr(ptr).to_str().unwrap()) + } + } + + /// Iterate over this node's children. + /// + /// A [`TreeCursor`] is used to retrieve the children efficiently. Obtain + /// a [`TreeCursor`] by calling [`Tree::walk`] or [`Node::walk`]. To avoid + /// unnecessary allocations, you should reuse the same cursor for + /// subsequent calls to this method. + /// + /// If you're walking the tree recursively, you may want to use the + /// [`TreeCursor`] APIs directly instead. + pub fn children<'cursor>( + &self, + cursor: &'cursor mut TreeCursor<'tree>, + ) -> impl ExactSizeIterator> + 'cursor { + cursor.reset(*self); + cursor.goto_first_child(); + (0..self.child_count()).map(move |_| { + let result = cursor.node(); + cursor.goto_next_sibling(); + result + }) + } + + /// Iterate over this node's named children. + /// + /// See also [`Node::children`]. + pub fn named_children<'cursor>( + &self, + cursor: &'cursor mut TreeCursor<'tree>, + ) -> impl ExactSizeIterator> + 'cursor { + cursor.reset(*self); + cursor.goto_first_child(); + (0..self.named_child_count()).map(move |_| { + while !cursor.node().is_named() { + if !cursor.goto_next_sibling() { + break; + } + } + let result = cursor.node(); + cursor.goto_next_sibling(); + result + }) + } + + /// Iterate over this node's children with a given field name. + /// + /// See also [`Node::children`]. + pub fn children_by_field_name<'cursor>( + &self, + field_name: &str, + cursor: &'cursor mut TreeCursor<'tree>, + ) -> impl Iterator> + 'cursor { + let field_id = self.language().field_id_for_name(field_name); + let mut done = field_id.is_none(); + if !done { + cursor.reset(*self); + cursor.goto_first_child(); + } + iter::from_fn(move || { + if !done { + while cursor.field_id() != field_id { + if !cursor.goto_next_sibling() { + return None; + } + } + let result = cursor.node(); + if !cursor.goto_next_sibling() { + done = true; + } + return Some(result); + } + None + }) + } + + /// Iterate over this node's children with a given field id. + /// + /// See also [`Node::children_by_field_name`]. + pub fn children_by_field_id<'cursor>( + &self, + field_id: FieldId, + cursor: &'cursor mut TreeCursor<'tree>, + ) -> impl Iterator> + 'cursor { + cursor.reset(*self); + cursor.goto_first_child(); + let mut done = false; + iter::from_fn(move || { + if !done { + while cursor.field_id() != Some(field_id) { + if !cursor.goto_next_sibling() { + return None; + } + } + let result = cursor.node(); + if !cursor.goto_next_sibling() { + done = true; + } + return Some(result); + } + None + }) + } + + /// Get this node's immediate parent. + /// Prefer [`child_with_descendant`](Node::child_with_descendant) + /// for iterating over this node's ancestors. + #[doc(alias = "ts_node_parent")] + #[must_use] + pub fn parent(&self) -> Option { + Self::new(unsafe { ffi::ts_node_parent(self.0) }) + } + + /// Get the node that contains `descendant`. + /// + /// Note that this can return `descendant` itself. + #[doc(alias = "ts_node_child_with_descendant")] + #[must_use] + pub fn child_with_descendant(&self, descendant: Self) -> Option { + Self::new(unsafe { ffi::ts_node_child_with_descendant(self.0, descendant.0) }) + } + + /// Get this node's next sibling. + #[doc(alias = "ts_node_next_sibling")] + #[must_use] + pub fn next_sibling(&self) -> Option { + Self::new(unsafe { ffi::ts_node_next_sibling(self.0) }) + } + + /// Get this node's previous sibling. + #[doc(alias = "ts_node_prev_sibling")] + #[must_use] + pub fn prev_sibling(&self) -> Option { + Self::new(unsafe { ffi::ts_node_prev_sibling(self.0) }) + } + + /// Get this node's next named sibling. + #[doc(alias = "ts_node_next_named_sibling")] + #[must_use] + pub fn next_named_sibling(&self) -> Option { + Self::new(unsafe { ffi::ts_node_next_named_sibling(self.0) }) + } + + /// Get this node's previous named sibling. + #[doc(alias = "ts_node_prev_named_sibling")] + #[must_use] + pub fn prev_named_sibling(&self) -> Option { + Self::new(unsafe { ffi::ts_node_prev_named_sibling(self.0) }) + } + + /// Get this node's first child that contains or starts after the given byte offset. + #[doc(alias = "ts_node_first_child_for_byte")] + #[must_use] + pub fn first_child_for_byte(&self, byte: usize) -> Option { + Self::new(unsafe { ffi::ts_node_first_child_for_byte(self.0, byte as u32) }) + } + + /// Get this node's first named child that contains or starts after the given byte offset. + #[doc(alias = "ts_node_first_named_child_for_point")] + #[must_use] + pub fn first_named_child_for_byte(&self, byte: usize) -> Option { + Self::new(unsafe { ffi::ts_node_first_named_child_for_byte(self.0, byte as u32) }) + } + + /// Get the node's number of descendants, including one for the node itself. + #[doc(alias = "ts_node_descendant_count")] + #[must_use] + pub fn descendant_count(&self) -> usize { + unsafe { ffi::ts_node_descendant_count(self.0) as usize } + } + + /// Get the smallest node within this node that spans the given byte range. + #[doc(alias = "ts_node_descendant_for_byte_range")] + #[must_use] + pub fn descendant_for_byte_range(&self, start: usize, end: usize) -> Option { + Self::new(unsafe { + ffi::ts_node_descendant_for_byte_range(self.0, start as u32, end as u32) + }) + } + + /// Get the smallest named node within this node that spans the given byte range. + #[doc(alias = "ts_node_named_descendant_for_byte_range")] + #[must_use] + pub fn named_descendant_for_byte_range(&self, start: usize, end: usize) -> Option { + Self::new(unsafe { + ffi::ts_node_named_descendant_for_byte_range(self.0, start as u32, end as u32) + }) + } + + /// Get the smallest node within this node that spans the given point range. + #[doc(alias = "ts_node_descendant_for_point_range")] + #[must_use] + pub fn descendant_for_point_range(&self, start: Point, end: Point) -> Option { + Self::new(unsafe { + ffi::ts_node_descendant_for_point_range(self.0, start.into(), end.into()) + }) + } + + /// Get the smallest named node within this node that spans the given point range. + #[doc(alias = "ts_node_named_descendant_for_point_range")] + #[must_use] + pub fn named_descendant_for_point_range(&self, start: Point, end: Point) -> Option { + Self::new(unsafe { + ffi::ts_node_named_descendant_for_point_range(self.0, start.into(), end.into()) + }) + } + + /// Get an S-expression representing the node. + #[doc(alias = "ts_node_string")] + #[must_use] + pub fn to_sexp(&self) -> String { + let c_string = unsafe { ffi::ts_node_string(self.0) }; + let result = unsafe { CStr::from_ptr(c_string) } + .to_str() + .unwrap() + .to_string(); + unsafe { (FREE_FN)(c_string.cast::()) }; + result + } + + pub fn utf8_text<'a>(&self, source: &'a [u8]) -> Result<&'a str, str::Utf8Error> { + str::from_utf8(&source[self.start_byte()..self.end_byte()]) + } + + #[must_use] + pub fn utf16_text<'a>(&self, source: &'a [u16]) -> &'a [u16] { + &source[self.start_byte() / 2..self.end_byte() / 2] + } + + /// Create a new [`TreeCursor`] starting from this node. + /// + /// Note that the given node is considered the root of the cursor, + /// and the cursor cannot walk outside this node. + #[doc(alias = "ts_tree_cursor_new")] + #[must_use] + pub fn walk(&self) -> TreeCursor<'tree> { + TreeCursor(unsafe { ffi::ts_tree_cursor_new(self.0) }, PhantomData) + } + + /// Edit this node to keep it in-sync with source code that has been edited. + /// + /// This function is only rarely needed. When you edit a syntax tree with + /// the [`Tree::edit`] method, all of the nodes that you retrieve from + /// the tree afterward will already reflect the edit. You only need to + /// use [`Node::edit`] when you have a specific [`Node`] instance that + /// you want to keep and continue to use after an edit. + #[doc(alias = "ts_node_edit")] + pub fn edit(&mut self, edit: &InputEdit) { + let edit = edit.into(); + unsafe { ffi::ts_node_edit(core::ptr::addr_of_mut!(self.0), &edit) } + } +} + +impl PartialEq for Node<'_> { + fn eq(&self, other: &Self) -> bool { + core::ptr::eq(self.0.id, other.0.id) + } +} + +impl Eq for Node<'_> {} + +impl hash::Hash for Node<'_> { + fn hash(&self, state: &mut H) { + self.0.id.hash(state); + self.0.context[0].hash(state); + self.0.context[1].hash(state); + self.0.context[2].hash(state); + self.0.context[3].hash(state); + } +} + +impl fmt::Debug for Node<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{{Node {} {} - {}}}", + self.kind(), + self.start_position(), + self.end_position() + ) + } +} + +impl fmt::Display for Node<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let sexp = self.to_sexp(); + if sexp.is_empty() { + write!(f, "") + } else if !f.alternate() { + write!(f, "{sexp}") + } else { + write!(f, "{}", format_sexp(&sexp, f.width().unwrap_or(0))) + } + } +} + +impl<'cursor> TreeCursor<'cursor> { + /// Get the tree cursor's current [`Node`]. + #[doc(alias = "ts_tree_cursor_current_node")] + #[must_use] + pub fn node(&self) -> Node<'cursor> { + Node( + unsafe { ffi::ts_tree_cursor_current_node(&self.0) }, + PhantomData, + ) + } + + /// Get the numerical field id of this tree cursor's current node. + /// + /// See also [`field_name`](TreeCursor::field_name). + #[doc(alias = "ts_tree_cursor_current_field_id")] + #[must_use] + pub fn field_id(&self) -> Option { + let id = unsafe { ffi::ts_tree_cursor_current_field_id(&self.0) }; + FieldId::new(id) + } + + /// Get the field name of this tree cursor's current node. + #[doc(alias = "ts_tree_cursor_current_field_name")] + #[must_use] + pub fn field_name(&self) -> Option<&'static str> { + unsafe { + let ptr = ffi::ts_tree_cursor_current_field_name(&self.0); + (!ptr.is_null()).then(|| CStr::from_ptr(ptr).to_str().unwrap()) + } + } + + /// Get the depth of the cursor's current node relative to the original + /// node that the cursor was constructed with. + #[doc(alias = "ts_tree_cursor_current_depth")] + #[must_use] + pub fn depth(&self) -> u32 { + unsafe { ffi::ts_tree_cursor_current_depth(&self.0) } + } + + /// Get the index of the cursor's current node out of all of the + /// descendants of the original node that the cursor was constructed with + #[doc(alias = "ts_tree_cursor_current_descendant_index")] + #[must_use] + pub fn descendant_index(&self) -> usize { + unsafe { ffi::ts_tree_cursor_current_descendant_index(&self.0) as usize } + } + + /// Move this cursor to the first child of its current node. + /// + /// This returns `true` if the cursor successfully moved, and returns + /// `false` if there were no children. + #[doc(alias = "ts_tree_cursor_goto_first_child")] + pub fn goto_first_child(&mut self) -> bool { + unsafe { ffi::ts_tree_cursor_goto_first_child(&mut self.0) } + } + + /// Move this cursor to the last child of its current node. + /// + /// This returns `true` if the cursor successfully moved, and returns + /// `false` if there were no children. + /// + /// Note that this function may be slower than + /// [`goto_first_child`](TreeCursor::goto_first_child) because it needs to + /// iterate through all the children to compute the child's position. + #[doc(alias = "ts_tree_cursor_goto_last_child")] + pub fn goto_last_child(&mut self) -> bool { + unsafe { ffi::ts_tree_cursor_goto_last_child(&mut self.0) } + } + + /// Move this cursor to the parent of its current node. + /// + /// This returns `true` if the cursor successfully moved, and returns + /// `false` if there was no parent node (the cursor was already on the + /// root node). + /// + /// Note that the node the cursor was constructed with is considered the root + /// of the cursor, and the cursor cannot walk outside this node. + #[doc(alias = "ts_tree_cursor_goto_parent")] + pub fn goto_parent(&mut self) -> bool { + unsafe { ffi::ts_tree_cursor_goto_parent(&mut self.0) } + } + + /// Move this cursor to the next sibling of its current node. + /// + /// This returns `true` if the cursor successfully moved, and returns + /// `false` if there was no next sibling node. + /// + /// Note that the node the cursor was constructed with is considered the root + /// of the cursor, and the cursor cannot walk outside this node. + #[doc(alias = "ts_tree_cursor_goto_next_sibling")] + pub fn goto_next_sibling(&mut self) -> bool { + unsafe { ffi::ts_tree_cursor_goto_next_sibling(&mut self.0) } + } + + /// Move the cursor to the node that is the nth descendant of + /// the original node that the cursor was constructed with, where + /// zero represents the original node itself. + #[doc(alias = "ts_tree_cursor_goto_descendant")] + pub fn goto_descendant(&mut self, descendant_index: usize) { + unsafe { ffi::ts_tree_cursor_goto_descendant(&mut self.0, descendant_index as u32) } + } + + /// Move this cursor to the previous sibling of its current node. + /// + /// This returns `true` if the cursor successfully moved, and returns + /// `false` if there was no previous sibling node. + /// + /// Note, that this function may be slower than + /// [`goto_next_sibling`](TreeCursor::goto_next_sibling) due to how node + /// positions are stored. In the worst case, this will need to iterate + /// through all the children up to the previous sibling node to recalculate + /// its position. Also note that the node the cursor was constructed with is + /// considered the root of the cursor, and the cursor cannot walk outside this node. + #[doc(alias = "ts_tree_cursor_goto_previous_sibling")] + pub fn goto_previous_sibling(&mut self) -> bool { + unsafe { ffi::ts_tree_cursor_goto_previous_sibling(&mut self.0) } + } + + /// Move this cursor to the first child of its current node that contains or + /// starts after the given byte offset. + /// + /// This returns the index of the child node if one was found, and returns + /// `None` if no such child was found. + #[doc(alias = "ts_tree_cursor_goto_first_child_for_byte")] + pub fn goto_first_child_for_byte(&mut self, index: usize) -> Option { + let result = + unsafe { ffi::ts_tree_cursor_goto_first_child_for_byte(&mut self.0, index as u32) }; + result.try_into().ok() + } + + /// Move this cursor to the first child of its current node that contains or + /// starts after the given byte offset. + /// + /// This returns the index of the child node if one was found, and returns + /// `None` if no such child was found. + #[doc(alias = "ts_tree_cursor_goto_first_child_for_point")] + pub fn goto_first_child_for_point(&mut self, point: Point) -> Option { + let result = + unsafe { ffi::ts_tree_cursor_goto_first_child_for_point(&mut self.0, point.into()) }; + result.try_into().ok() + } + + /// Re-initialize this tree cursor to start at the original node that the + /// cursor was constructed with. + #[doc(alias = "ts_tree_cursor_reset")] + pub fn reset(&mut self, node: Node<'cursor>) { + unsafe { ffi::ts_tree_cursor_reset(&mut self.0, node.0) }; + } + + /// Re-initialize a tree cursor to the same position as another cursor. + /// + /// Unlike [`reset`](TreeCursor::reset), this will not lose parent + /// information and allows reusing already created cursors. + #[doc(alias = "ts_tree_cursor_reset_to")] + pub fn reset_to(&mut self, cursor: &Self) { + unsafe { ffi::ts_tree_cursor_reset_to(&mut self.0, &cursor.0) }; + } +} + +impl Clone for TreeCursor<'_> { + fn clone(&self) -> Self { + TreeCursor(unsafe { ffi::ts_tree_cursor_copy(&self.0) }, PhantomData) + } +} + +impl Drop for TreeCursor<'_> { + fn drop(&mut self) { + unsafe { ffi::ts_tree_cursor_delete(&mut self.0) } + } +} + +impl LookaheadIterator { + /// Get the current language of the lookahead iterator. + #[doc(alias = "ts_lookahead_iterator_language")] + #[must_use] + pub fn language(&self) -> LanguageRef<'_> { + LanguageRef( + unsafe { ffi::ts_lookahead_iterator_language(self.0.as_ptr()) }, + PhantomData, + ) + } + + /// Get the current symbol of the lookahead iterator. + #[doc(alias = "ts_lookahead_iterator_current_symbol")] + #[must_use] + pub fn current_symbol(&self) -> u16 { + unsafe { ffi::ts_lookahead_iterator_current_symbol(self.0.as_ptr()) } + } + + /// Get the current symbol name of the lookahead iterator. + #[doc(alias = "ts_lookahead_iterator_current_symbol_name")] + #[must_use] + pub fn current_symbol_name(&self) -> &'static str { + unsafe { + CStr::from_ptr(ffi::ts_lookahead_iterator_current_symbol_name( + self.0.as_ptr(), + )) + .to_str() + .unwrap() + } + } + + /// Reset the lookahead iterator. + /// + /// This returns `true` if the language was set successfully and `false` + /// otherwise. + #[doc(alias = "ts_lookahead_iterator_reset")] + pub fn reset(&mut self, language: &Language, state: u16) -> bool { + unsafe { ffi::ts_lookahead_iterator_reset(self.0.as_ptr(), language.0, state) } + } + + /// Reset the lookahead iterator to another state. + /// + /// This returns `true` if the iterator was reset to the given state and + /// `false` otherwise. + #[doc(alias = "ts_lookahead_iterator_reset_state")] + pub fn reset_state(&mut self, state: u16) -> bool { + unsafe { ffi::ts_lookahead_iterator_reset_state(self.0.as_ptr(), state) } + } + + /// Iterate symbol names. + pub fn iter_names(&mut self) -> impl Iterator + '_ { + LookaheadNamesIterator(self) + } +} + +impl Iterator for LookaheadNamesIterator<'_> { + type Item = &'static str; + + #[doc(alias = "ts_lookahead_iterator_next")] + fn next(&mut self) -> Option { + unsafe { ffi::ts_lookahead_iterator_next(self.0 .0.as_ptr()) } + .then(|| self.0.current_symbol_name()) + } +} + +impl Iterator for LookaheadIterator { + type Item = u16; + + #[doc(alias = "ts_lookahead_iterator_next")] + fn next(&mut self) -> Option { + // the first symbol is always `0` so we can safely skip it + unsafe { ffi::ts_lookahead_iterator_next(self.0.as_ptr()) }.then(|| self.current_symbol()) + } +} + +impl Drop for LookaheadIterator { + #[doc(alias = "ts_lookahead_iterator_delete")] + fn drop(&mut self) { + unsafe { ffi::ts_lookahead_iterator_delete(self.0.as_ptr()) } + } +} + +impl Query { + /// Create a new query from a string containing one or more S-expression + /// patterns. + /// + /// The query is associated with a particular language, and can only be run + /// on syntax nodes parsed with that language. References to Queries can be + /// shared between multiple threads. + pub fn new(language: &Language, source: &str) -> Result { + let mut error_offset = 0u32; + let mut error_type: ffi::TSQueryError = 0; + let bytes = source.as_bytes(); + + // Compile the query. + let ptr = unsafe { + ffi::ts_query_new( + language.0, + bytes.as_ptr().cast::(), + bytes.len() as u32, + core::ptr::addr_of_mut!(error_offset), + core::ptr::addr_of_mut!(error_type), + ) + }; + + // On failure, build an error based on the error code and offset. + if ptr.is_null() { + if error_type == ffi::TSQueryErrorLanguage { + return Err(QueryError { + row: 0, + column: 0, + offset: 0, + message: LanguageError { + version: language.abi_version(), + } + .to_string(), + kind: QueryErrorKind::Language, + }); + } + + let offset = error_offset as usize; + let mut line_start = 0; + let mut row = 0; + let mut line_containing_error = None; + for line in source.lines() { + let line_end = line_start + line.len() + 1; + if line_end > offset { + line_containing_error = Some(line); + break; + } + line_start = line_end; + row += 1; + } + let column = offset - line_start; + + let kind; + let message; + match error_type { + // Error types that report names + ffi::TSQueryErrorNodeType | ffi::TSQueryErrorField | ffi::TSQueryErrorCapture => { + let suffix = source.split_at(offset).1; + let in_quotes = offset > 0 && source.as_bytes()[offset - 1] == b'"'; + let mut backslashes = 0; + let end_offset = suffix + .find(|c| { + if in_quotes { + if c == '"' && backslashes % 2 == 0 { + true + } else if c == '\\' { + backslashes += 1; + false + } else { + backslashes = 0; + false + } + } else { + !char::is_alphanumeric(c) && c != '_' && c != '-' + } + }) + .unwrap_or(suffix.len()); + message = suffix.split_at(end_offset).0.to_string(); + kind = match error_type { + ffi::TSQueryErrorNodeType => QueryErrorKind::NodeType, + ffi::TSQueryErrorField => QueryErrorKind::Field, + ffi::TSQueryErrorCapture => QueryErrorKind::Capture, + _ => unreachable!(), + }; + } + + // Error types that report positions + _ => { + message = line_containing_error.map_or_else( + || "Unexpected EOF".to_string(), + |line| line.to_string() + "\n" + &" ".repeat(offset - line_start) + "^", + ); + kind = match error_type { + ffi::TSQueryErrorStructure => QueryErrorKind::Structure, + _ => QueryErrorKind::Syntax, + }; + } + } + + return Err(QueryError { + row, + column, + offset, + message, + kind, + }); + } + + unsafe { Self::from_raw_parts(ptr, source) } + } + + #[doc(hidden)] + unsafe fn from_raw_parts(ptr: *mut ffi::TSQuery, source: &str) -> Result { + let ptr = { + struct TSQueryDrop(*mut ffi::TSQuery); + impl Drop for TSQueryDrop { + fn drop(&mut self) { + unsafe { ffi::ts_query_delete(self.0) } + } + } + TSQueryDrop(ptr) + }; + + let string_count = unsafe { ffi::ts_query_string_count(ptr.0) }; + let capture_count = unsafe { ffi::ts_query_capture_count(ptr.0) }; + let pattern_count = unsafe { ffi::ts_query_pattern_count(ptr.0) as usize }; + + let mut capture_names = Vec::with_capacity(capture_count as usize); + let mut capture_quantifiers_vec = Vec::with_capacity(pattern_count as usize); + let mut text_predicates_vec = Vec::with_capacity(pattern_count); + let mut property_predicates_vec = Vec::with_capacity(pattern_count); + let mut property_settings_vec = Vec::with_capacity(pattern_count); + let mut general_predicates_vec = Vec::with_capacity(pattern_count); + + // Build a vector of strings to store the capture names. + for i in 0..capture_count { + unsafe { + let mut length = 0u32; + let name = + ffi::ts_query_capture_name_for_id(ptr.0, i, core::ptr::addr_of_mut!(length)) + .cast::(); + let name = slice::from_raw_parts(name, length as usize); + let name = str::from_utf8_unchecked(name); + capture_names.push(name); + } + } + + // Build a vector to store capture quantifiers. + for i in 0..pattern_count { + let mut capture_quantifiers = Vec::with_capacity(capture_count as usize); + for j in 0..capture_count { + unsafe { + let quantifier = ffi::ts_query_capture_quantifier_for_id(ptr.0, i as u32, j); + capture_quantifiers.push(quantifier.into()); + } + } + capture_quantifiers_vec.push(capture_quantifiers.into()); + } + + // Build a vector of strings to represent literal values used in predicates. + let string_values = (0..string_count) + .map(|i| unsafe { + let mut length = 0u32; + let value = + ffi::ts_query_string_value_for_id(ptr.0, i, core::ptr::addr_of_mut!(length)) + .cast::(); + let value = slice::from_raw_parts(value, length as usize); + let value = str::from_utf8_unchecked(value); + value + }) + .collect::>(); + + // Build a vector of predicates for each pattern. + for i in 0..pattern_count { + let predicate_steps = unsafe { + let mut length = 0u32; + let raw_predicates = ffi::ts_query_predicates_for_pattern( + ptr.0, + i as u32, + core::ptr::addr_of_mut!(length), + ); + (length > 0) + .then(|| slice::from_raw_parts(raw_predicates, length as usize)) + .unwrap_or_default() + }; + + let byte_offset = unsafe { ffi::ts_query_start_byte_for_pattern(ptr.0, i as u32) }; + let row = source + .char_indices() + .take_while(|(i, _)| *i < byte_offset as usize) + .filter(|(_, c)| *c == '\n') + .count(); + + use ffi::TSQueryPredicateStepType as T; + const TYPE_DONE: T = ffi::TSQueryPredicateStepTypeDone; + const TYPE_CAPTURE: T = ffi::TSQueryPredicateStepTypeCapture; + const TYPE_STRING: T = ffi::TSQueryPredicateStepTypeString; + + let mut text_predicates = Vec::new(); + let mut property_predicates = Vec::new(); + let mut property_settings = Vec::new(); + let mut general_predicates = Vec::new(); + for p in predicate_steps.split(|s| s.type_ == TYPE_DONE) { + if p.is_empty() { + continue; + } + + if p[0].type_ != TYPE_STRING { + return Err(predicate_error( + row, + format!( + "Expected predicate to start with a function name. Got @{}.", + capture_names[p[0].value_id as usize], + ), + )); + } + + // Build a predicate for each of the known predicate function names. + let operator_name = string_values[p[0].value_id as usize]; + match operator_name { + "eq?" | "not-eq?" | "any-eq?" | "any-not-eq?" => { + if p.len() != 3 { + return Err(predicate_error( + row, + format!( + "Wrong number of arguments to #eq? predicate. Expected 2, got {}.", + p.len() - 1 + ), + )); + } + if p[1].type_ != TYPE_CAPTURE { + return Err(predicate_error(row, format!( + "First argument to #eq? predicate must be a capture name. Got literal \"{}\".", + string_values[p[1].value_id as usize], + ))); + } + + let is_positive = operator_name == "eq?" || operator_name == "any-eq?"; + let match_all = match operator_name { + "eq?" | "not-eq?" => true, + "any-eq?" | "any-not-eq?" => false, + _ => unreachable!(), + }; + text_predicates.push(if p[2].type_ == TYPE_CAPTURE { + TextPredicateCapture::EqCapture( + p[1].value_id, + p[2].value_id, + is_positive, + match_all, + ) + } else { + TextPredicateCapture::EqString( + p[1].value_id, + string_values[p[2].value_id as usize].to_string().into(), + is_positive, + match_all, + ) + }); + } + + "match?" | "not-match?" | "any-match?" | "any-not-match?" => { + if p.len() != 3 { + return Err(predicate_error(row, format!( + "Wrong number of arguments to #match? predicate. Expected 2, got {}.", + p.len() - 1 + ))); + } + if p[1].type_ != TYPE_CAPTURE { + return Err(predicate_error(row, format!( + "First argument to #match? predicate must be a capture name. Got literal \"{}\".", + string_values[p[1].value_id as usize], + ))); + } + if p[2].type_ == TYPE_CAPTURE { + return Err(predicate_error(row, format!( + "Second argument to #match? predicate must be a literal. Got capture @{}.", + capture_names[p[2].value_id as usize], + ))); + } + #[cfg(feature = "regex-predicates")] + { + let is_positive = + operator_name == "match?" || operator_name == "any-match?"; + let match_all = match operator_name { + "match?" | "not-match?" => true, + "any-match?" | "any-not-match?" => false, + _ => unreachable!(), + }; + let regex = &string_values[p[2].value_id as usize]; + text_predicates.push(TextPredicateCapture::MatchString( + p[1].value_id, + regex::bytes::Regex::new(regex).map_err(|_| { + predicate_error(row, format!("Invalid regex '{regex}'")) + })?, + is_positive, + match_all, + )); + } + #[cfg(not(feature = "regex-predicates"))] + { + return Err(predicate_error( + row, + "Regex query predicates are disabled in this build.".to_string(), + )); + } + } + + "set!" => property_settings.push(Self::parse_property( + row, + operator_name, + &capture_names, + &string_values, + &p[1..], + )?), + + "is?" | "is-not?" => property_predicates.push(( + Self::parse_property( + row, + operator_name, + &capture_names, + &string_values, + &p[1..], + )?, + operator_name == "is?", + )), + + "any-of?" | "not-any-of?" => { + if p.len() < 2 { + return Err(predicate_error(row, format!( + "Wrong number of arguments to #any-of? predicate. Expected at least 1, got {}.", + p.len() - 1 + ))); + } + if p[1].type_ != TYPE_CAPTURE { + return Err(predicate_error(row, format!( + "First argument to #any-of? predicate must be a capture name. Got literal \"{}\".", + string_values[p[1].value_id as usize], + ))); + } + + let is_positive = operator_name == "any-of?"; + let mut values = Vec::new(); + for arg in &p[2..] { + if arg.type_ == TYPE_CAPTURE { + return Err(predicate_error(row, format!( + "Arguments to #any-of? predicate must be literals. Got capture @{}.", + capture_names[arg.value_id as usize], + ))); + } + values.push(string_values[arg.value_id as usize]); + } + text_predicates.push(TextPredicateCapture::AnyString( + p[1].value_id, + values + .iter() + .map(|x| (*x).to_string().into()) + .collect::>() + .into(), + is_positive, + )); + } + + _ => general_predicates.push(QueryPredicate { + operator: operator_name.to_string().into(), + args: p[1..] + .iter() + .map(|a| { + if a.type_ == TYPE_CAPTURE { + QueryPredicateArg::Capture(a.value_id) + } else { + QueryPredicateArg::String( + string_values[a.value_id as usize].to_string().into(), + ) + } + }) + .collect(), + }), + } + } + + text_predicates_vec.push(text_predicates.into()); + property_predicates_vec.push(property_predicates.into()); + property_settings_vec.push(property_settings.into()); + general_predicates_vec.push(general_predicates.into()); + } + + let result = Self { + ptr: unsafe { NonNull::new_unchecked(ptr.0) }, + capture_names: capture_names.into(), + capture_quantifiers: capture_quantifiers_vec.into(), + text_predicates: text_predicates_vec.into(), + property_predicates: property_predicates_vec.into(), + property_settings: property_settings_vec.into(), + general_predicates: general_predicates_vec.into(), + }; + + core::mem::forget(ptr); + + Ok(result) + } + + /// Get the byte offset where the given pattern starts in the query's + /// source. + #[doc(alias = "ts_query_start_byte_for_pattern")] + #[must_use] + pub fn start_byte_for_pattern(&self, pattern_index: usize) -> usize { + assert!( + pattern_index < self.text_predicates.len(), + "Pattern index is {pattern_index} but the pattern count is {}", + self.text_predicates.len(), + ); + unsafe { + ffi::ts_query_start_byte_for_pattern(self.ptr.as_ptr(), pattern_index as u32) as usize + } + } + + /// Get the byte offset where the given pattern ends in the query's + /// source. + #[doc(alias = "ts_query_end_byte_for_pattern")] + #[must_use] + pub fn end_byte_for_pattern(&self, pattern_index: usize) -> usize { + assert!( + pattern_index < self.text_predicates.len(), + "Pattern index is {pattern_index} but the pattern count is {}", + self.text_predicates.len(), + ); + unsafe { + ffi::ts_query_end_byte_for_pattern(self.ptr.as_ptr(), pattern_index as u32) as usize + } + } + + /// Get the number of patterns in the query. + #[doc(alias = "ts_query_pattern_count")] + #[must_use] + pub fn pattern_count(&self) -> usize { + unsafe { ffi::ts_query_pattern_count(self.ptr.as_ptr()) as usize } + } + + /// Get the names of the captures used in the query. + #[must_use] + pub const fn capture_names(&self) -> &[&str] { + &self.capture_names + } + + /// Get the quantifiers of the captures used in the query. + #[must_use] + pub const fn capture_quantifiers(&self, index: usize) -> &[CaptureQuantifier] { + &self.capture_quantifiers[index] + } + + /// Get the index for a given capture name. + #[must_use] + pub fn capture_index_for_name(&self, name: &str) -> Option { + self.capture_names + .iter() + .position(|n| *n == name) + .map(|ix| ix as u32) + } + + /// Get the properties that are checked for the given pattern index. + /// + /// This includes predicates with the operators `is?` and `is-not?`. + #[must_use] + pub const fn property_predicates(&self, index: usize) -> &[(QueryProperty, bool)] { + &self.property_predicates[index] + } + + /// Get the properties that are set for the given pattern index. + /// + /// This includes predicates with the operator `set!`. + #[must_use] + pub const fn property_settings(&self, index: usize) -> &[QueryProperty] { + &self.property_settings[index] + } + + /// Get the other user-defined predicates associated with the given index. + /// + /// This includes predicate with operators other than: + /// * `match?` + /// * `eq?` and `not-eq?` + /// * `is?` and `is-not?` + /// * `set!` + #[must_use] + pub const fn general_predicates(&self, index: usize) -> &[QueryPredicate] { + &self.general_predicates[index] + } + + /// Disable a certain capture within a query. + /// + /// This prevents the capture from being returned in matches, and also + /// avoids any resource usage associated with recording the capture. + #[doc(alias = "ts_query_disable_capture")] + pub fn disable_capture(&mut self, name: &str) { + unsafe { + ffi::ts_query_disable_capture( + self.ptr.as_ptr(), + name.as_bytes().as_ptr().cast::(), + name.len() as u32, + ); + } + } + + /// Disable a certain pattern within a query. + /// + /// This prevents the pattern from matching, and also avoids any resource + /// usage associated with the pattern. + #[doc(alias = "ts_query_disable_pattern")] + pub fn disable_pattern(&mut self, index: usize) { + unsafe { ffi::ts_query_disable_pattern(self.ptr.as_ptr(), index as u32) } + } + + /// Check if a given pattern within a query has a single root node. + #[doc(alias = "ts_query_is_pattern_rooted")] + #[must_use] + pub fn is_pattern_rooted(&self, index: usize) -> bool { + unsafe { ffi::ts_query_is_pattern_rooted(self.ptr.as_ptr(), index as u32) } + } + + /// Check if a given pattern within a query has a single root node. + #[doc(alias = "ts_query_is_pattern_non_local")] + #[must_use] + pub fn is_pattern_non_local(&self, index: usize) -> bool { + unsafe { ffi::ts_query_is_pattern_non_local(self.ptr.as_ptr(), index as u32) } + } + + /// Check if a given step in a query is 'definite'. + /// + /// A query step is 'definite' if its parent pattern will be guaranteed to + /// match successfully once it reaches the step. + #[doc(alias = "ts_query_is_pattern_guaranteed_at_step")] + #[must_use] + pub fn is_pattern_guaranteed_at_step(&self, byte_offset: usize) -> bool { + unsafe { + ffi::ts_query_is_pattern_guaranteed_at_step(self.ptr.as_ptr(), byte_offset as u32) + } + } + + fn parse_property( + row: usize, + function_name: &str, + capture_names: &[&str], + string_values: &[&str], + args: &[ffi::TSQueryPredicateStep], + ) -> Result { + if args.is_empty() || args.len() > 3 { + return Err(predicate_error( + row, + format!( + "Wrong number of arguments to {function_name} predicate. Expected 1 to 3, got {}.", + args.len(), + ), + )); + } + + let mut capture_id = None; + let mut key = None; + let mut value = None; + + for arg in args { + if arg.type_ == ffi::TSQueryPredicateStepTypeCapture { + if capture_id.is_some() { + return Err(predicate_error( + row, + format!( + "Invalid arguments to {function_name} predicate. Unexpected second capture name @{}", + capture_names[arg.value_id as usize] + ), + )); + } + capture_id = Some(arg.value_id as usize); + } else if key.is_none() { + key = Some(&string_values[arg.value_id as usize]); + } else if value.is_none() { + value = Some(string_values[arg.value_id as usize]); + } else { + return Err(predicate_error( + row, + format!( + "Invalid arguments to {function_name} predicate. Unexpected third argument @{}", + string_values[arg.value_id as usize] + ), + )); + } + } + + if let Some(key) = key { + Ok(QueryProperty::new(key, value, capture_id)) + } else { + Err(predicate_error( + row, + format!("Invalid arguments to {function_name} predicate. Missing key argument",), + )) + } + } +} + +impl Default for QueryCursor { + fn default() -> Self { + Self::new() + } +} + +impl QueryCursor { + /// Create a new cursor for executing a given query. + /// + /// The cursor stores the state that is needed to iteratively search for + /// matches. + #[doc(alias = "ts_query_cursor_new")] + #[must_use] + pub fn new() -> Self { + Self { + ptr: unsafe { NonNull::new_unchecked(ffi::ts_query_cursor_new()) }, + } + } + + /// Return the maximum number of in-progress matches for this cursor. + #[doc(alias = "ts_query_cursor_match_limit")] + #[must_use] + pub fn match_limit(&self) -> u32 { + unsafe { ffi::ts_query_cursor_match_limit(self.ptr.as_ptr()) } + } + + /// Set the maximum number of in-progress matches for this cursor. The + /// limit must be > 0 and <= 65536. + #[doc(alias = "ts_query_cursor_set_match_limit")] + pub fn set_match_limit(&mut self, limit: u32) { + unsafe { + ffi::ts_query_cursor_set_match_limit(self.ptr.as_ptr(), limit); + } + } + + /// Set the maximum duration in microseconds that query execution should be allowed to + /// take before halting. + /// + /// If query execution takes longer than this, it will halt early, returning None. + #[doc(alias = "ts_query_cursor_set_timeout_micros")] + #[deprecated( + since = "0.25.0", + note = "Prefer using `matches_with_options` or `captures_with_options` and using a callback" + )] + pub fn set_timeout_micros(&mut self, timeout: u64) { + unsafe { + ffi::ts_query_cursor_set_timeout_micros(self.ptr.as_ptr(), timeout); + } + } + + /// Get the duration in microseconds that query execution is allowed to take. + /// + /// This is set via [`set_timeout_micros`](QueryCursor::set_timeout_micros). + #[doc(alias = "ts_query_cursor_timeout_micros")] + #[deprecated( + since = "0.25.0", + note = "Prefer using `matches_with_options` or `captures_with_options` and using a callback" + )] + #[must_use] + pub fn timeout_micros(&self) -> u64 { + unsafe { ffi::ts_query_cursor_timeout_micros(self.ptr.as_ptr()) } + } + + /// Check if, on its last execution, this cursor exceeded its maximum number + /// of in-progress matches. + #[doc(alias = "ts_query_cursor_did_exceed_match_limit")] + #[must_use] + pub fn did_exceed_match_limit(&self) -> bool { + unsafe { ffi::ts_query_cursor_did_exceed_match_limit(self.ptr.as_ptr()) } + } + + /// Iterate over all of the matches in the order that they were found. + /// + /// Each match contains the index of the pattern that matched, and a list of + /// captures. Because multiple patterns can match the same set of nodes, + /// one match may contain captures that appear *before* some of the + /// captures from a previous match. + /// + /// Iterating over a `QueryMatches` object requires the `StreamingIterator` + /// or `StreamingIteratorMut` trait to be in scope. This can be done via + /// `use tree_sitter::StreamingIterator` or `use tree_sitter::StreamingIteratorMut` + #[doc(alias = "ts_query_cursor_exec")] + pub fn matches<'query, 'cursor: 'query, 'tree, T: TextProvider, I: AsRef<[u8]>>( + &'cursor mut self, + query: &'query Query, + node: Node<'tree>, + text_provider: T, + ) -> QueryMatches<'query, 'tree, T, I> { + let ptr = self.ptr.as_ptr(); + unsafe { ffi::ts_query_cursor_exec(ptr, query.ptr.as_ptr(), node.0) }; + QueryMatches { + ptr, + query, + text_provider, + buffer1: Vec::default(), + buffer2: Vec::default(), + current_match: None, + _options: None, + _phantom: PhantomData, + } + } + + /// Iterate over all of the matches in the order that they were found, with options. + /// + /// Each match contains the index of the pattern that matched, and a list of + /// captures. Because multiple patterns can match the same set of nodes, + /// one match may contain captures that appear *before* some of the + /// captures from a previous match. + #[doc(alias = "ts_query_cursor_exec_with_options")] + pub fn matches_with_options< + 'query, + 'cursor: 'query, + 'tree, + T: TextProvider, + I: AsRef<[u8]>, + >( + &'cursor mut self, + query: &'query Query, + node: Node<'tree>, + text_provider: T, + options: QueryCursorOptions, + ) -> QueryMatches<'query, 'tree, T, I> { + unsafe extern "C" fn progress(state: *mut ffi::TSQueryCursorState) -> bool { + let callback = (*state) + .payload + .cast::() + .as_mut() + .unwrap(); + (callback)(&QueryCursorState::from_raw(state)) + } + + let query_options = options.progress_callback.map(|cb| { + QueryCursorOptionsDrop(Box::into_raw(Box::new(ffi::TSQueryCursorOptions { + payload: Box::into_raw(Box::new(cb)).cast::(), + progress_callback: Some(progress), + }))) + }); + + let ptr = self.ptr.as_ptr(); + unsafe { + ffi::ts_query_cursor_exec_with_options( + ptr, + query.ptr.as_ptr(), + node.0, + query_options.as_ref().map_or(ptr::null_mut(), |q| q.0), + ); + } + QueryMatches { + ptr, + query, + text_provider, + buffer1: Vec::default(), + buffer2: Vec::default(), + current_match: None, + _options: query_options, + _phantom: PhantomData, + } + } + + /// Iterate over all of the individual captures in the order that they + /// appear. + /// + /// This is useful if you don't care about which pattern matched, and just + /// want a single, ordered sequence of captures. + /// + /// Iterating over a `QueryCaptures` object requires the `StreamingIterator` + /// or `StreamingIteratorMut` trait to be in scope. This can be done via + /// `use tree_sitter::StreamingIterator` or `use tree_sitter::StreamingIteratorMut` + #[doc(alias = "ts_query_cursor_exec")] + pub fn captures<'query, 'cursor: 'query, 'tree, T: TextProvider, I: AsRef<[u8]>>( + &'cursor mut self, + query: &'query Query, + node: Node<'tree>, + text_provider: T, + ) -> QueryCaptures<'query, 'tree, T, I> { + let ptr = self.ptr.as_ptr(); + unsafe { ffi::ts_query_cursor_exec(ptr, query.ptr.as_ptr(), node.0) }; + QueryCaptures { + ptr, + query, + text_provider, + buffer1: Vec::default(), + buffer2: Vec::default(), + current_match: None, + _options: None, + _phantom: PhantomData, + } + } + + /// Iterate over all of the individual captures in the order that they + /// appear, with options. + /// + /// This is useful if you don't care about which pattern matched, and just + /// want a single, ordered sequence of captures. + #[doc(alias = "ts_query_cursor_exec")] + pub fn captures_with_options< + 'query, + 'cursor: 'query, + 'tree, + T: TextProvider, + I: AsRef<[u8]>, + >( + &'cursor mut self, + query: &'query Query, + node: Node<'tree>, + text_provider: T, + options: QueryCursorOptions, + ) -> QueryCaptures<'query, 'tree, T, I> { + unsafe extern "C" fn progress(state: *mut ffi::TSQueryCursorState) -> bool { + let callback = (*state) + .payload + .cast::() + .as_mut() + .unwrap(); + (callback)(&QueryCursorState::from_raw(state)) + } + + let query_options = options.progress_callback.map(|cb| { + QueryCursorOptionsDrop(Box::into_raw(Box::new(ffi::TSQueryCursorOptions { + payload: Box::into_raw(Box::new(cb)).cast::(), + progress_callback: Some(progress), + }))) + }); + + let ptr = self.ptr.as_ptr(); + unsafe { + ffi::ts_query_cursor_exec_with_options( + ptr, + query.ptr.as_ptr(), + node.0, + query_options.as_ref().map_or(ptr::null_mut(), |q| q.0), + ); + } + QueryCaptures { + ptr, + query, + text_provider, + buffer1: Vec::default(), + buffer2: Vec::default(), + current_match: None, + _options: query_options, + _phantom: PhantomData, + } + } + + /// Set the range in which the query will be executed, in terms of byte + /// offsets. + #[doc(alias = "ts_query_cursor_set_byte_range")] + pub fn set_byte_range(&mut self, range: ops::Range) -> &mut Self { + unsafe { + ffi::ts_query_cursor_set_byte_range( + self.ptr.as_ptr(), + range.start as u32, + range.end as u32, + ); + } + self + } + + /// Set the range in which the query will be executed, in terms of rows and + /// columns. + #[doc(alias = "ts_query_cursor_set_point_range")] + pub fn set_point_range(&mut self, range: ops::Range) -> &mut Self { + unsafe { + ffi::ts_query_cursor_set_point_range( + self.ptr.as_ptr(), + range.start.into(), + range.end.into(), + ); + } + self + } + + /// Set the maximum start depth for a query cursor. + /// + /// This prevents cursors from exploring children nodes at a certain depth. + /// Note if a pattern includes many children, then they will still be + /// checked. + /// + /// The zero max start depth value can be used as a special behavior and + /// it helps to destructure a subtree by staying on a node and using + /// captures for interested parts. Note that the zero max start depth + /// only limit a search depth for a pattern's root node but other nodes + /// that are parts of the pattern may be searched at any depth what + /// defined by the pattern structure. + /// + /// Set to `None` to remove the maximum start depth. + #[doc(alias = "ts_query_cursor_set_max_start_depth")] + pub fn set_max_start_depth(&mut self, max_start_depth: Option) -> &mut Self { + unsafe { + ffi::ts_query_cursor_set_max_start_depth( + self.ptr.as_ptr(), + max_start_depth.unwrap_or(u32::MAX), + ); + } + self + } +} + +impl<'tree> QueryMatch<'_, 'tree> { + #[must_use] + pub const fn id(&self) -> u32 { + self.id + } + + #[doc(alias = "ts_query_cursor_remove_match")] + pub fn remove(&self) { + unsafe { ffi::ts_query_cursor_remove_match(self.cursor, self.id) } + } + + pub fn nodes_for_capture_index( + &self, + capture_ix: u32, + ) -> impl Iterator> + '_ { + self.captures + .iter() + .filter_map(move |capture| (capture.index == capture_ix).then_some(capture.node)) + } + + fn new(m: &ffi::TSQueryMatch, cursor: *mut ffi::TSQueryCursor) -> Self { + QueryMatch { + cursor, + id: m.id, + pattern_index: m.pattern_index as usize, + captures: (m.capture_count > 0) + .then(|| unsafe { + slice::from_raw_parts( + m.captures.cast::>(), + m.capture_count as usize, + ) + }) + .unwrap_or_default(), + } + } + + pub fn satisfies_text_predicates>( + &self, + query: &Query, + buffer1: &mut Vec, + buffer2: &mut Vec, + text_provider: &mut impl TextProvider, + ) -> bool { + struct NodeText<'a, T> { + buffer: &'a mut Vec, + first_chunk: Option, + } + impl<'a, T: AsRef<[u8]>> NodeText<'a, T> { + fn new(buffer: &'a mut Vec) -> Self { + Self { + buffer, + first_chunk: None, + } + } + + fn get_text(&mut self, chunks: &mut impl Iterator) -> &[u8] { + self.first_chunk = chunks.next(); + if let Some(next_chunk) = chunks.next() { + self.buffer.clear(); + self.buffer + .extend_from_slice(self.first_chunk.as_ref().unwrap().as_ref()); + self.buffer.extend_from_slice(next_chunk.as_ref()); + for chunk in chunks { + self.buffer.extend_from_slice(chunk.as_ref()); + } + self.buffer.as_slice() + } else if let Some(ref first_chunk) = self.first_chunk { + first_chunk.as_ref() + } else { + &[] + } + } + } + + let mut node_text1 = NodeText::new(buffer1); + let mut node_text2 = NodeText::new(buffer2); + + query.text_predicates[self.pattern_index] + .iter() + .all(|predicate| match predicate { + TextPredicateCapture::EqCapture(i, j, is_positive, match_all_nodes) => { + let mut nodes_1 = self.nodes_for_capture_index(*i).peekable(); + let mut nodes_2 = self.nodes_for_capture_index(*j).peekable(); + while nodes_1.peek().is_some() && nodes_2.peek().is_some() { + let node1 = nodes_1.next().unwrap(); + let node2 = nodes_2.next().unwrap(); + let mut text1 = text_provider.text(node1); + let mut text2 = text_provider.text(node2); + let text1 = node_text1.get_text(&mut text1); + let text2 = node_text2.get_text(&mut text2); + let is_positive_match = text1 == text2; + if is_positive_match != *is_positive && *match_all_nodes { + return false; + } + if is_positive_match == *is_positive && !*match_all_nodes { + return true; + } + } + nodes_1.next().is_none() && nodes_2.next().is_none() + } + TextPredicateCapture::EqString(i, s, is_positive, match_all_nodes) => { + let nodes = self.nodes_for_capture_index(*i); + for node in nodes { + let mut text = text_provider.text(node); + let text = node_text1.get_text(&mut text); + let is_positive_match = text == s.as_bytes(); + if is_positive_match != *is_positive && *match_all_nodes { + return false; + } + if is_positive_match == *is_positive && !*match_all_nodes { + return true; + } + } + true + } + #[cfg(feature = "regex-predicates")] + TextPredicateCapture::MatchString(i, r, is_positive, match_all_nodes) => { + let nodes = self.nodes_for_capture_index(*i); + for node in nodes { + let mut text = text_provider.text(node); + let text = node_text1.get_text(&mut text); + let is_positive_match = r.is_match(text); + if is_positive_match != *is_positive && *match_all_nodes { + return false; + } + if is_positive_match == *is_positive && !*match_all_nodes { + return true; + } + } + true + } + TextPredicateCapture::AnyString(i, v, is_positive) => { + let nodes = self.nodes_for_capture_index(*i); + for node in nodes { + let mut text = text_provider.text(node); + let text = node_text1.get_text(&mut text); + if (v.iter().any(|s| text == s.as_bytes())) != *is_positive { + return false; + } + } + true + } + }) + } +} + +impl QueryProperty { + #[must_use] + pub fn new(key: &str, value: Option<&str>, capture_id: Option) -> Self { + Self { + capture_id, + key: key.to_string().into(), + value: value.map(|s| s.to_string().into()), + } + } +} + +/// Provide a `StreamingIterator` instead of the traditional `Iterator`, as the +/// underlying object in the C library gets updated on each iteration. Copies would +/// have their internal state overwritten, leading to Undefined Behavior +impl<'query, 'tree: 'query, T: TextProvider, I: AsRef<[u8]>> StreamingIterator + for QueryMatches<'query, 'tree, T, I> +{ + type Item = QueryMatch<'query, 'tree>; + + fn advance(&mut self) { + self.current_match = unsafe { + loop { + let mut m = MaybeUninit::::uninit(); + if ffi::ts_query_cursor_next_match(self.ptr, m.as_mut_ptr()) { + let result = QueryMatch::new(&m.assume_init(), self.ptr); + if result.satisfies_text_predicates( + self.query, + &mut self.buffer1, + &mut self.buffer2, + &mut self.text_provider, + ) { + break Some(result); + } + } else { + break None; + } + } + }; + } + + fn get(&self) -> Option<&Self::Item> { + self.current_match.as_ref() + } +} + +impl<'query, 'tree: 'query, T: TextProvider, I: AsRef<[u8]>> StreamingIteratorMut + for QueryMatches<'query, 'tree, T, I> +{ + fn get_mut(&mut self) -> Option<&mut Self::Item> { + self.current_match.as_mut() + } +} + +impl<'query, 'tree: 'query, T: TextProvider, I: AsRef<[u8]>> StreamingIterator + for QueryCaptures<'query, 'tree, T, I> +{ + type Item = (QueryMatch<'query, 'tree>, usize); + + fn advance(&mut self) { + self.current_match = unsafe { + loop { + let mut capture_index = 0u32; + let mut m = MaybeUninit::::uninit(); + if ffi::ts_query_cursor_next_capture( + self.ptr, + m.as_mut_ptr(), + core::ptr::addr_of_mut!(capture_index), + ) { + let result = QueryMatch::new(&m.assume_init(), self.ptr); + if result.satisfies_text_predicates( + self.query, + &mut self.buffer1, + &mut self.buffer2, + &mut self.text_provider, + ) { + break Some((result, capture_index as usize)); + } + result.remove(); + } else { + break None; + } + } + } + } + + fn get(&self) -> Option<&Self::Item> { + self.current_match.as_ref() + } +} + +impl<'query, 'tree: 'query, T: TextProvider, I: AsRef<[u8]>> StreamingIteratorMut + for QueryCaptures<'query, 'tree, T, I> +{ + fn get_mut(&mut self) -> Option<&mut Self::Item> { + self.current_match.as_mut() + } +} + +impl, I: AsRef<[u8]>> QueryMatches<'_, '_, T, I> { + #[doc(alias = "ts_query_cursor_set_byte_range")] + pub fn set_byte_range(&mut self, range: ops::Range) { + unsafe { + ffi::ts_query_cursor_set_byte_range(self.ptr, range.start as u32, range.end as u32); + } + } + + #[doc(alias = "ts_query_cursor_set_point_range")] + pub fn set_point_range(&mut self, range: ops::Range) { + unsafe { + ffi::ts_query_cursor_set_point_range(self.ptr, range.start.into(), range.end.into()); + } + } +} + +impl, I: AsRef<[u8]>> QueryCaptures<'_, '_, T, I> { + #[doc(alias = "ts_query_cursor_set_byte_range")] + pub fn set_byte_range(&mut self, range: ops::Range) { + unsafe { + ffi::ts_query_cursor_set_byte_range(self.ptr, range.start as u32, range.end as u32); + } + } + + #[doc(alias = "ts_query_cursor_set_point_range")] + pub fn set_point_range(&mut self, range: ops::Range) { + unsafe { + ffi::ts_query_cursor_set_point_range(self.ptr, range.start.into(), range.end.into()); + } + } +} + +impl fmt::Debug for QueryMatch<'_, '_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "QueryMatch {{ id: {}, pattern_index: {}, captures: {:?} }}", + self.id, self.pattern_index, self.captures + ) + } +} + +impl TextProvider for F +where + F: FnMut(Node) -> R, + R: Iterator, + I: AsRef<[u8]>, +{ + type I = R; + + fn text(&mut self, node: Node) -> Self::I { + (self)(node) + } +} + +impl<'a> TextProvider<&'a [u8]> for &'a [u8] { + type I = iter::Once<&'a [u8]>; + + fn text(&mut self, node: Node) -> Self::I { + iter::once(&self[node.byte_range()]) + } +} + +impl PartialEq for Query { + fn eq(&self, other: &Self) -> bool { + self.ptr == other.ptr + } +} + +impl Drop for Query { + fn drop(&mut self) { + unsafe { ffi::ts_query_delete(self.ptr.as_ptr()) } + } +} + +impl Drop for QueryCursor { + fn drop(&mut self) { + unsafe { ffi::ts_query_cursor_delete(self.ptr.as_ptr()) } + } +} + +impl Point { + #[must_use] + pub const fn new(row: usize, column: usize) -> Self { + Self { row, column } + } +} + +impl fmt::Display for Point { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "({}, {})", self.row, self.column) + } +} + +impl From for ffi::TSPoint { + fn from(val: Point) -> Self { + Self { + row: val.row as u32, + column: val.column as u32, + } + } +} + +impl From for Point { + fn from(point: ffi::TSPoint) -> Self { + Self { + row: point.row as usize, + column: point.column as usize, + } + } +} + +impl From for ffi::TSRange { + fn from(val: Range) -> Self { + Self { + start_byte: val.start_byte as u32, + end_byte: val.end_byte as u32, + start_point: val.start_point.into(), + end_point: val.end_point.into(), + } + } +} + +impl From for Range { + fn from(range: ffi::TSRange) -> Self { + Self { + start_byte: range.start_byte as usize, + end_byte: range.end_byte as usize, + start_point: range.start_point.into(), + end_point: range.end_point.into(), + } + } +} + +impl From<&'_ InputEdit> for ffi::TSInputEdit { + fn from(val: &'_ InputEdit) -> Self { + Self { + start_byte: val.start_byte as u32, + old_end_byte: val.old_end_byte as u32, + new_end_byte: val.new_end_byte as u32, + start_point: val.start_position.into(), + old_end_point: val.old_end_position.into(), + new_end_point: val.new_end_position.into(), + } + } +} + +impl<'a> LossyUtf8<'a> { + #[must_use] + pub const fn new(bytes: &'a [u8]) -> Self { + LossyUtf8 { + bytes, + in_replacement: false, + } + } +} + +impl<'a> Iterator for LossyUtf8<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option<&'a str> { + if self.bytes.is_empty() { + return None; + } + if self.in_replacement { + self.in_replacement = false; + return Some("\u{fffd}"); + } + match core::str::from_utf8(self.bytes) { + Ok(valid) => { + self.bytes = &[]; + Some(valid) + } + Err(error) => { + if let Some(error_len) = error.error_len() { + let error_start = error.valid_up_to(); + if error_start > 0 { + let result = + unsafe { core::str::from_utf8_unchecked(&self.bytes[..error_start]) }; + self.bytes = &self.bytes[(error_start + error_len)..]; + self.in_replacement = true; + Some(result) + } else { + self.bytes = &self.bytes[error_len..]; + Some("\u{fffd}") + } + } else { + None + } + } + } + } +} + +#[must_use] +const fn predicate_error(row: usize, message: String) -> QueryError { + QueryError { + kind: QueryErrorKind::Predicate, + row, + column: 0, + offset: 0, + message, + } +} + +impl fmt::Display for IncludedRangesError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Incorrect range by index: {}", self.0) + } +} + +impl fmt::Display for LanguageError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Incompatible language version {}. Expected minimum {}, maximum {}", + self.version, MIN_COMPATIBLE_LANGUAGE_VERSION, LANGUAGE_VERSION, + ) + } +} + +impl fmt::Display for QueryError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let msg = match self.kind { + QueryErrorKind::Field => "Invalid field name ", + QueryErrorKind::NodeType => "Invalid node type ", + QueryErrorKind::Capture => "Invalid capture name ", + QueryErrorKind::Predicate => "Invalid predicate: ", + QueryErrorKind::Structure => "Impossible pattern:\n", + QueryErrorKind::Syntax => "Invalid syntax:\n", + QueryErrorKind::Language => "", + }; + if msg.is_empty() { + write!(f, "{}", self.message) + } else { + write!( + f, + "Query error at {}:{}. {}{}", + self.row + 1, + self.column + 1, + msg, + self.message + ) + } + } +} + +#[doc(hidden)] +#[must_use] +pub fn format_sexp(sexp: &str, initial_indent_level: usize) -> String { + let mut indent_level = initial_indent_level; + let mut formatted = String::new(); + let mut has_field = false; + + let mut c_iter = sexp.chars().peekable(); + let mut s = String::with_capacity(sexp.len()); + let mut quote = '\0'; + let mut saw_paren = false; + let mut did_last = false; + + let mut fetch_next_str = |next: &mut String| { + next.clear(); + while let Some(c) = c_iter.next() { + if c == '\'' || c == '"' { + quote = c; + } else if c == ' ' || (c == ')' && quote != '\0') { + if let Some(next_c) = c_iter.peek() { + if *next_c == quote { + next.push(c); + next.push(*next_c); + c_iter.next(); + quote = '\0'; + continue; + } + } + break; + } + if c == ')' { + saw_paren = true; + break; + } + next.push(c); + } + + // at the end + if c_iter.peek().is_none() && next.is_empty() { + if saw_paren { + // but did we see a ) before ending? + saw_paren = false; + return Some(()); + } + if !did_last { + // but did we account for the end empty string as if we're splitting? + did_last = true; + return Some(()); + } + return None; + } + Some(()) + }; + + while fetch_next_str(&mut s).is_some() { + if s.is_empty() && indent_level > 0 { + // ")" + indent_level -= 1; + write!(formatted, ")").unwrap(); + } else if s.starts_with('(') { + if has_field { + has_field = false; + } else { + if indent_level > 0 { + writeln!(formatted).unwrap(); + for _ in 0..indent_level { + write!(formatted, " ").unwrap(); + } + } + indent_level += 1; + } + + // "(node_name" + write!(formatted, "{s}").unwrap(); + + // "(MISSING node_name" or "(UNEXPECTED 'x'" + if s.starts_with("(MISSING") || s.starts_with("(UNEXPECTED") { + fetch_next_str(&mut s).unwrap(); + if s.is_empty() { + while indent_level > 0 { + indent_level -= 1; + write!(formatted, ")").unwrap(); + } + } else { + write!(formatted, " {s}").unwrap(); + } + } + } else if s.ends_with(':') { + // "field:" + writeln!(formatted).unwrap(); + for _ in 0..indent_level { + write!(formatted, " ").unwrap(); + } + write!(formatted, "{s} ").unwrap(); + has_field = true; + indent_level += 1; + } + } + + formatted +} + +pub fn wasm_stdlib_symbols() -> impl Iterator { + const WASM_STDLIB_SYMBOLS: &str = include_str!(concat!(env!("OUT_DIR"), "/stdlib-symbols.txt")); + + WASM_STDLIB_SYMBOLS + .lines() + .map(|s| s.trim_matches(|c| c == '"' || c == ',')) +} + +extern "C" { + fn free(ptr: *mut c_void); +} + +static mut FREE_FN: unsafe extern "C" fn(ptr: *mut c_void) = free; + +/// Sets the memory allocation functions that the core library should use. +/// +/// # Safety +/// +/// This function uses FFI and mutates a static global. +#[doc(alias = "ts_set_allocator")] +pub unsafe fn set_allocator( + new_malloc: Option *mut c_void>, + new_calloc: Option *mut c_void>, + new_realloc: Option *mut c_void>, + new_free: Option, +) { + FREE_FN = new_free.unwrap_or(free); + ffi::ts_set_allocator(new_malloc, new_calloc, new_realloc, new_free); +} + +#[cfg(feature = "std")] +#[cfg_attr(docsrs, doc(cfg(feature = "std")))] +impl error::Error for IncludedRangesError {} +#[cfg(feature = "std")] +#[cfg_attr(docsrs, doc(cfg(feature = "std")))] +impl error::Error for LanguageError {} +#[cfg(feature = "std")] +#[cfg_attr(docsrs, doc(cfg(feature = "std")))] +impl error::Error for QueryError {} + +unsafe impl Send for Language {} +unsafe impl Sync for Language {} + +unsafe impl Send for Node<'_> {} +unsafe impl Sync for Node<'_> {} + +unsafe impl Send for LookaheadIterator {} +unsafe impl Sync for LookaheadIterator {} + +unsafe impl Send for LookaheadNamesIterator<'_> {} +unsafe impl Sync for LookaheadNamesIterator<'_> {} + +unsafe impl Send for Parser {} +unsafe impl Sync for Parser {} + +unsafe impl Send for Query {} +unsafe impl Sync for Query {} + +unsafe impl Send for QueryCursor {} +unsafe impl Sync for QueryCursor {} + +unsafe impl Send for Tree {} +unsafe impl Sync for Tree {} + +unsafe impl Send for TreeCursor<'_> {} +unsafe impl Sync for TreeCursor<'_> {} diff --git a/vendor/tree-sitter/binding_rust/util.rs b/vendor/tree-sitter/binding_rust/util.rs new file mode 100644 index 0000000..dc67f1f --- /dev/null +++ b/vendor/tree-sitter/binding_rust/util.rs @@ -0,0 +1,45 @@ +use core::ffi::c_void; + +use super::FREE_FN; + +/// A raw pointer and a length, exposed as an iterator. +pub struct CBufferIter { + ptr: *mut T, + count: usize, + i: usize, +} + +impl CBufferIter { + pub const unsafe fn new(ptr: *mut T, count: usize) -> Self { + Self { ptr, count, i: 0 } + } +} + +impl Iterator for CBufferIter { + type Item = T; + + fn next(&mut self) -> Option { + let i = self.i; + if i >= self.count { + None + } else { + self.i += 1; + Some(unsafe { *self.ptr.add(i) }) + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.count - self.i; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for CBufferIter {} + +impl Drop for CBufferIter { + fn drop(&mut self) { + if !self.ptr.is_null() { + unsafe { (FREE_FN)(self.ptr.cast::()) }; + } + } +} diff --git a/vendor/tree-sitter/binding_rust/wasm_language.rs b/vendor/tree-sitter/binding_rust/wasm_language.rs new file mode 100644 index 0000000..76aff50 --- /dev/null +++ b/vendor/tree-sitter/binding_rust/wasm_language.rs @@ -0,0 +1,147 @@ +use std::{ + error, + ffi::{CStr, CString}, + fmt, + mem::{self, MaybeUninit}, + os::raw::c_char, +}; + +pub use wasmtime_c_api::wasmtime; + +use crate::{ffi, Language, LanguageError, Parser, FREE_FN}; + +// Force Cargo to include wasmtime-c-api as a dependency of this crate, +// even though it is only used by the C code. +#[allow(unused)] +fn _use_wasmtime() { + wasmtime_c_api::wasm_engine_new(); +} + +#[repr(C)] +#[derive(Clone)] +#[allow(non_camel_case_types)] +pub struct wasm_engine_t { + pub(crate) engine: wasmtime::Engine, +} + +pub struct WasmStore(*mut ffi::TSWasmStore); + +unsafe impl Send for WasmStore {} +unsafe impl Sync for WasmStore {} + +#[derive(Debug, PartialEq, Eq)] +pub struct WasmError { + pub kind: WasmErrorKind, + pub message: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum WasmErrorKind { + Parse, + Compile, + Instantiate, + Other, +} + +impl WasmStore { + pub fn new(engine: &wasmtime::Engine) -> Result { + unsafe { + let mut error = MaybeUninit::::uninit(); + let store = ffi::ts_wasm_store_new( + std::ptr::from_ref::(engine) + .cast_mut() + .cast(), + error.as_mut_ptr(), + ); + if store.is_null() { + Err(WasmError::new(error.assume_init())) + } else { + Ok(Self(store)) + } + } + } + + pub fn load_language(&mut self, name: &str, bytes: &[u8]) -> Result { + let name = CString::new(name).unwrap(); + unsafe { + let mut error = MaybeUninit::::uninit(); + let language = ffi::ts_wasm_store_load_language( + self.0, + name.as_ptr(), + bytes.as_ptr().cast::(), + bytes.len() as u32, + error.as_mut_ptr(), + ); + if language.is_null() { + Err(WasmError::new(error.assume_init())) + } else { + Ok(Language(language)) + } + } + } + + #[must_use] + pub fn language_count(&self) -> usize { + unsafe { ffi::ts_wasm_store_language_count(self.0) } + } +} + +impl WasmError { + unsafe fn new(error: ffi::TSWasmError) -> Self { + let message = CStr::from_ptr(error.message).to_str().unwrap().to_string(); + (FREE_FN)(error.message.cast()); + Self { + kind: match error.kind { + ffi::TSWasmErrorKindParse => WasmErrorKind::Parse, + ffi::TSWasmErrorKindCompile => WasmErrorKind::Compile, + ffi::TSWasmErrorKindInstantiate => WasmErrorKind::Instantiate, + _ => WasmErrorKind::Other, + }, + message, + } + } +} + +impl Language { + #[must_use] + pub fn is_wasm(&self) -> bool { + unsafe { ffi::ts_language_is_wasm(self.0) } + } +} + +impl Parser { + pub fn set_wasm_store(&mut self, store: WasmStore) -> Result<(), LanguageError> { + unsafe { ffi::ts_parser_set_wasm_store(self.0.as_ptr(), store.0) }; + mem::forget(store); + Ok(()) + } + + pub fn take_wasm_store(&mut self) -> Option { + let ptr = unsafe { ffi::ts_parser_take_wasm_store(self.0.as_ptr()) }; + if ptr.is_null() { + None + } else { + Some(WasmStore(ptr)) + } + } +} + +impl Drop for WasmStore { + fn drop(&mut self) { + unsafe { ffi::ts_wasm_store_delete(self.0) }; + } +} + +impl fmt::Display for WasmError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let kind = match self.kind { + WasmErrorKind::Parse => "Failed to parse wasm", + WasmErrorKind::Compile => "Failed to compile wasm", + WasmErrorKind::Instantiate => "Failed to instantiate wasm module", + WasmErrorKind::Other => "Unknown error", + }; + write!(f, "{kind}: {}", self.message) + } +} + +impl error::Error for WasmError {} diff --git a/vendor/tree-sitter/include/tree_sitter/api.h b/vendor/tree-sitter/include/tree_sitter/api.h new file mode 100644 index 0000000..2bbfe66 --- /dev/null +++ b/vendor/tree-sitter/include/tree_sitter/api.h @@ -0,0 +1,1478 @@ +#ifndef TREE_SITTER_API_H_ +#define TREE_SITTER_API_H_ + +#ifndef TREE_SITTER_HIDE_SYMBOLS +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC visibility push(default) +#endif +#endif + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/****************************/ +/* Section - ABI Versioning */ +/****************************/ + +/** + * The latest ABI version that is supported by the current version of the + * library. When Languages are generated by the Tree-sitter CLI, they are + * assigned an ABI version number that corresponds to the current CLI version. + * The Tree-sitter library is generally backwards-compatible with languages + * generated using older CLI versions, but is not forwards-compatible. + */ +#define TREE_SITTER_LANGUAGE_VERSION 15 + +/** + * The earliest ABI version that is supported by the current version of the + * library. + */ +#define TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION 13 + +/*******************/ +/* Section - Types */ +/*******************/ + +typedef uint16_t TSStateId; +typedef uint16_t TSSymbol; +typedef uint16_t TSFieldId; +typedef struct TSLanguage TSLanguage; +typedef struct TSParser TSParser; +typedef struct TSTree TSTree; +typedef struct TSQuery TSQuery; +typedef struct TSQueryCursor TSQueryCursor; +typedef struct TSLookaheadIterator TSLookaheadIterator; + +// This function signature reads one code point from the given string, +// returning the number of bytes consumed. It should write the code point +// to the `code_point` pointer, or write -1 if the input is invalid. +typedef uint32_t (*DecodeFunction)( + const uint8_t *string, + uint32_t length, + int32_t *code_point +); + +typedef enum TSInputEncoding { + TSInputEncodingUTF8, + TSInputEncodingUTF16LE, + TSInputEncodingUTF16BE, + TSInputEncodingCustom +} TSInputEncoding; + +typedef enum TSSymbolType { + TSSymbolTypeRegular, + TSSymbolTypeAnonymous, + TSSymbolTypeSupertype, + TSSymbolTypeAuxiliary, +} TSSymbolType; + +typedef struct TSPoint { + uint32_t row; + uint32_t column; +} TSPoint; + +typedef struct TSRange { + TSPoint start_point; + TSPoint end_point; + uint32_t start_byte; + uint32_t end_byte; +} TSRange; + +typedef struct TSInput { + void *payload; + const char *(*read)(void *payload, uint32_t byte_index, TSPoint position, uint32_t *bytes_read); + TSInputEncoding encoding; + DecodeFunction decode; +} TSInput; + +typedef struct TSParseState { + void *payload; + uint32_t current_byte_offset; + bool has_error; +} TSParseState; + +typedef struct TSParseOptions { + void *payload; + bool (*progress_callback)(TSParseState *state); +} TSParseOptions; + +typedef enum TSLogType { + TSLogTypeParse, + TSLogTypeLex, +} TSLogType; + +typedef struct TSLogger { + void *payload; + void (*log)(void *payload, TSLogType log_type, const char *buffer); +} TSLogger; + +typedef struct TSInputEdit { + uint32_t start_byte; + uint32_t old_end_byte; + uint32_t new_end_byte; + TSPoint start_point; + TSPoint old_end_point; + TSPoint new_end_point; +} TSInputEdit; + +typedef struct TSNode { + uint32_t context[4]; + const void *id; + const TSTree *tree; +} TSNode; + +typedef struct TSTreeCursor { + const void *tree; + const void *id; + uint32_t context[3]; +} TSTreeCursor; + +typedef struct TSQueryCapture { + TSNode node; + uint32_t index; +} TSQueryCapture; + +typedef enum TSQuantifier { + TSQuantifierZero = 0, // must match the array initialization value + TSQuantifierZeroOrOne, + TSQuantifierZeroOrMore, + TSQuantifierOne, + TSQuantifierOneOrMore, +} TSQuantifier; + +typedef struct TSQueryMatch { + uint32_t id; + uint16_t pattern_index; + uint16_t capture_count; + const TSQueryCapture *captures; +} TSQueryMatch; + +typedef enum TSQueryPredicateStepType { + TSQueryPredicateStepTypeDone, + TSQueryPredicateStepTypeCapture, + TSQueryPredicateStepTypeString, +} TSQueryPredicateStepType; + +typedef struct TSQueryPredicateStep { + TSQueryPredicateStepType type; + uint32_t value_id; +} TSQueryPredicateStep; + +typedef enum TSQueryError { + TSQueryErrorNone = 0, + TSQueryErrorSyntax, + TSQueryErrorNodeType, + TSQueryErrorField, + TSQueryErrorCapture, + TSQueryErrorStructure, + TSQueryErrorLanguage, +} TSQueryError; + +typedef struct TSQueryCursorState { + void *payload; + uint32_t current_byte_offset; +} TSQueryCursorState; + +typedef struct TSQueryCursorOptions { + void *payload; + bool (*progress_callback)(TSQueryCursorState *state); +} TSQueryCursorOptions; + +/** + * The metadata associated with a language. + * + * Currently, this metadata can be used to check the [Semantic Version](https://semver.org/) + * of the language. This version information should be used to signal if a given parser might + * be incompatible with existing queries when upgrading between major versions, or minor versions + * if it's in zerover. + */ +typedef struct TSLanguageMetadata { + uint8_t major_version; + uint8_t minor_version; + uint8_t patch_version; +} TSLanguageMetadata; + +/********************/ +/* Section - Parser */ +/********************/ + +/** + * Create a new parser. + */ +TSParser *ts_parser_new(void); + +/** + * Delete the parser, freeing all of the memory that it used. + */ +void ts_parser_delete(TSParser *self); + +/** + * Get the parser's current language. + */ +const TSLanguage *ts_parser_language(const TSParser *self); + +/** + * Set the language that the parser should use for parsing. + * + * Returns a boolean indicating whether or not the language was successfully + * assigned. True means assignment succeeded. False means there was a version + * mismatch: the language was generated with an incompatible version of the + * Tree-sitter CLI. Check the language's ABI version using [`ts_language_abi_version`] + * and compare it to this library's [`TREE_SITTER_LANGUAGE_VERSION`] and + * [`TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION`] constants. + */ +bool ts_parser_set_language(TSParser *self, const TSLanguage *language); + +/** + * Set the ranges of text that the parser should include when parsing. + * + * By default, the parser will always include entire documents. This function + * allows you to parse only a *portion* of a document but still return a syntax + * tree whose ranges match up with the document as a whole. You can also pass + * multiple disjoint ranges. + * + * The second and third parameters specify the location and length of an array + * of ranges. The parser does *not* take ownership of these ranges; it copies + * the data, so it doesn't matter how these ranges are allocated. + * + * If `count` is zero, then the entire document will be parsed. Otherwise, + * the given ranges must be ordered from earliest to latest in the document, + * and they must not overlap. That is, the following must hold for all: + * + * `i < count - 1`: `ranges[i].end_byte <= ranges[i + 1].start_byte` + * + * If this requirement is not satisfied, the operation will fail, the ranges + * will not be assigned, and this function will return `false`. On success, + * this function returns `true` + */ +bool ts_parser_set_included_ranges( + TSParser *self, + const TSRange *ranges, + uint32_t count +); + +/** + * Get the ranges of text that the parser will include when parsing. + * + * The returned pointer is owned by the parser. The caller should not free it + * or write to it. The length of the array will be written to the given + * `count` pointer. + */ +const TSRange *ts_parser_included_ranges( + const TSParser *self, + uint32_t *count +); + +/** + * Use the parser to parse some source code and create a syntax tree. + * + * If you are parsing this document for the first time, pass `NULL` for the + * `old_tree` parameter. Otherwise, if you have already parsed an earlier + * version of this document and the document has since been edited, pass the + * previous syntax tree so that the unchanged parts of it can be reused. + * This will save time and memory. For this to work correctly, you must have + * already edited the old syntax tree using the [`ts_tree_edit`] function in a + * way that exactly matches the source code changes. + * + * The [`TSInput`] parameter lets you specify how to read the text. It has the + * following three fields: + * 1. [`read`]: A function to retrieve a chunk of text at a given byte offset + * and (row, column) position. The function should return a pointer to the + * text and write its length to the [`bytes_read`] pointer. The parser does + * not take ownership of this buffer; it just borrows it until it has + * finished reading it. The function should write a zero value to the + * [`bytes_read`] pointer to indicate the end of the document. + * 2. [`payload`]: An arbitrary pointer that will be passed to each invocation + * of the [`read`] function. + * 3. [`encoding`]: An indication of how the text is encoded. Either + * `TSInputEncodingUTF8` or `TSInputEncodingUTF16`. + * + * This function returns a syntax tree on success, and `NULL` on failure. There + * are four possible reasons for failure: + * 1. The parser does not have a language assigned. Check for this using the + [`ts_parser_language`] function. + * 2. Parsing was cancelled due to a timeout that was set by an earlier call to + * the [`ts_parser_set_timeout_micros`] function. You can resume parsing from + * where the parser left out by calling [`ts_parser_parse`] again with the + * same arguments. Or you can start parsing from scratch by first calling + * [`ts_parser_reset`]. + * 3. Parsing was cancelled using a cancellation flag that was set by an + * earlier call to [`ts_parser_set_cancellation_flag`]. You can resume parsing + * from where the parser left out by calling [`ts_parser_parse`] again with + * the same arguments. + * 4. Parsing was cancelled due to the progress callback returning true. This callback + * is passed in [`ts_parser_parse_with_options`] inside the [`TSParseOptions`] struct. + * + * [`read`]: TSInput::read + * [`payload`]: TSInput::payload + * [`encoding`]: TSInput::encoding + * [`bytes_read`]: TSInput::read + */ +TSTree *ts_parser_parse( + TSParser *self, + const TSTree *old_tree, + TSInput input +); + +/** + * Use the parser to parse some source code and create a syntax tree, with some options. + * + * See [`ts_parser_parse`] for more details. + * + * See [`TSParseOptions`] for more details on the options. + */ +TSTree* ts_parser_parse_with_options( + TSParser *self, + const TSTree *old_tree, + TSInput input, + TSParseOptions parse_options +); + +/** + * Use the parser to parse some source code stored in one contiguous buffer. + * The first two parameters are the same as in the [`ts_parser_parse`] function + * above. The second two parameters indicate the location of the buffer and its + * length in bytes. + */ +TSTree *ts_parser_parse_string( + TSParser *self, + const TSTree *old_tree, + const char *string, + uint32_t length +); + +/** + * Use the parser to parse some source code stored in one contiguous buffer with + * a given encoding. The first four parameters work the same as in the + * [`ts_parser_parse_string`] method above. The final parameter indicates whether + * the text is encoded as UTF8 or UTF16. + */ +TSTree *ts_parser_parse_string_encoding( + TSParser *self, + const TSTree *old_tree, + const char *string, + uint32_t length, + TSInputEncoding encoding +); + +/** + * Instruct the parser to start the next parse from the beginning. + * + * If the parser previously failed because of a timeout or a cancellation, then + * by default, it will resume where it left off on the next call to + * [`ts_parser_parse`] or other parsing functions. If you don't want to resume, + * and instead intend to use this parser to parse some other document, you must + * call [`ts_parser_reset`] first. + */ +void ts_parser_reset(TSParser *self); + +/** + * @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26. + * + * Set the maximum duration in microseconds that parsing should be allowed to + * take before halting. + * + * If parsing takes longer than this, it will halt early, returning NULL. + * See [`ts_parser_parse`] for more information. + */ +void ts_parser_set_timeout_micros(TSParser *self, uint64_t timeout_micros); + +/** + * @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26. + * + * Get the duration in microseconds that parsing is allowed to take. + */ +uint64_t ts_parser_timeout_micros(const TSParser *self); + +/** + * @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26. + * + * Set the parser's current cancellation flag pointer. + * + * If a non-null pointer is assigned, then the parser will periodically read + * from this pointer during parsing. If it reads a non-zero value, it will + * halt early, returning NULL. See [`ts_parser_parse`] for more information. + */ +void ts_parser_set_cancellation_flag(TSParser *self, const size_t *flag); + +/** + * @deprecated use [`ts_parser_parse_with_options`] and pass in a callback instead, this will be removed in 0.26. + * + * Get the parser's current cancellation flag pointer. + */ +const size_t *ts_parser_cancellation_flag(const TSParser *self); + +/** + * Set the logger that a parser should use during parsing. + * + * The parser does not take ownership over the logger payload. If a logger was + * previously assigned, the caller is responsible for releasing any memory + * owned by the previous logger. + */ +void ts_parser_set_logger(TSParser *self, TSLogger logger); + +/** + * Get the parser's current logger. + */ +TSLogger ts_parser_logger(const TSParser *self); + +/** + * Set the file descriptor to which the parser should write debugging graphs + * during parsing. The graphs are formatted in the DOT language. You may want + * to pipe these graphs directly to a `dot(1)` process in order to generate + * SVG output. You can turn off this logging by passing a negative number. + */ +void ts_parser_print_dot_graphs(TSParser *self, int fd); + +/******************/ +/* Section - Tree */ +/******************/ + +/** + * Create a shallow copy of the syntax tree. This is very fast. + * + * You need to copy a syntax tree in order to use it on more than one thread at + * a time, as syntax trees are not thread safe. + */ +TSTree *ts_tree_copy(const TSTree *self); + +/** + * Delete the syntax tree, freeing all of the memory that it used. + */ +void ts_tree_delete(TSTree *self); + +/** + * Get the root node of the syntax tree. + */ +TSNode ts_tree_root_node(const TSTree *self); + +/** + * Get the root node of the syntax tree, but with its position + * shifted forward by the given offset. + */ +TSNode ts_tree_root_node_with_offset( + const TSTree *self, + uint32_t offset_bytes, + TSPoint offset_extent +); + +/** + * Get the language that was used to parse the syntax tree. + */ +const TSLanguage *ts_tree_language(const TSTree *self); + +/** + * Get the array of included ranges that was used to parse the syntax tree. + * + * The returned pointer must be freed by the caller. + */ +TSRange *ts_tree_included_ranges(const TSTree *self, uint32_t *length); + +/** + * Edit the syntax tree to keep it in sync with source code that has been + * edited. + * + * You must describe the edit both in terms of byte offsets and in terms of + * (row, column) coordinates. + */ +void ts_tree_edit(TSTree *self, const TSInputEdit *edit); + +/** + * Compare an old edited syntax tree to a new syntax tree representing the same + * document, returning an array of ranges whose syntactic structure has changed. + * + * For this to work correctly, the old syntax tree must have been edited such + * that its ranges match up to the new tree. Generally, you'll want to call + * this function right after calling one of the [`ts_parser_parse`] functions. + * You need to pass the old tree that was passed to parse, as well as the new + * tree that was returned from that function. + * + * The returned ranges indicate areas where the hierarchical structure of syntax + * nodes (from root to leaf) has changed between the old and new trees. Characters + * outside these ranges have identical ancestor nodes in both trees. + * + * Note that the returned ranges may be slightly larger than the exact changed areas, + * but Tree-sitter attempts to make them as small as possible. + * + * The returned array is allocated using `malloc` and the caller is responsible + * for freeing it using `free`. The length of the array will be written to the + * given `length` pointer. + */ +TSRange *ts_tree_get_changed_ranges( + const TSTree *old_tree, + const TSTree *new_tree, + uint32_t *length +); + +/** + * Write a DOT graph describing the syntax tree to the given file. + */ +void ts_tree_print_dot_graph(const TSTree *self, int file_descriptor); + +/******************/ +/* Section - Node */ +/******************/ + +/** + * Get the node's type as a null-terminated string. + */ +const char *ts_node_type(TSNode self); + +/** + * Get the node's type as a numerical id. + */ +TSSymbol ts_node_symbol(TSNode self); + +/** + * Get the node's language. + */ +const TSLanguage *ts_node_language(TSNode self); + +/** + * Get the node's type as it appears in the grammar ignoring aliases as a + * null-terminated string. + */ +const char *ts_node_grammar_type(TSNode self); + +/** + * Get the node's type as a numerical id as it appears in the grammar ignoring + * aliases. This should be used in [`ts_language_next_state`] instead of + * [`ts_node_symbol`]. + */ +TSSymbol ts_node_grammar_symbol(TSNode self); + +/** + * Get the node's start byte. + */ +uint32_t ts_node_start_byte(TSNode self); + +/** + * Get the node's start position in terms of rows and columns. + */ +TSPoint ts_node_start_point(TSNode self); + +/** + * Get the node's end byte. + */ +uint32_t ts_node_end_byte(TSNode self); + +/** + * Get the node's end position in terms of rows and columns. + */ +TSPoint ts_node_end_point(TSNode self); + +/** + * Get an S-expression representing the node as a string. + * + * This string is allocated with `malloc` and the caller is responsible for + * freeing it using `free`. + */ +char *ts_node_string(TSNode self); + +/** + * Check if the node is null. Functions like [`ts_node_child`] and + * [`ts_node_next_sibling`] will return a null node to indicate that no such node + * was found. + */ +bool ts_node_is_null(TSNode self); + +/** + * Check if the node is *named*. Named nodes correspond to named rules in the + * grammar, whereas *anonymous* nodes correspond to string literals in the + * grammar. + */ +bool ts_node_is_named(TSNode self); + +/** + * Check if the node is *missing*. Missing nodes are inserted by the parser in + * order to recover from certain kinds of syntax errors. + */ +bool ts_node_is_missing(TSNode self); + +/** + * Check if the node is *extra*. Extra nodes represent things like comments, + * which are not required the grammar, but can appear anywhere. + */ +bool ts_node_is_extra(TSNode self); + +/** + * Check if a syntax node has been edited. + */ +bool ts_node_has_changes(TSNode self); + +/** + * Check if the node is a syntax error or contains any syntax errors. + */ +bool ts_node_has_error(TSNode self); + +/** + * Check if the node is a syntax error. +*/ +bool ts_node_is_error(TSNode self); + +/** + * Get this node's parse state. +*/ +TSStateId ts_node_parse_state(TSNode self); + +/** + * Get the parse state after this node. +*/ +TSStateId ts_node_next_parse_state(TSNode self); + +/** + * Get the node's immediate parent. + * Prefer [`ts_node_child_with_descendant`] for + * iterating over the node's ancestors. + */ +TSNode ts_node_parent(TSNode self); + +/** + * Get the node that contains `descendant`. + * + * Note that this can return `descendant` itself. + */ +TSNode ts_node_child_with_descendant(TSNode self, TSNode descendant); + +/** + * Get the node's child at the given index, where zero represents the first + * child. + */ +TSNode ts_node_child(TSNode self, uint32_t child_index); + +/** + * Get the field name for node's child at the given index, where zero represents + * the first child. Returns NULL, if no field is found. + */ +const char *ts_node_field_name_for_child(TSNode self, uint32_t child_index); + +/** + * Get the field name for node's named child at the given index, where zero + * represents the first named child. Returns NULL, if no field is found. + */ +const char *ts_node_field_name_for_named_child(TSNode self, uint32_t named_child_index); + +/** + * Get the node's number of children. + */ +uint32_t ts_node_child_count(TSNode self); + +/** + * Get the node's *named* child at the given index. + * + * See also [`ts_node_is_named`]. + */ +TSNode ts_node_named_child(TSNode self, uint32_t child_index); + +/** + * Get the node's number of *named* children. + * + * See also [`ts_node_is_named`]. + */ +uint32_t ts_node_named_child_count(TSNode self); + +/** + * Get the node's child with the given field name. + */ +TSNode ts_node_child_by_field_name( + TSNode self, + const char *name, + uint32_t name_length +); + +/** + * Get the node's child with the given numerical field id. + * + * You can convert a field name to an id using the + * [`ts_language_field_id_for_name`] function. + */ +TSNode ts_node_child_by_field_id(TSNode self, TSFieldId field_id); + +/** + * Get the node's next / previous sibling. + */ +TSNode ts_node_next_sibling(TSNode self); +TSNode ts_node_prev_sibling(TSNode self); + +/** + * Get the node's next / previous *named* sibling. + */ +TSNode ts_node_next_named_sibling(TSNode self); +TSNode ts_node_prev_named_sibling(TSNode self); + +/** + * Get the node's first child that contains or starts after the given byte offset. + */ +TSNode ts_node_first_child_for_byte(TSNode self, uint32_t byte); + +/** + * Get the node's first named child that contains or starts after the given byte offset. + */ +TSNode ts_node_first_named_child_for_byte(TSNode self, uint32_t byte); + +/** + * Get the node's number of descendants, including one for the node itself. + */ +uint32_t ts_node_descendant_count(TSNode self); + +/** + * Get the smallest node within this node that spans the given range of bytes + * or (row, column) positions. + */ +TSNode ts_node_descendant_for_byte_range(TSNode self, uint32_t start, uint32_t end); +TSNode ts_node_descendant_for_point_range(TSNode self, TSPoint start, TSPoint end); + +/** + * Get the smallest named node within this node that spans the given range of + * bytes or (row, column) positions. + */ +TSNode ts_node_named_descendant_for_byte_range(TSNode self, uint32_t start, uint32_t end); +TSNode ts_node_named_descendant_for_point_range(TSNode self, TSPoint start, TSPoint end); + +/** + * Edit the node to keep it in-sync with source code that has been edited. + * + * This function is only rarely needed. When you edit a syntax tree with the + * [`ts_tree_edit`] function, all of the nodes that you retrieve from the tree + * afterward will already reflect the edit. You only need to use [`ts_node_edit`] + * when you have a [`TSNode`] instance that you want to keep and continue to use + * after an edit. + */ +void ts_node_edit(TSNode *self, const TSInputEdit *edit); + +/** + * Check if two nodes are identical. + */ +bool ts_node_eq(TSNode self, TSNode other); + +/************************/ +/* Section - TreeCursor */ +/************************/ + +/** + * Create a new tree cursor starting from the given node. + * + * A tree cursor allows you to walk a syntax tree more efficiently than is + * possible using the [`TSNode`] functions. It is a mutable object that is always + * on a certain syntax node, and can be moved imperatively to different nodes. + * + * Note that the given node is considered the root of the cursor, + * and the cursor cannot walk outside this node. + */ +TSTreeCursor ts_tree_cursor_new(TSNode node); + +/** + * Delete a tree cursor, freeing all of the memory that it used. + */ +void ts_tree_cursor_delete(TSTreeCursor *self); + +/** + * Re-initialize a tree cursor to start at the original node that the cursor was + * constructed with. + */ +void ts_tree_cursor_reset(TSTreeCursor *self, TSNode node); + +/** + * Re-initialize a tree cursor to the same position as another cursor. + * + * Unlike [`ts_tree_cursor_reset`], this will not lose parent information and + * allows reusing already created cursors. +*/ +void ts_tree_cursor_reset_to(TSTreeCursor *dst, const TSTreeCursor *src); + +/** + * Get the tree cursor's current node. + */ +TSNode ts_tree_cursor_current_node(const TSTreeCursor *self); + +/** + * Get the field name of the tree cursor's current node. + * + * This returns `NULL` if the current node doesn't have a field. + * See also [`ts_node_child_by_field_name`]. + */ +const char *ts_tree_cursor_current_field_name(const TSTreeCursor *self); + +/** + * Get the field id of the tree cursor's current node. + * + * This returns zero if the current node doesn't have a field. + * See also [`ts_node_child_by_field_id`], [`ts_language_field_id_for_name`]. + */ +TSFieldId ts_tree_cursor_current_field_id(const TSTreeCursor *self); + +/** + * Move the cursor to the parent of its current node. + * + * This returns `true` if the cursor successfully moved, and returns `false` + * if there was no parent node (the cursor was already on the root node). + * + * Note that the node the cursor was constructed with is considered the root + * of the cursor, and the cursor cannot walk outside this node. + */ +bool ts_tree_cursor_goto_parent(TSTreeCursor *self); + +/** + * Move the cursor to the next sibling of its current node. + * + * This returns `true` if the cursor successfully moved, and returns `false` + * if there was no next sibling node. + * + * Note that the node the cursor was constructed with is considered the root + * of the cursor, and the cursor cannot walk outside this node. + */ +bool ts_tree_cursor_goto_next_sibling(TSTreeCursor *self); + +/** + * Move the cursor to the previous sibling of its current node. + * + * This returns `true` if the cursor successfully moved, and returns `false` if + * there was no previous sibling node. + * + * Note, that this function may be slower than + * [`ts_tree_cursor_goto_next_sibling`] due to how node positions are stored. In + * the worst case, this will need to iterate through all the children up to the + * previous sibling node to recalculate its position. Also note that the node the cursor + * was constructed with is considered the root of the cursor, and the cursor cannot + * walk outside this node. + */ +bool ts_tree_cursor_goto_previous_sibling(TSTreeCursor *self); + +/** + * Move the cursor to the first child of its current node. + * + * This returns `true` if the cursor successfully moved, and returns `false` + * if there were no children. + */ +bool ts_tree_cursor_goto_first_child(TSTreeCursor *self); + +/** + * Move the cursor to the last child of its current node. + * + * This returns `true` if the cursor successfully moved, and returns `false` if + * there were no children. + * + * Note that this function may be slower than [`ts_tree_cursor_goto_first_child`] + * because it needs to iterate through all the children to compute the child's + * position. + */ +bool ts_tree_cursor_goto_last_child(TSTreeCursor *self); + +/** + * Move the cursor to the node that is the nth descendant of + * the original node that the cursor was constructed with, where + * zero represents the original node itself. + */ +void ts_tree_cursor_goto_descendant(TSTreeCursor *self, uint32_t goal_descendant_index); + +/** + * Get the index of the cursor's current node out of all of the + * descendants of the original node that the cursor was constructed with. + */ +uint32_t ts_tree_cursor_current_descendant_index(const TSTreeCursor *self); + +/** + * Get the depth of the cursor's current node relative to the original + * node that the cursor was constructed with. + */ +uint32_t ts_tree_cursor_current_depth(const TSTreeCursor *self); + +/** + * Move the cursor to the first child of its current node that contains or starts after + * the given byte offset or point. + * + * This returns the index of the child node if one was found, and returns -1 + * if no such child was found. + */ +int64_t ts_tree_cursor_goto_first_child_for_byte(TSTreeCursor *self, uint32_t goal_byte); +int64_t ts_tree_cursor_goto_first_child_for_point(TSTreeCursor *self, TSPoint goal_point); + +TSTreeCursor ts_tree_cursor_copy(const TSTreeCursor *cursor); + +/*******************/ +/* Section - Query */ +/*******************/ + +/** + * Create a new query from a string containing one or more S-expression + * patterns. The query is associated with a particular language, and can + * only be run on syntax nodes parsed with that language. + * + * If all of the given patterns are valid, this returns a [`TSQuery`]. + * If a pattern is invalid, this returns `NULL`, and provides two pieces + * of information about the problem: + * 1. The byte offset of the error is written to the `error_offset` parameter. + * 2. The type of error is written to the `error_type` parameter. + */ +TSQuery *ts_query_new( + const TSLanguage *language, + const char *source, + uint32_t source_len, + uint32_t *error_offset, + TSQueryError *error_type +); + +/** + * Delete a query, freeing all of the memory that it used. + */ +void ts_query_delete(TSQuery *self); + +/** + * Get the number of patterns, captures, or string literals in the query. + */ +uint32_t ts_query_pattern_count(const TSQuery *self); +uint32_t ts_query_capture_count(const TSQuery *self); +uint32_t ts_query_string_count(const TSQuery *self); + +/** + * Get the byte offset where the given pattern starts in the query's source. + * + * This can be useful when combining queries by concatenating their source + * code strings. + */ +uint32_t ts_query_start_byte_for_pattern(const TSQuery *self, uint32_t pattern_index); + +/** + * Get the byte offset where the given pattern ends in the query's source. + * + * This can be useful when combining queries by concatenating their source + * code strings. + */ +uint32_t ts_query_end_byte_for_pattern(const TSQuery *self, uint32_t pattern_index); + +/** + * Get all of the predicates for the given pattern in the query. + * + * The predicates are represented as a single array of steps. There are three + * types of steps in this array, which correspond to the three legal values for + * the `type` field: + * - `TSQueryPredicateStepTypeCapture` - Steps with this type represent names + * of captures. Their `value_id` can be used with the + * [`ts_query_capture_name_for_id`] function to obtain the name of the capture. + * - `TSQueryPredicateStepTypeString` - Steps with this type represent literal + * strings. Their `value_id` can be used with the + * [`ts_query_string_value_for_id`] function to obtain their string value. + * - `TSQueryPredicateStepTypeDone` - Steps with this type are *sentinels* + * that represent the end of an individual predicate. If a pattern has two + * predicates, then there will be two steps with this `type` in the array. + */ +const TSQueryPredicateStep *ts_query_predicates_for_pattern( + const TSQuery *self, + uint32_t pattern_index, + uint32_t *step_count +); + +/* + * Check if the given pattern in the query has a single root node. + */ +bool ts_query_is_pattern_rooted(const TSQuery *self, uint32_t pattern_index); + +/* + * Check if the given pattern in the query is 'non local'. + * + * A non-local pattern has multiple root nodes and can match within a + * repeating sequence of nodes, as specified by the grammar. Non-local + * patterns disable certain optimizations that would otherwise be possible + * when executing a query on a specific range of a syntax tree. + */ +bool ts_query_is_pattern_non_local(const TSQuery *self, uint32_t pattern_index); + +/* + * Check if a given pattern is guaranteed to match once a given step is reached. + * The step is specified by its byte offset in the query's source code. + */ +bool ts_query_is_pattern_guaranteed_at_step(const TSQuery *self, uint32_t byte_offset); + +/** + * Get the name and length of one of the query's captures, or one of the + * query's string literals. Each capture and string is associated with a + * numeric id based on the order that it appeared in the query's source. + */ +const char *ts_query_capture_name_for_id( + const TSQuery *self, + uint32_t index, + uint32_t *length +); + +/** + * Get the quantifier of the query's captures. Each capture is * associated + * with a numeric id based on the order that it appeared in the query's source. + */ +TSQuantifier ts_query_capture_quantifier_for_id( + const TSQuery *self, + uint32_t pattern_index, + uint32_t capture_index +); + +const char *ts_query_string_value_for_id( + const TSQuery *self, + uint32_t index, + uint32_t *length +); + +/** + * Disable a certain capture within a query. + * + * This prevents the capture from being returned in matches, and also avoids + * any resource usage associated with recording the capture. Currently, there + * is no way to undo this. + */ +void ts_query_disable_capture(TSQuery *self, const char *name, uint32_t length); + +/** + * Disable a certain pattern within a query. + * + * This prevents the pattern from matching and removes most of the overhead + * associated with the pattern. Currently, there is no way to undo this. + */ +void ts_query_disable_pattern(TSQuery *self, uint32_t pattern_index); + +/** + * Create a new cursor for executing a given query. + * + * The cursor stores the state that is needed to iteratively search + * for matches. To use the query cursor, first call [`ts_query_cursor_exec`] + * to start running a given query on a given syntax node. Then, there are + * two options for consuming the results of the query: + * 1. Repeatedly call [`ts_query_cursor_next_match`] to iterate over all of the + * *matches* in the order that they were found. Each match contains the + * index of the pattern that matched, and an array of captures. Because + * multiple patterns can match the same set of nodes, one match may contain + * captures that appear *before* some of the captures from a previous match. + * 2. Repeatedly call [`ts_query_cursor_next_capture`] to iterate over all of the + * individual *captures* in the order that they appear. This is useful if + * don't care about which pattern matched, and just want a single ordered + * sequence of captures. + * + * If you don't care about consuming all of the results, you can stop calling + * [`ts_query_cursor_next_match`] or [`ts_query_cursor_next_capture`] at any point. + * You can then start executing another query on another node by calling + * [`ts_query_cursor_exec`] again. + */ +TSQueryCursor *ts_query_cursor_new(void); + +/** + * Delete a query cursor, freeing all of the memory that it used. + */ +void ts_query_cursor_delete(TSQueryCursor *self); + +/** + * Start running a given query on a given node. + */ +void ts_query_cursor_exec(TSQueryCursor *self, const TSQuery *query, TSNode node); + +/** + * Start running a given query on a given node, with some options. + */ +void ts_query_cursor_exec_with_options( + TSQueryCursor *self, + const TSQuery *query, + TSNode node, + const TSQueryCursorOptions *query_options +); + +/** + * Manage the maximum number of in-progress matches allowed by this query + * cursor. + * + * Query cursors have an optional maximum capacity for storing lists of + * in-progress captures. If this capacity is exceeded, then the + * earliest-starting match will silently be dropped to make room for further + * matches. This maximum capacity is optional — by default, query cursors allow + * any number of pending matches, dynamically allocating new space for them as + * needed as the query is executed. + */ +bool ts_query_cursor_did_exceed_match_limit(const TSQueryCursor *self); +uint32_t ts_query_cursor_match_limit(const TSQueryCursor *self); +void ts_query_cursor_set_match_limit(TSQueryCursor *self, uint32_t limit); + +/** + * @deprecated use [`ts_query_cursor_exec_with_options`] and pass in a callback instead, this will be removed in 0.26. + * + * Set the maximum duration in microseconds that query execution should be allowed to + * take before halting. + * + * If query execution takes longer than this, it will halt early, returning NULL. + * See [`ts_query_cursor_next_match`] or [`ts_query_cursor_next_capture`] for more information. + */ +void ts_query_cursor_set_timeout_micros(TSQueryCursor *self, uint64_t timeout_micros); + +/** + * @deprecated use [`ts_query_cursor_exec_with_options`] and pass in a callback instead, this will be removed in 0.26. + * + * Get the duration in microseconds that query execution is allowed to take. + * + * This is set via [`ts_query_cursor_set_timeout_micros`]. + */ +uint64_t ts_query_cursor_timeout_micros(const TSQueryCursor *self); + +/** + * Set the range of bytes in which the query will be executed. + * + * The query cursor will return matches that intersect with the given point range. + * This means that a match may be returned even if some of its captures fall + * outside the specified range, as long as at least part of the match + * overlaps with the range. + * + * For example, if a query pattern matches a node that spans a larger area + * than the specified range, but part of that node intersects with the range, + * the entire match will be returned. + * + * This will return `false` if the start byte is greater than the end byte, otherwise + * it will return `true`. + */ +bool ts_query_cursor_set_byte_range(TSQueryCursor *self, uint32_t start_byte, uint32_t end_byte); + +/** + * Set the range of (row, column) positions in which the query will be executed. + * + * The query cursor will return matches that intersect with the given point range. + * This means that a match may be returned even if some of its captures fall + * outside the specified range, as long as at least part of the match + * overlaps with the range. + * + * For example, if a query pattern matches a node that spans a larger area + * than the specified range, but part of that node intersects with the range, + * the entire match will be returned. + * + * This will return `false` if the start point is greater than the end point, otherwise + * it will return `true`. + */ +bool ts_query_cursor_set_point_range(TSQueryCursor *self, TSPoint start_point, TSPoint end_point); + +/** + * Advance to the next match of the currently running query. + * + * If there is a match, write it to `*match` and return `true`. + * Otherwise, return `false`. + */ +bool ts_query_cursor_next_match(TSQueryCursor *self, TSQueryMatch *match); +void ts_query_cursor_remove_match(TSQueryCursor *self, uint32_t match_id); + +/** + * Advance to the next capture of the currently running query. + * + * If there is a capture, write its match to `*match` and its index within + * the match's capture list to `*capture_index`. Otherwise, return `false`. + */ +bool ts_query_cursor_next_capture( + TSQueryCursor *self, + TSQueryMatch *match, + uint32_t *capture_index +); + +/** + * Set the maximum start depth for a query cursor. + * + * This prevents cursors from exploring children nodes at a certain depth. + * Note if a pattern includes many children, then they will still be checked. + * + * The zero max start depth value can be used as a special behavior and + * it helps to destructure a subtree by staying on a node and using captures + * for interested parts. Note that the zero max start depth only limit a search + * depth for a pattern's root node but other nodes that are parts of the pattern + * may be searched at any depth what defined by the pattern structure. + * + * Set to `UINT32_MAX` to remove the maximum start depth. + */ +void ts_query_cursor_set_max_start_depth(TSQueryCursor *self, uint32_t max_start_depth); + +/**********************/ +/* Section - Language */ +/**********************/ + +/** + * Get another reference to the given language. + */ +const TSLanguage *ts_language_copy(const TSLanguage *self); + +/** + * Free any dynamically-allocated resources for this language, if + * this is the last reference. + */ +void ts_language_delete(const TSLanguage *self); + +/** + * Get the number of distinct node types in the language. + */ +uint32_t ts_language_symbol_count(const TSLanguage *self); + +/** + * Get the number of valid states in this language. +*/ +uint32_t ts_language_state_count(const TSLanguage *self); + +/** + * Get the numerical id for the given node type string. + */ +TSSymbol ts_language_symbol_for_name( + const TSLanguage *self, + const char *string, + uint32_t length, + bool is_named +); + +/** + * Get the number of distinct field names in the language. + */ +uint32_t ts_language_field_count(const TSLanguage *self); + +/** + * Get the field name string for the given numerical id. + */ +const char *ts_language_field_name_for_id(const TSLanguage *self, TSFieldId id); + +/** + * Get the numerical id for the given field name string. + */ +TSFieldId ts_language_field_id_for_name(const TSLanguage *self, const char *name, uint32_t name_length); + +/** + * Get a list of all supertype symbols for the language. +*/ +const TSSymbol *ts_language_supertypes(const TSLanguage *self, uint32_t *length); + +/** + * Get a list of all subtype symbol ids for a given supertype symbol. + * + * See [`ts_language_supertypes`] for fetching all supertype symbols. + */ +const TSSymbol *ts_language_subtypes( + const TSLanguage *self, + TSSymbol supertype, + uint32_t *length +); + +/** + * Get a node type string for the given numerical id. + */ +const char *ts_language_symbol_name(const TSLanguage *self, TSSymbol symbol); + +/** + * Check whether the given node type id belongs to named nodes, anonymous nodes, + * or a hidden nodes. + * + * See also [`ts_node_is_named`]. Hidden nodes are never returned from the API. + */ +TSSymbolType ts_language_symbol_type(const TSLanguage *self, TSSymbol symbol); + +/** + * @deprecated use [`ts_language_abi_version`] instead, this will be removed in 0.26. + * + * Get the ABI version number for this language. This version number is used + * to ensure that languages were generated by a compatible version of + * Tree-sitter. + * + * See also [`ts_parser_set_language`]. + */ +uint32_t ts_language_version(const TSLanguage *self); + +/** + * Get the ABI version number for this language. This version number is used + * to ensure that languages were generated by a compatible version of + * Tree-sitter. + * + * See also [`ts_parser_set_language`]. + */ +uint32_t ts_language_abi_version(const TSLanguage *self); + +/** + * Get the metadata for this language. This information is generated by the + * CLI, and relies on the language author providing the correct metadata in + * the language's `tree-sitter.json` file. + * + * See also [`TSMetadata`]. + */ +const TSLanguageMetadata *ts_language_metadata(const TSLanguage *self); + +/** + * Get the next parse state. Combine this with lookahead iterators to generate + * completion suggestions or valid symbols in error nodes. Use + * [`ts_node_grammar_symbol`] for valid symbols. +*/ +TSStateId ts_language_next_state(const TSLanguage *self, TSStateId state, TSSymbol symbol); + +/** + * Get the name of this language. This returns `NULL` in older parsers. + */ +const char *ts_language_name(const TSLanguage *self); + +/********************************/ +/* Section - Lookahead Iterator */ +/********************************/ + +/** + * Create a new lookahead iterator for the given language and parse state. + * + * This returns `NULL` if state is invalid for the language. + * + * Repeatedly using [`ts_lookahead_iterator_next`] and + * [`ts_lookahead_iterator_current_symbol`] will generate valid symbols in the + * given parse state. Newly created lookahead iterators will contain the `ERROR` + * symbol. + * + * Lookahead iterators can be useful to generate suggestions and improve syntax + * error diagnostics. To get symbols valid in an ERROR node, use the lookahead + * iterator on its first leaf node state. For `MISSING` nodes, a lookahead + * iterator created on the previous non-extra leaf node may be appropriate. +*/ +TSLookaheadIterator *ts_lookahead_iterator_new(const TSLanguage *self, TSStateId state); + +/** + * Delete a lookahead iterator freeing all the memory used. +*/ +void ts_lookahead_iterator_delete(TSLookaheadIterator *self); + +/** + * Reset the lookahead iterator to another state. + * + * This returns `true` if the iterator was reset to the given state and `false` + * otherwise. +*/ +bool ts_lookahead_iterator_reset_state(TSLookaheadIterator *self, TSStateId state); + +/** + * Reset the lookahead iterator. + * + * This returns `true` if the language was set successfully and `false` + * otherwise. +*/ +bool ts_lookahead_iterator_reset(TSLookaheadIterator *self, const TSLanguage *language, TSStateId state); + +/** + * Get the current language of the lookahead iterator. +*/ +const TSLanguage *ts_lookahead_iterator_language(const TSLookaheadIterator *self); + +/** + * Advance the lookahead iterator to the next symbol. + * + * This returns `true` if there is a new symbol and `false` otherwise. +*/ +bool ts_lookahead_iterator_next(TSLookaheadIterator *self); + +/** + * Get the current symbol of the lookahead iterator; +*/ +TSSymbol ts_lookahead_iterator_current_symbol(const TSLookaheadIterator *self); + +/** + * Get the current symbol type of the lookahead iterator as a null terminated + * string. +*/ +const char *ts_lookahead_iterator_current_symbol_name(const TSLookaheadIterator *self); + +/*************************************/ +/* Section - WebAssembly Integration */ +/************************************/ + +typedef struct wasm_engine_t TSWasmEngine; +typedef struct TSWasmStore TSWasmStore; + +typedef enum { + TSWasmErrorKindNone = 0, + TSWasmErrorKindParse, + TSWasmErrorKindCompile, + TSWasmErrorKindInstantiate, + TSWasmErrorKindAllocate, +} TSWasmErrorKind; + +typedef struct { + TSWasmErrorKind kind; + char *message; +} TSWasmError; + +/** + * Create a Wasm store. + */ +TSWasmStore *ts_wasm_store_new( + TSWasmEngine *engine, + TSWasmError *error +); + +/** + * Free the memory associated with the given Wasm store. + */ +void ts_wasm_store_delete(TSWasmStore *); + +/** + * Create a language from a buffer of Wasm. The resulting language behaves + * like any other Tree-sitter language, except that in order to use it with + * a parser, that parser must have a Wasm store. Note that the language + * can be used with any Wasm store, it doesn't need to be the same store that + * was used to originally load it. + */ +const TSLanguage *ts_wasm_store_load_language( + TSWasmStore *, + const char *name, + const char *wasm, + uint32_t wasm_len, + TSWasmError *error +); + +/** + * Get the number of languages instantiated in the given wasm store. + */ +size_t ts_wasm_store_language_count(const TSWasmStore *); + +/** + * Check if the language came from a Wasm module. If so, then in order to use + * this language with a Parser, that parser must have a Wasm store assigned. + */ +bool ts_language_is_wasm(const TSLanguage *); + +/** + * Assign the given Wasm store to the parser. A parser must have a Wasm store + * in order to use Wasm languages. + */ +void ts_parser_set_wasm_store(TSParser *, TSWasmStore *); + +/** + * Remove the parser's current Wasm store and return it. This returns NULL if + * the parser doesn't have a Wasm store. + */ +TSWasmStore *ts_parser_take_wasm_store(TSParser *); + +/**********************************/ +/* Section - Global Configuration */ +/**********************************/ + +/** + * Set the allocation functions used by the library. + * + * By default, Tree-sitter uses the standard libc allocation functions, + * but aborts the process when an allocation fails. This function lets + * you supply alternative allocation functions at runtime. + * + * If you pass `NULL` for any parameter, Tree-sitter will switch back to + * its default implementation of that function. + * + * If you call this function after the library has already been used, then + * you must ensure that either: + * 1. All the existing objects have been freed. + * 2. The new allocator shares its state with the old one, so it is capable + * of freeing memory that was allocated by the old allocator. + */ +void ts_set_allocator( + void *(*new_malloc)(size_t), + void *(*new_calloc)(size_t, size_t), + void *(*new_realloc)(void *, size_t), + void (*new_free)(void *) +); + +#ifdef __cplusplus +} +#endif + +#ifndef TREE_SITTER_HIDE_SYMBOLS +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC visibility pop +#endif +#endif + +#endif // TREE_SITTER_API_H_ diff --git a/vendor/tree-sitter/src/alloc.c b/vendor/tree-sitter/src/alloc.c new file mode 100644 index 0000000..e5cb1d5 --- /dev/null +++ b/vendor/tree-sitter/src/alloc.c @@ -0,0 +1,48 @@ +#include "alloc.h" +#include "tree_sitter/api.h" +#include + +static void *ts_malloc_default(size_t size) { + void *result = malloc(size); + if (size > 0 && !result) { + fprintf(stderr, "tree-sitter failed to allocate %zu bytes", size); + abort(); + } + return result; +} + +static void *ts_calloc_default(size_t count, size_t size) { + void *result = calloc(count, size); + if (count > 0 && !result) { + fprintf(stderr, "tree-sitter failed to allocate %zu bytes", count * size); + abort(); + } + return result; +} + +static void *ts_realloc_default(void *buffer, size_t size) { + void *result = realloc(buffer, size); + if (size > 0 && !result) { + fprintf(stderr, "tree-sitter failed to reallocate %zu bytes", size); + abort(); + } + return result; +} + +// Allow clients to override allocation functions dynamically +TS_PUBLIC void *(*ts_current_malloc)(size_t) = ts_malloc_default; +TS_PUBLIC void *(*ts_current_calloc)(size_t, size_t) = ts_calloc_default; +TS_PUBLIC void *(*ts_current_realloc)(void *, size_t) = ts_realloc_default; +TS_PUBLIC void (*ts_current_free)(void *) = free; + +void ts_set_allocator( + void *(*new_malloc)(size_t size), + void *(*new_calloc)(size_t count, size_t size), + void *(*new_realloc)(void *ptr, size_t size), + void (*new_free)(void *ptr) +) { + ts_current_malloc = new_malloc ? new_malloc : ts_malloc_default; + ts_current_calloc = new_calloc ? new_calloc : ts_calloc_default; + ts_current_realloc = new_realloc ? new_realloc : ts_realloc_default; + ts_current_free = new_free ? new_free : free; +} diff --git a/vendor/tree-sitter/src/alloc.h b/vendor/tree-sitter/src/alloc.h new file mode 100644 index 0000000..a27b8a6 --- /dev/null +++ b/vendor/tree-sitter/src/alloc.h @@ -0,0 +1,41 @@ +#ifndef TREE_SITTER_ALLOC_H_ +#define TREE_SITTER_ALLOC_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#if defined(TREE_SITTER_HIDDEN_SYMBOLS) || defined(_WIN32) +#define TS_PUBLIC +#else +#define TS_PUBLIC __attribute__((visibility("default"))) +#endif + +TS_PUBLIC extern void *(*ts_current_malloc)(size_t size); +TS_PUBLIC extern void *(*ts_current_calloc)(size_t count, size_t size); +TS_PUBLIC extern void *(*ts_current_realloc)(void *ptr, size_t size); +TS_PUBLIC extern void (*ts_current_free)(void *ptr); + +// Allow clients to override allocation functions +#ifndef ts_malloc +#define ts_malloc ts_current_malloc +#endif +#ifndef ts_calloc +#define ts_calloc ts_current_calloc +#endif +#ifndef ts_realloc +#define ts_realloc ts_current_realloc +#endif +#ifndef ts_free +#define ts_free ts_current_free +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ALLOC_H_ diff --git a/vendor/tree-sitter/src/array.h b/vendor/tree-sitter/src/array.h new file mode 100644 index 0000000..d965c61 --- /dev/null +++ b/vendor/tree-sitter/src/array.h @@ -0,0 +1,291 @@ +#ifndef TREE_SITTER_ARRAY_H_ +#define TREE_SITTER_ARRAY_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./alloc.h" +#include "./ts_assert.h" + +#include +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4101) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-variable" +#endif + +#define Array(T) \ + struct { \ + T *contents; \ + uint32_t size; \ + uint32_t capacity; \ + } + +/// Initialize an array. +#define array_init(self) \ + ((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL) + +/// Create an empty array. +#define array_new() \ + { NULL, 0, 0 } + +/// Get a pointer to the element at a given `index` in the array. +#define array_get(self, _index) \ + (ts_assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index]) + +/// Get a pointer to the first element in the array. +#define array_front(self) array_get(self, 0) + +/// Get a pointer to the last element in the array. +#define array_back(self) array_get(self, (self)->size - 1) + +/// Clear the array, setting its size to zero. Note that this does not free any +/// memory allocated for the array's contents. +#define array_clear(self) ((self)->size = 0) + +/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is +/// less than the array's current capacity, this function has no effect. +#define array_reserve(self, new_capacity) \ + _array__reserve((Array *)(self), array_elem_size(self), new_capacity) + +/// Free any memory allocated for this array. Note that this does not free any +/// memory allocated for the array's contents. +#define array_delete(self) _array__delete((Array *)(self)) + +/// Push a new `element` onto the end of the array. +#define array_push(self, element) \ + (_array__grow((Array *)(self), 1, array_elem_size(self)), \ + (self)->contents[(self)->size++] = (element)) + +/// Increase the array's size by `count` elements. +/// New elements are zero-initialized. +#define array_grow_by(self, count) \ + do { \ + if ((count) == 0) break; \ + _array__grow((Array *)(self), count, array_elem_size(self)); \ + memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \ + (self)->size += (count); \ + } while (0) + +/// Append all elements from one array to the end of another. +#define array_push_all(self, other) \ + array_extend((self), (other)->size, (other)->contents) + +/// Append `count` elements to the end of the array, reading their values from the +/// `contents` pointer. +#define array_extend(self, count, contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), (self)->size, \ + 0, count, contents \ + ) + +/// Remove `old_count` elements from the array starting at the given `index`. At +/// the same index, insert `new_count` new elements, reading their values from the +/// `new_contents` pointer. +#define array_splice(self, _index, old_count, new_count, new_contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), _index, \ + old_count, new_count, new_contents \ + ) + +/// Insert one `element` into the array at the given `index`. +#define array_insert(self, _index, element) \ + _array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element)) + +/// Remove one element from the array at the given `index`. +#define array_erase(self, _index) \ + _array__erase((Array *)(self), array_elem_size(self), _index) + +/// Pop the last element off the array, returning the element by value. +#define array_pop(self) ((self)->contents[--(self)->size]) + +/// Assign the contents of one array to another, reallocating if necessary. +#define array_assign(self, other) \ + _array__assign((Array *)(self), (const Array *)(other), array_elem_size(self)) + +/// Swap one array with another +#define array_swap(self, other) \ + _array__swap((Array *)(self), (Array *)(other)) + +/// Get the size of the array contents +#define array_elem_size(self) (sizeof *(self)->contents) + +/// Search a sorted array for a given `needle` value, using the given `compare` +/// callback to determine the order. +/// +/// If an existing element is found to be equal to `needle`, then the `index` +/// out-parameter is set to the existing value's index, and the `exists` +/// out-parameter is set to true. Otherwise, `index` is set to an index where +/// `needle` should be inserted in order to preserve the sorting, and `exists` +/// is set to false. +#define array_search_sorted_with(self, compare, needle, _index, _exists) \ + _array__search_sorted(self, 0, compare, , needle, _index, _exists) + +/// Search a sorted array for a given `needle` value, using integer comparisons +/// of a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_with`. +#define array_search_sorted_by(self, field, needle, _index, _exists) \ + _array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists) + +/// Insert a given `value` into a sorted array, using the given `compare` +/// callback to determine the order. +#define array_insert_sorted_with(self, compare, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_with(self, compare, &(value), &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +/// Insert a given `value` into a sorted array, using integer comparisons of +/// a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_by`. +#define array_insert_sorted_by(self, field, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_by(self, field, (value) field, &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +// Private + +typedef Array(void) Array; + +/// This is not what you're looking for, see `array_delete`. +static inline void _array__delete(Array *self) { + if (self->contents) { + ts_free(self->contents); + self->contents = NULL; + self->size = 0; + self->capacity = 0; + } +} + +/// This is not what you're looking for, see `array_erase`. +static inline void _array__erase(Array *self, size_t element_size, + uint32_t index) { + ts_assert(index < self->size); + char *contents = (char *)self->contents; + memmove(contents + index * element_size, contents + (index + 1) * element_size, + (self->size - index - 1) * element_size); + self->size--; +} + +/// This is not what you're looking for, see `array_reserve`. +static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) { + if (new_capacity > self->capacity) { + if (self->contents) { + self->contents = ts_realloc(self->contents, new_capacity * element_size); + } else { + self->contents = ts_malloc(new_capacity * element_size); + } + self->capacity = new_capacity; + } +} + +/// This is not what you're looking for, see `array_assign`. +static inline void _array__assign(Array *self, const Array *other, size_t element_size) { + _array__reserve(self, element_size, other->size); + self->size = other->size; + memcpy(self->contents, other->contents, self->size * element_size); +} + +/// This is not what you're looking for, see `array_swap`. +static inline void _array__swap(Array *self, Array *other) { + Array swap = *other; + *other = *self; + *self = swap; +} + +/// This is not what you're looking for, see `array_push` or `array_grow_by`. +static inline void _array__grow(Array *self, uint32_t count, size_t element_size) { + uint32_t new_size = self->size + count; + if (new_size > self->capacity) { + uint32_t new_capacity = self->capacity * 2; + if (new_capacity < 8) new_capacity = 8; + if (new_capacity < new_size) new_capacity = new_size; + _array__reserve(self, element_size, new_capacity); + } +} + +/// This is not what you're looking for, see `array_splice`. +static inline void _array__splice(Array *self, size_t element_size, + uint32_t index, uint32_t old_count, + uint32_t new_count, const void *elements) { + uint32_t new_size = self->size + new_count - old_count; + uint32_t old_end = index + old_count; + uint32_t new_end = index + new_count; + ts_assert(old_end <= self->size); + + _array__reserve(self, element_size, new_size); + + char *contents = (char *)self->contents; + if (self->size > old_end) { + memmove( + contents + new_end * element_size, + contents + old_end * element_size, + (self->size - old_end) * element_size + ); + } + if (new_count > 0) { + if (elements) { + memcpy( + (contents + index * element_size), + elements, + new_count * element_size + ); + } else { + memset( + (contents + index * element_size), + 0, + new_count * element_size + ); + } + } + self->size += new_count - old_count; +} + +/// A binary search routine, based on Rust's `std::slice::binary_search_by`. +/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`. +#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \ + do { \ + *(_index) = start; \ + *(_exists) = false; \ + uint32_t size = (self)->size - *(_index); \ + if (size == 0) break; \ + int comparison; \ + while (size > 1) { \ + uint32_t half_size = size / 2; \ + uint32_t mid_index = *(_index) + half_size; \ + comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \ + if (comparison <= 0) *(_index) = mid_index; \ + size -= half_size; \ + } \ + comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \ + if (comparison == 0) *(_exists) = true; \ + else if (comparison < 0) *(_index) += 1; \ + } while (0) + +/// Helper macro for the `_sorted_by` routines below. This takes the left (existing) +/// parameter by reference in order to work with the generic sorting function above. +#define _compare_int(a, b) ((int)*(a) - (int)(b)) + +#ifdef _MSC_VER +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ARRAY_H_ diff --git a/vendor/tree-sitter/src/atomic.h b/vendor/tree-sitter/src/atomic.h new file mode 100644 index 0000000..e680b60 --- /dev/null +++ b/vendor/tree-sitter/src/atomic.h @@ -0,0 +1,68 @@ +#ifndef TREE_SITTER_ATOMIC_H_ +#define TREE_SITTER_ATOMIC_H_ + +#include +#include +#include + +#ifdef __TINYC__ + +static inline size_t atomic_load(const volatile size_t *p) { + return *p; +} + +static inline uint32_t atomic_inc(volatile uint32_t *p) { + *p += 1; + return *p; +} + +static inline uint32_t atomic_dec(volatile uint32_t *p) { + *p-= 1; + return *p; +} + +#elif defined(_WIN32) + +#include + +static inline size_t atomic_load(const volatile size_t *p) { + return *p; +} + +static inline uint32_t atomic_inc(volatile uint32_t *p) { + return InterlockedIncrement((long volatile *)p); +} + +static inline uint32_t atomic_dec(volatile uint32_t *p) { + return InterlockedDecrement((long volatile *)p); +} + +#else + +static inline size_t atomic_load(const volatile size_t *p) { +#ifdef __ATOMIC_RELAXED + return __atomic_load_n(p, __ATOMIC_RELAXED); +#else + return __sync_fetch_and_add((volatile size_t *)p, 0); +#endif +} + +static inline uint32_t atomic_inc(volatile uint32_t *p) { + #ifdef __ATOMIC_RELAXED + return __atomic_add_fetch(p, 1U, __ATOMIC_SEQ_CST); + #else + return __sync_add_and_fetch(p, 1U); + #endif +} + +static inline uint32_t atomic_dec(volatile uint32_t *p) { + #ifdef __ATOMIC_RELAXED + return __atomic_sub_fetch(p, 1U, __ATOMIC_SEQ_CST); + #else + return __sync_sub_and_fetch(p, 1U); + #endif +} + +#endif + +#endif // TREE_SITTER_ATOMIC_H_ diff --git a/vendor/tree-sitter/src/clock.h b/vendor/tree-sitter/src/clock.h new file mode 100644 index 0000000..7a13185 --- /dev/null +++ b/vendor/tree-sitter/src/clock.h @@ -0,0 +1,146 @@ +#ifndef TREE_SITTER_CLOCK_H_ +#define TREE_SITTER_CLOCK_H_ + +#include +#include + +typedef uint64_t TSDuration; + +#ifdef _WIN32 + +// Windows: +// * Represent a time as a performance counter value. +// * Represent a duration as a number of performance counter ticks. + +#include +typedef uint64_t TSClock; + +static inline TSDuration duration_from_micros(uint64_t micros) { + LARGE_INTEGER frequency; + QueryPerformanceFrequency(&frequency); + return micros * (uint64_t)frequency.QuadPart / 1000000; +} + +static inline uint64_t duration_to_micros(TSDuration self) { + LARGE_INTEGER frequency; + QueryPerformanceFrequency(&frequency); + return self * 1000000 / (uint64_t)frequency.QuadPart; +} + +static inline TSClock clock_null(void) { + return 0; +} + +static inline TSClock clock_now(void) { + LARGE_INTEGER result; + QueryPerformanceCounter(&result); + return (uint64_t)result.QuadPart; +} + +static inline TSClock clock_after(TSClock base, TSDuration duration) { + return base + duration; +} + +static inline bool clock_is_null(TSClock self) { + return !self; +} + +static inline bool clock_is_gt(TSClock self, TSClock other) { + return self > other; +} + +#elif defined(CLOCK_MONOTONIC) + +// POSIX with monotonic clock support (Linux, macOS) +// * Represent a time as a monotonic (seconds, nanoseconds) pair. +// * Represent a duration as a number of microseconds. +// +// On these platforms, parse timeouts will correspond accurately to +// real time, regardless of what other processes are running. + +#include +typedef struct timespec TSClock; + +static inline TSDuration duration_from_micros(uint64_t micros) { + return micros; +} + +static inline uint64_t duration_to_micros(TSDuration self) { + return self; +} + +static inline TSClock clock_now(void) { + TSClock result; + clock_gettime(CLOCK_MONOTONIC, &result); + return result; +} + +static inline TSClock clock_null(void) { + return (TSClock) {0, 0}; +} + +static inline TSClock clock_after(TSClock base, TSDuration duration) { + TSClock result = base; + result.tv_sec += duration / 1000000; + result.tv_nsec += (duration % 1000000) * 1000; + if (result.tv_nsec >= 1000000000) { + result.tv_nsec -= 1000000000; + ++(result.tv_sec); + } + return result; +} + +static inline bool clock_is_null(TSClock self) { + return !self.tv_sec && !self.tv_nsec; +} + +static inline bool clock_is_gt(TSClock self, TSClock other) { + if (self.tv_sec > other.tv_sec) return true; + if (self.tv_sec < other.tv_sec) return false; + return self.tv_nsec > other.tv_nsec; +} + +#else + +// POSIX without monotonic clock support +// * Represent a time as a process clock value. +// * Represent a duration as a number of process clock ticks. +// +// On these platforms, parse timeouts may be affected by other processes, +// which is not ideal, but is better than using a non-monotonic time API +// like `gettimeofday`. + +#include +typedef uint64_t TSClock; + +static inline TSDuration duration_from_micros(uint64_t micros) { + return micros * (uint64_t)CLOCKS_PER_SEC / 1000000; +} + +static inline uint64_t duration_to_micros(TSDuration self) { + return self * 1000000 / (uint64_t)CLOCKS_PER_SEC; +} + +static inline TSClock clock_null(void) { + return 0; +} + +static inline TSClock clock_now(void) { + return (uint64_t)clock(); +} + +static inline TSClock clock_after(TSClock base, TSDuration duration) { + return base + duration; +} + +static inline bool clock_is_null(TSClock self) { + return !self; +} + +static inline bool clock_is_gt(TSClock self, TSClock other) { + return self > other; +} + +#endif + +#endif // TREE_SITTER_CLOCK_H_ diff --git a/vendor/tree-sitter/src/error_costs.h b/vendor/tree-sitter/src/error_costs.h new file mode 100644 index 0000000..32d3666 --- /dev/null +++ b/vendor/tree-sitter/src/error_costs.h @@ -0,0 +1,11 @@ +#ifndef TREE_SITTER_ERROR_COSTS_H_ +#define TREE_SITTER_ERROR_COSTS_H_ + +#define ERROR_STATE 0 +#define ERROR_COST_PER_RECOVERY 500 +#define ERROR_COST_PER_MISSING_TREE 110 +#define ERROR_COST_PER_SKIPPED_TREE 100 +#define ERROR_COST_PER_SKIPPED_LINE 30 +#define ERROR_COST_PER_SKIPPED_CHAR 1 + +#endif diff --git a/vendor/tree-sitter/src/get_changed_ranges.c b/vendor/tree-sitter/src/get_changed_ranges.c new file mode 100644 index 0000000..11084c3 --- /dev/null +++ b/vendor/tree-sitter/src/get_changed_ranges.c @@ -0,0 +1,523 @@ +#include "./get_changed_ranges.h" +#include "./subtree.h" +#include "./language.h" +#include "./error_costs.h" +#include "./tree_cursor.h" +#include "./ts_assert.h" + +// #define DEBUG_GET_CHANGED_RANGES + +static void ts_range_array_add( + TSRangeArray *self, + Length start, + Length end +) { + if (self->size > 0) { + TSRange *last_range = array_back(self); + if (start.bytes <= last_range->end_byte) { + last_range->end_byte = end.bytes; + last_range->end_point = end.extent; + return; + } + } + + if (start.bytes < end.bytes) { + TSRange range = { start.extent, end.extent, start.bytes, end.bytes }; + array_push(self, range); + } +} + +bool ts_range_array_intersects( + const TSRangeArray *self, + unsigned start_index, + uint32_t start_byte, + uint32_t end_byte +) { + for (unsigned i = start_index; i < self->size; i++) { + TSRange *range = array_get(self, i); + if (range->end_byte > start_byte) { + if (range->start_byte >= end_byte) break; + return true; + } + } + return false; +} + +void ts_range_array_get_changed_ranges( + const TSRange *old_ranges, unsigned old_range_count, + const TSRange *new_ranges, unsigned new_range_count, + TSRangeArray *differences +) { + unsigned new_index = 0; + unsigned old_index = 0; + Length current_position = length_zero(); + bool in_old_range = false; + bool in_new_range = false; + + while (old_index < old_range_count || new_index < new_range_count) { + const TSRange *old_range = &old_ranges[old_index]; + const TSRange *new_range = &new_ranges[new_index]; + + Length next_old_position; + if (in_old_range) { + next_old_position = (Length) {old_range->end_byte, old_range->end_point}; + } else if (old_index < old_range_count) { + next_old_position = (Length) {old_range->start_byte, old_range->start_point}; + } else { + next_old_position = LENGTH_MAX; + } + + Length next_new_position; + if (in_new_range) { + next_new_position = (Length) {new_range->end_byte, new_range->end_point}; + } else if (new_index < new_range_count) { + next_new_position = (Length) {new_range->start_byte, new_range->start_point}; + } else { + next_new_position = LENGTH_MAX; + } + + if (next_old_position.bytes < next_new_position.bytes) { + if (in_old_range != in_new_range) { + ts_range_array_add(differences, current_position, next_old_position); + } + if (in_old_range) old_index++; + current_position = next_old_position; + in_old_range = !in_old_range; + } else if (next_new_position.bytes < next_old_position.bytes) { + if (in_old_range != in_new_range) { + ts_range_array_add(differences, current_position, next_new_position); + } + if (in_new_range) new_index++; + current_position = next_new_position; + in_new_range = !in_new_range; + } else { + if (in_old_range != in_new_range) { + ts_range_array_add(differences, current_position, next_new_position); + } + if (in_old_range) old_index++; + if (in_new_range) new_index++; + in_old_range = !in_old_range; + in_new_range = !in_new_range; + current_position = next_new_position; + } + } +} + +typedef struct { + TreeCursor cursor; + const TSLanguage *language; + unsigned visible_depth; + bool in_padding; + Subtree prev_external_token; +} Iterator; + +static Iterator iterator_new( + TreeCursor *cursor, + const Subtree *tree, + const TSLanguage *language +) { + array_clear(&cursor->stack); + array_push(&cursor->stack, ((TreeCursorEntry) { + .subtree = tree, + .position = length_zero(), + .child_index = 0, + .structural_child_index = 0, + })); + return (Iterator) { + .cursor = *cursor, + .language = language, + .visible_depth = 1, + .in_padding = false, + .prev_external_token = NULL_SUBTREE, + }; +} + +static bool iterator_done(Iterator *self) { + return self->cursor.stack.size == 0; +} + +static Length iterator_start_position(Iterator *self) { + TreeCursorEntry entry = *array_back(&self->cursor.stack); + if (self->in_padding) { + return entry.position; + } else { + return length_add(entry.position, ts_subtree_padding(*entry.subtree)); + } +} + +static Length iterator_end_position(Iterator *self) { + TreeCursorEntry entry = *array_back(&self->cursor.stack); + Length result = length_add(entry.position, ts_subtree_padding(*entry.subtree)); + if (self->in_padding) { + return result; + } else { + return length_add(result, ts_subtree_size(*entry.subtree)); + } +} + +static bool iterator_tree_is_visible(const Iterator *self) { + TreeCursorEntry entry = *array_back(&self->cursor.stack); + if (ts_subtree_visible(*entry.subtree)) return true; + if (self->cursor.stack.size > 1) { + Subtree parent = *array_get(&self->cursor.stack, self->cursor.stack.size - 2)->subtree; + return ts_language_alias_at( + self->language, + parent.ptr->production_id, + entry.structural_child_index + ) != 0; + } + return false; +} + +static void iterator_get_visible_state( + const Iterator *self, + Subtree *tree, + TSSymbol *alias_symbol, + uint32_t *start_byte +) { + uint32_t i = self->cursor.stack.size - 1; + + if (self->in_padding) { + if (i == 0) return; + i--; + } + + for (; i + 1 > 0; i--) { + TreeCursorEntry entry = *array_get(&self->cursor.stack, i); + + if (i > 0) { + const Subtree *parent = array_get(&self->cursor.stack, i - 1)->subtree; + *alias_symbol = ts_language_alias_at( + self->language, + parent->ptr->production_id, + entry.structural_child_index + ); + } + + if (ts_subtree_visible(*entry.subtree) || *alias_symbol) { + *tree = *entry.subtree; + *start_byte = entry.position.bytes; + break; + } + } +} + +static void iterator_ascend(Iterator *self) { + if (iterator_done(self)) return; + if (iterator_tree_is_visible(self) && !self->in_padding) self->visible_depth--; + if (array_back(&self->cursor.stack)->child_index > 0) self->in_padding = false; + self->cursor.stack.size--; +} + +static bool iterator_descend(Iterator *self, uint32_t goal_position) { + if (self->in_padding) return false; + + bool did_descend = false; + do { + did_descend = false; + TreeCursorEntry entry = *array_back(&self->cursor.stack); + Length position = entry.position; + uint32_t structural_child_index = 0; + for (uint32_t i = 0, n = ts_subtree_child_count(*entry.subtree); i < n; i++) { + const Subtree *child = &ts_subtree_children(*entry.subtree)[i]; + Length child_left = length_add(position, ts_subtree_padding(*child)); + Length child_right = length_add(child_left, ts_subtree_size(*child)); + + if (child_right.bytes > goal_position) { + array_push(&self->cursor.stack, ((TreeCursorEntry) { + .subtree = child, + .position = position, + .child_index = i, + .structural_child_index = structural_child_index, + })); + + if (iterator_tree_is_visible(self)) { + if (child_left.bytes > goal_position) { + self->in_padding = true; + } else { + self->visible_depth++; + } + return true; + } + + did_descend = true; + break; + } + + position = child_right; + if (!ts_subtree_extra(*child)) structural_child_index++; + Subtree last_external_token = ts_subtree_last_external_token(*child); + if (last_external_token.ptr) { + self->prev_external_token = last_external_token; + } + } + } while (did_descend); + + return false; +} + +static void iterator_advance(Iterator *self) { + if (self->in_padding) { + self->in_padding = false; + if (iterator_tree_is_visible(self)) { + self->visible_depth++; + } else { + iterator_descend(self, 0); + } + return; + } + + for (;;) { + if (iterator_tree_is_visible(self)) self->visible_depth--; + TreeCursorEntry entry = array_pop(&self->cursor.stack); + if (iterator_done(self)) return; + + const Subtree *parent = array_back(&self->cursor.stack)->subtree; + uint32_t child_index = entry.child_index + 1; + Subtree last_external_token = ts_subtree_last_external_token(*entry.subtree); + if (last_external_token.ptr) { + self->prev_external_token = last_external_token; + } + if (ts_subtree_child_count(*parent) > child_index) { + Length position = length_add(entry.position, ts_subtree_total_size(*entry.subtree)); + uint32_t structural_child_index = entry.structural_child_index; + if (!ts_subtree_extra(*entry.subtree)) structural_child_index++; + const Subtree *next_child = &ts_subtree_children(*parent)[child_index]; + + array_push(&self->cursor.stack, ((TreeCursorEntry) { + .subtree = next_child, + .position = position, + .child_index = child_index, + .structural_child_index = structural_child_index, + })); + + if (iterator_tree_is_visible(self)) { + if (ts_subtree_padding(*next_child).bytes > 0) { + self->in_padding = true; + } else { + self->visible_depth++; + } + } else { + iterator_descend(self, 0); + } + break; + } + } +} + +typedef enum { + IteratorDiffers, + IteratorMayDiffer, + IteratorMatches, +} IteratorComparison; + +static IteratorComparison iterator_compare( + const Iterator *old_iter, + const Iterator *new_iter +) { + Subtree old_tree = NULL_SUBTREE; + Subtree new_tree = NULL_SUBTREE; + uint32_t old_start = 0; + uint32_t new_start = 0; + TSSymbol old_alias_symbol = 0; + TSSymbol new_alias_symbol = 0; + iterator_get_visible_state(old_iter, &old_tree, &old_alias_symbol, &old_start); + iterator_get_visible_state(new_iter, &new_tree, &new_alias_symbol, &new_start); + TSSymbol old_symbol = ts_subtree_symbol(old_tree); + TSSymbol new_symbol = ts_subtree_symbol(new_tree); + + if (!old_tree.ptr && !new_tree.ptr) return IteratorMatches; + if (!old_tree.ptr || !new_tree.ptr) return IteratorDiffers; + if (old_alias_symbol != new_alias_symbol || old_symbol != new_symbol) return IteratorDiffers; + + uint32_t old_size = ts_subtree_size(old_tree).bytes; + uint32_t new_size = ts_subtree_size(new_tree).bytes; + TSStateId old_state = ts_subtree_parse_state(old_tree); + TSStateId new_state = ts_subtree_parse_state(new_tree); + bool old_has_external_tokens = ts_subtree_has_external_tokens(old_tree); + bool new_has_external_tokens = ts_subtree_has_external_tokens(new_tree); + uint32_t old_error_cost = ts_subtree_error_cost(old_tree); + uint32_t new_error_cost = ts_subtree_error_cost(new_tree); + + if ( + old_start != new_start || + old_symbol == ts_builtin_sym_error || + old_size != new_size || + old_state == TS_TREE_STATE_NONE || + new_state == TS_TREE_STATE_NONE || + ((old_state == ERROR_STATE) != (new_state == ERROR_STATE)) || + old_error_cost != new_error_cost || + old_has_external_tokens != new_has_external_tokens || + ts_subtree_has_changes(old_tree) || + ( + old_has_external_tokens && + !ts_subtree_external_scanner_state_eq(old_iter->prev_external_token, new_iter->prev_external_token) + ) + ) { + return IteratorMayDiffer; + } + + return IteratorMatches; +} + +#ifdef DEBUG_GET_CHANGED_RANGES +static inline void iterator_print_state(Iterator *self) { + TreeCursorEntry entry = *array_back(&self->cursor.stack); + TSPoint start = iterator_start_position(self).extent; + TSPoint end = iterator_end_position(self).extent; + const char *name = ts_language_symbol_name(self->language, ts_subtree_symbol(*entry.subtree)); + printf( + "(%-25s %s\t depth:%u [%u, %u] - [%u, %u])", + name, self->in_padding ? "(p)" : " ", + self->visible_depth, + start.row, start.column, + end.row, end.column + ); +} +#endif + +unsigned ts_subtree_get_changed_ranges( + const Subtree *old_tree, const Subtree *new_tree, + TreeCursor *cursor1, TreeCursor *cursor2, + const TSLanguage *language, + const TSRangeArray *included_range_differences, + TSRange **ranges +) { + TSRangeArray results = array_new(); + + Iterator old_iter = iterator_new(cursor1, old_tree, language); + Iterator new_iter = iterator_new(cursor2, new_tree, language); + + unsigned included_range_difference_index = 0; + + Length position = iterator_start_position(&old_iter); + Length next_position = iterator_start_position(&new_iter); + if (position.bytes < next_position.bytes) { + ts_range_array_add(&results, position, next_position); + position = next_position; + } else if (position.bytes > next_position.bytes) { + ts_range_array_add(&results, next_position, position); + next_position = position; + } + + do { + #ifdef DEBUG_GET_CHANGED_RANGES + printf("At [%-2u, %-2u] Compare ", position.extent.row, position.extent.column); + iterator_print_state(&old_iter); + printf("\tvs\t"); + iterator_print_state(&new_iter); + puts(""); + #endif + + // Compare the old and new subtrees. + IteratorComparison comparison = iterator_compare(&old_iter, &new_iter); + + // Even if the two subtrees appear to be identical, they could differ + // internally if they contain a range of text that was previously + // excluded from the parse, and is now included, or vice-versa. + if (comparison == IteratorMatches && ts_range_array_intersects( + included_range_differences, + included_range_difference_index, + position.bytes, + iterator_end_position(&old_iter).bytes + )) { + comparison = IteratorMayDiffer; + } + + bool is_changed = false; + switch (comparison) { + // If the subtrees are definitely identical, move to the end + // of both subtrees. + case IteratorMatches: + next_position = iterator_end_position(&old_iter); + break; + + // If the subtrees might differ internally, descend into both + // subtrees, finding the first child that spans the current position. + case IteratorMayDiffer: + if (iterator_descend(&old_iter, position.bytes)) { + if (!iterator_descend(&new_iter, position.bytes)) { + is_changed = true; + next_position = iterator_end_position(&old_iter); + } + } else if (iterator_descend(&new_iter, position.bytes)) { + is_changed = true; + next_position = iterator_end_position(&new_iter); + } else { + next_position = length_min( + iterator_end_position(&old_iter), + iterator_end_position(&new_iter) + ); + } + break; + + // If the subtrees are different, record a change and then move + // to the end of both subtrees. + case IteratorDiffers: + is_changed = true; + next_position = length_min( + iterator_end_position(&old_iter), + iterator_end_position(&new_iter) + ); + break; + } + + // Ensure that both iterators are caught up to the current position. + while ( + !iterator_done(&old_iter) && + iterator_end_position(&old_iter).bytes <= next_position.bytes + ) iterator_advance(&old_iter); + while ( + !iterator_done(&new_iter) && + iterator_end_position(&new_iter).bytes <= next_position.bytes + ) iterator_advance(&new_iter); + + // Ensure that both iterators are at the same depth in the tree. + while (old_iter.visible_depth > new_iter.visible_depth) { + iterator_ascend(&old_iter); + } + while (new_iter.visible_depth > old_iter.visible_depth) { + iterator_ascend(&new_iter); + } + + if (is_changed) { + #ifdef DEBUG_GET_CHANGED_RANGES + printf( + " change: [[%u, %u] - [%u, %u]]\n", + position.extent.row + 1, position.extent.column, + next_position.extent.row + 1, next_position.extent.column + ); + #endif + + ts_range_array_add(&results, position, next_position); + } + + position = next_position; + + // Keep track of the current position in the included range differences + // array in order to avoid scanning the entire array on each iteration. + while (included_range_difference_index < included_range_differences->size) { + const TSRange *range = array_get(included_range_differences, + included_range_difference_index + ); + if (range->end_byte <= position.bytes) { + included_range_difference_index++; + } else { + break; + } + } + } while (!iterator_done(&old_iter) && !iterator_done(&new_iter)); + + Length old_size = ts_subtree_total_size(*old_tree); + Length new_size = ts_subtree_total_size(*new_tree); + if (old_size.bytes < new_size.bytes) { + ts_range_array_add(&results, old_size, new_size); + } else if (new_size.bytes < old_size.bytes) { + ts_range_array_add(&results, new_size, old_size); + } + + *cursor1 = old_iter.cursor; + *cursor2 = new_iter.cursor; + *ranges = results.contents; + return results.size; +} diff --git a/vendor/tree-sitter/src/get_changed_ranges.h b/vendor/tree-sitter/src/get_changed_ranges.h new file mode 100644 index 0000000..a1f1dbb --- /dev/null +++ b/vendor/tree-sitter/src/get_changed_ranges.h @@ -0,0 +1,36 @@ +#ifndef TREE_SITTER_GET_CHANGED_RANGES_H_ +#define TREE_SITTER_GET_CHANGED_RANGES_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./tree_cursor.h" +#include "./subtree.h" + +typedef Array(TSRange) TSRangeArray; + +void ts_range_array_get_changed_ranges( + const TSRange *old_ranges, unsigned old_range_count, + const TSRange *new_ranges, unsigned new_range_count, + TSRangeArray *differences +); + +bool ts_range_array_intersects( + const TSRangeArray *self, unsigned start_index, + uint32_t start_byte, uint32_t end_byte +); + +unsigned ts_subtree_get_changed_ranges( + const Subtree *old_tree, const Subtree *new_tree, + TreeCursor *cursor1, TreeCursor *cursor2, + const TSLanguage *language, + const TSRangeArray *included_range_differences, + TSRange **ranges +); + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_GET_CHANGED_RANGES_H_ diff --git a/vendor/tree-sitter/src/host.h b/vendor/tree-sitter/src/host.h new file mode 100644 index 0000000..a07e9f8 --- /dev/null +++ b/vendor/tree-sitter/src/host.h @@ -0,0 +1,21 @@ + +// Determine endian and pointer size based on known defines. +// TS_BIG_ENDIAN and TS_PTR_SIZE can be set as -D compiler arguments +// to override this. + +#if !defined(TS_BIG_ENDIAN) +#if (defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__) \ + || (defined( __APPLE_CC__) && (defined(__ppc__) || defined(__ppc64__))) +#define TS_BIG_ENDIAN 1 +#else +#define TS_BIG_ENDIAN 0 +#endif +#endif + +#if !defined(TS_PTR_SIZE) +#if UINTPTR_MAX == 0xFFFFFFFF +#define TS_PTR_SIZE 32 +#else +#define TS_PTR_SIZE 64 +#endif +#endif diff --git a/vendor/tree-sitter/src/language.c b/vendor/tree-sitter/src/language.c new file mode 100644 index 0000000..2dce699 --- /dev/null +++ b/vendor/tree-sitter/src/language.c @@ -0,0 +1,293 @@ +#include "./language.h" +#include "./wasm_store.h" +#include "tree_sitter/api.h" +#include + +const TSLanguage *ts_language_copy(const TSLanguage *self) { + if (self && ts_language_is_wasm(self)) { + ts_wasm_language_retain(self); + } + return self; +} + +void ts_language_delete(const TSLanguage *self) { + if (self && ts_language_is_wasm(self)) { + ts_wasm_language_release(self); + } +} + +uint32_t ts_language_symbol_count(const TSLanguage *self) { + return self->symbol_count + self->alias_count; +} + +uint32_t ts_language_state_count(const TSLanguage *self) { + return self->state_count; +} + +const TSSymbol *ts_language_supertypes(const TSLanguage *self, uint32_t *length) { + if (self->abi_version >= LANGUAGE_VERSION_WITH_RESERVED_WORDS) { + *length = self->supertype_count; + return self->supertype_symbols; + } else { + *length = 0; + return NULL; + } +} + +const TSSymbol *ts_language_subtypes( + const TSLanguage *self, + TSSymbol supertype, + uint32_t *length +) { + if (self->abi_version < LANGUAGE_VERSION_WITH_RESERVED_WORDS || !ts_language_symbol_metadata(self, supertype).supertype) { + *length = 0; + return NULL; + } + + TSMapSlice slice = self->supertype_map_slices[supertype]; + *length = slice.length; + return &self->supertype_map_entries[slice.index]; +} + +uint32_t ts_language_version(const TSLanguage *self) { + return self->abi_version; +} + +uint32_t ts_language_abi_version(const TSLanguage *self) { + return self->abi_version; +} + +const TSLanguageMetadata *ts_language_metadata(const TSLanguage *self) { + return self->abi_version >= LANGUAGE_VERSION_WITH_RESERVED_WORDS ? &self->metadata : NULL; +} + +const char *ts_language_name(const TSLanguage *self) { + return self->abi_version >= LANGUAGE_VERSION_WITH_RESERVED_WORDS ? self->name : NULL; +} + +uint32_t ts_language_field_count(const TSLanguage *self) { + return self->field_count; +} + +void ts_language_table_entry( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol, + TableEntry *result +) { + if (symbol == ts_builtin_sym_error || symbol == ts_builtin_sym_error_repeat) { + result->action_count = 0; + result->is_reusable = false; + result->actions = NULL; + } else { + ts_assert(symbol < self->token_count); + uint32_t action_index = ts_language_lookup(self, state, symbol); + const TSParseActionEntry *entry = &self->parse_actions[action_index]; + result->action_count = entry->entry.count; + result->is_reusable = entry->entry.reusable; + result->actions = (const TSParseAction *)(entry + 1); + } +} + +TSLexerMode ts_language_lex_mode_for_state( + const TSLanguage *self, + TSStateId state +) { + if (self->abi_version < 15) { + TSLexMode mode = ((const TSLexMode *)self->lex_modes)[state]; + return (TSLexerMode) { + .lex_state = mode.lex_state, + .external_lex_state = mode.external_lex_state, + .reserved_word_set_id = 0, + }; + } else { + return self->lex_modes[state]; + } +} + +bool ts_language_is_reserved_word( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol +) { + TSLexerMode lex_mode = ts_language_lex_mode_for_state(self, state); + if (lex_mode.reserved_word_set_id > 0) { + unsigned start = lex_mode.reserved_word_set_id * self->max_reserved_word_set_size; + unsigned end = start + self->max_reserved_word_set_size; + for (unsigned i = start; i < end; i++) { + if (self->reserved_words[i] == symbol) return true; + if (self->reserved_words[i] == 0) break; + } + } + return false; +} + +TSSymbolMetadata ts_language_symbol_metadata( + const TSLanguage *self, + TSSymbol symbol +) { + if (symbol == ts_builtin_sym_error) { + return (TSSymbolMetadata) {.visible = true, .named = true}; + } else if (symbol == ts_builtin_sym_error_repeat) { + return (TSSymbolMetadata) {.visible = false, .named = false}; + } else { + return self->symbol_metadata[symbol]; + } +} + +TSSymbol ts_language_public_symbol( + const TSLanguage *self, + TSSymbol symbol +) { + if (symbol == ts_builtin_sym_error) return symbol; + return self->public_symbol_map[symbol]; +} + +TSStateId ts_language_next_state( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol +) { + if (symbol == ts_builtin_sym_error || symbol == ts_builtin_sym_error_repeat) { + return 0; + } else if (symbol < self->token_count) { + uint32_t count; + const TSParseAction *actions = ts_language_actions(self, state, symbol, &count); + if (count > 0) { + TSParseAction action = actions[count - 1]; + if (action.type == TSParseActionTypeShift) { + return action.shift.extra ? state : action.shift.state; + } + } + return 0; + } else { + return ts_language_lookup(self, state, symbol); + } +} + +const char *ts_language_symbol_name( + const TSLanguage *self, + TSSymbol symbol +) { + if (symbol == ts_builtin_sym_error) { + return "ERROR"; + } else if (symbol == ts_builtin_sym_error_repeat) { + return "_ERROR"; + } else if (symbol < ts_language_symbol_count(self)) { + return self->symbol_names[symbol]; + } else { + return NULL; + } +} + +TSSymbol ts_language_symbol_for_name( + const TSLanguage *self, + const char *string, + uint32_t length, + bool is_named +) { + if (is_named && !strncmp(string, "ERROR", length)) return ts_builtin_sym_error; + uint16_t count = (uint16_t)ts_language_symbol_count(self); + for (TSSymbol i = 0; i < count; i++) { + TSSymbolMetadata metadata = ts_language_symbol_metadata(self, i); + if ((!metadata.visible && !metadata.supertype) || metadata.named != is_named) continue; + const char *symbol_name = self->symbol_names[i]; + if (!strncmp(symbol_name, string, length) && !symbol_name[length]) { + return self->public_symbol_map[i]; + } + } + return 0; +} + +TSSymbolType ts_language_symbol_type( + const TSLanguage *self, + TSSymbol symbol +) { + TSSymbolMetadata metadata = ts_language_symbol_metadata(self, symbol); + if (metadata.named && metadata.visible) { + return TSSymbolTypeRegular; + } else if (metadata.visible) { + return TSSymbolTypeAnonymous; + } else if (metadata.supertype) { + return TSSymbolTypeSupertype; + } else { + return TSSymbolTypeAuxiliary; + } +} + +const char *ts_language_field_name_for_id( + const TSLanguage *self, + TSFieldId id +) { + uint32_t count = ts_language_field_count(self); + if (count && id <= count) { + return self->field_names[id]; + } else { + return NULL; + } +} + +TSFieldId ts_language_field_id_for_name( + const TSLanguage *self, + const char *name, + uint32_t name_length +) { + uint16_t count = (uint16_t)ts_language_field_count(self); + for (TSSymbol i = 1; i < count + 1; i++) { + switch (strncmp(name, self->field_names[i], name_length)) { + case 0: + if (self->field_names[i][name_length] == 0) return i; + break; + case -1: + return 0; + default: + break; + } + } + return 0; +} + +TSLookaheadIterator *ts_lookahead_iterator_new(const TSLanguage *self, TSStateId state) { + if (state >= self->state_count) return NULL; + LookaheadIterator *iterator = ts_malloc(sizeof(LookaheadIterator)); + *iterator = ts_language_lookaheads(self, state); + return (TSLookaheadIterator *)iterator; +} + +void ts_lookahead_iterator_delete(TSLookaheadIterator *self) { + ts_free(self); +} + +bool ts_lookahead_iterator_reset_state(TSLookaheadIterator * self, TSStateId state) { + LookaheadIterator *iterator = (LookaheadIterator *)self; + if (state >= iterator->language->state_count) return false; + *iterator = ts_language_lookaheads(iterator->language, state); + return true; +} + +const TSLanguage *ts_lookahead_iterator_language(const TSLookaheadIterator *self) { + const LookaheadIterator *iterator = (const LookaheadIterator *)self; + return iterator->language; +} + +bool ts_lookahead_iterator_reset(TSLookaheadIterator *self, const TSLanguage *language, TSStateId state) { + if (state >= language->state_count) return false; + LookaheadIterator *iterator = (LookaheadIterator *)self; + *iterator = ts_language_lookaheads(language, state); + return true; +} + +bool ts_lookahead_iterator_next(TSLookaheadIterator *self) { + LookaheadIterator *iterator = (LookaheadIterator *)self; + return ts_lookahead_iterator__next(iterator); +} + +TSSymbol ts_lookahead_iterator_current_symbol(const TSLookaheadIterator *self) { + const LookaheadIterator *iterator = (const LookaheadIterator *)self; + return iterator->symbol; +} + +const char *ts_lookahead_iterator_current_symbol_name(const TSLookaheadIterator *self) { + const LookaheadIterator *iterator = (const LookaheadIterator *)self; + return ts_language_symbol_name(iterator->language, iterator->symbol); +} diff --git a/vendor/tree-sitter/src/language.h b/vendor/tree-sitter/src/language.h new file mode 100644 index 0000000..518c06b --- /dev/null +++ b/vendor/tree-sitter/src/language.h @@ -0,0 +1,293 @@ +#ifndef TREE_SITTER_LANGUAGE_H_ +#define TREE_SITTER_LANGUAGE_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./subtree.h" +#include "./parser.h" + +#define ts_builtin_sym_error_repeat (ts_builtin_sym_error - 1) + +#define LANGUAGE_VERSION_WITH_RESERVED_WORDS 15 +#define LANGUAGE_VERSION_WITH_PRIMARY_STATES 14 + +typedef struct { + const TSParseAction *actions; + uint32_t action_count; + bool is_reusable; +} TableEntry; + +typedef struct { + const TSLanguage *language; + const uint16_t *data; + const uint16_t *group_end; + TSStateId state; + uint16_t table_value; + uint16_t section_index; + uint16_t group_count; + bool is_small_state; + + const TSParseAction *actions; + TSSymbol symbol; + TSStateId next_state; + uint16_t action_count; +} LookaheadIterator; + +void ts_language_table_entry(const TSLanguage *self, TSStateId state, TSSymbol symbol, TableEntry *result); +TSLexerMode ts_language_lex_mode_for_state(const TSLanguage *self, TSStateId state); +bool ts_language_is_reserved_word(const TSLanguage *self, TSStateId state, TSSymbol symbol); +TSSymbolMetadata ts_language_symbol_metadata(const TSLanguage *self, TSSymbol symbol); +TSSymbol ts_language_public_symbol(const TSLanguage *self, TSSymbol symbol); + +static inline const TSParseAction *ts_language_actions( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol, + uint32_t *count +) { + TableEntry entry; + ts_language_table_entry(self, state, symbol, &entry); + *count = entry.action_count; + return entry.actions; +} + +static inline bool ts_language_has_reduce_action( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol +) { + TableEntry entry; + ts_language_table_entry(self, state, symbol, &entry); + return entry.action_count > 0 && entry.actions[0].type == TSParseActionTypeReduce; +} + +// Lookup the table value for a given symbol and state. +// +// For non-terminal symbols, the table value represents a successor state. +// For terminal symbols, it represents an index in the actions table. +// For 'large' parse states, this is a direct lookup. For 'small' parse +// states, this requires searching through the symbol groups to find +// the given symbol. +static inline uint16_t ts_language_lookup( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol +) { + if (state >= self->large_state_count) { + uint32_t index = self->small_parse_table_map[state - self->large_state_count]; + const uint16_t *data = &self->small_parse_table[index]; + uint16_t group_count = *(data++); + for (unsigned i = 0; i < group_count; i++) { + uint16_t section_value = *(data++); + uint16_t symbol_count = *(data++); + for (unsigned j = 0; j < symbol_count; j++) { + if (*(data++) == symbol) return section_value; + } + } + return 0; + } else { + return self->parse_table[state * self->symbol_count + symbol]; + } +} + +static inline bool ts_language_has_actions( + const TSLanguage *self, + TSStateId state, + TSSymbol symbol +) { + return ts_language_lookup(self, state, symbol) != 0; +} + +// Iterate over all of the symbols that are valid in the given state. +// +// For 'large' parse states, this just requires iterating through +// all possible symbols and checking the parse table for each one. +// For 'small' parse states, this exploits the structure of the +// table to only visit the valid symbols. +static inline LookaheadIterator ts_language_lookaheads( + const TSLanguage *self, + TSStateId state +) { + bool is_small_state = state >= self->large_state_count; + const uint16_t *data; + const uint16_t *group_end = NULL; + uint16_t group_count = 0; + if (is_small_state) { + uint32_t index = self->small_parse_table_map[state - self->large_state_count]; + data = &self->small_parse_table[index]; + group_end = data + 1; + group_count = *data; + } else { + data = &self->parse_table[state * self->symbol_count] - 1; + } + return (LookaheadIterator) { + .language = self, + .data = data, + .group_end = group_end, + .group_count = group_count, + .is_small_state = is_small_state, + .symbol = UINT16_MAX, + .next_state = 0, + }; +} + +static inline bool ts_lookahead_iterator__next(LookaheadIterator *self) { + // For small parse states, valid symbols are listed explicitly, + // grouped by their value. There's no need to look up the actions + // again until moving to the next group. + if (self->is_small_state) { + self->data++; + if (self->data == self->group_end) { + if (self->group_count == 0) return false; + self->group_count--; + self->table_value = *(self->data++); + unsigned symbol_count = *(self->data++); + self->group_end = self->data + symbol_count; + self->symbol = *self->data; + } else { + self->symbol = *self->data; + return true; + } + } + + // For large parse states, iterate through every symbol until one + // is found that has valid actions. + else { + do { + self->data++; + self->symbol++; + if (self->symbol >= self->language->symbol_count) return false; + self->table_value = *self->data; + } while (!self->table_value); + } + + // Depending on if the symbols is terminal or non-terminal, the table value either + // represents a list of actions or a successor state. + if (self->symbol < self->language->token_count) { + const TSParseActionEntry *entry = &self->language->parse_actions[self->table_value]; + self->action_count = entry->entry.count; + self->actions = (const TSParseAction *)(entry + 1); + self->next_state = 0; + } else { + self->action_count = 0; + self->next_state = self->table_value; + } + return true; +} + +// Whether the state is a "primary state". If this returns false, it indicates that there exists +// another state that behaves identically to this one with respect to query analysis. +static inline bool ts_language_state_is_primary( + const TSLanguage *self, + TSStateId state +) { + if (self->abi_version >= LANGUAGE_VERSION_WITH_PRIMARY_STATES) { + return state == self->primary_state_ids[state]; + } else { + return true; + } +} + +static inline const bool *ts_language_enabled_external_tokens( + const TSLanguage *self, + unsigned external_scanner_state +) { + if (external_scanner_state == 0) { + return NULL; + } else { + return self->external_scanner.states + self->external_token_count * external_scanner_state; + } +} + +static inline const TSSymbol *ts_language_alias_sequence( + const TSLanguage *self, + uint32_t production_id +) { + return production_id ? + &self->alias_sequences[production_id * self->max_alias_sequence_length] : + NULL; +} + +static inline TSSymbol ts_language_alias_at( + const TSLanguage *self, + uint32_t production_id, + uint32_t child_index +) { + return production_id ? + self->alias_sequences[production_id * self->max_alias_sequence_length + child_index] : + 0; +} + +static inline void ts_language_field_map( + const TSLanguage *self, + uint32_t production_id, + const TSFieldMapEntry **start, + const TSFieldMapEntry **end +) { + if (self->field_count == 0) { + *start = NULL; + *end = NULL; + return; + } + + TSMapSlice slice = self->field_map_slices[production_id]; + *start = &self->field_map_entries[slice.index]; + *end = &self->field_map_entries[slice.index] + slice.length; +} + +static inline void ts_language_aliases_for_symbol( + const TSLanguage *self, + TSSymbol original_symbol, + const TSSymbol **start, + const TSSymbol **end +) { + *start = &self->public_symbol_map[original_symbol]; + *end = *start + 1; + + unsigned idx = 0; + for (;;) { + TSSymbol symbol = self->alias_map[idx++]; + if (symbol == 0 || symbol > original_symbol) break; + uint16_t count = self->alias_map[idx++]; + if (symbol == original_symbol) { + *start = &self->alias_map[idx]; + *end = &self->alias_map[idx + count]; + break; + } + idx += count; + } +} + +static inline void ts_language_write_symbol_as_dot_string( + const TSLanguage *self, + FILE *f, + TSSymbol symbol +) { + const char *name = ts_language_symbol_name(self, symbol); + for (const char *chr = name; *chr; chr++) { + switch (*chr) { + case '"': + case '\\': + fputc('\\', f); + fputc(*chr, f); + break; + case '\n': + fputs("\\n", f); + break; + case '\t': + fputs("\\t", f); + break; + default: + fputc(*chr, f); + break; + } + } +} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_LANGUAGE_H_ diff --git a/vendor/tree-sitter/src/length.h b/vendor/tree-sitter/src/length.h new file mode 100644 index 0000000..ddf156c --- /dev/null +++ b/vendor/tree-sitter/src/length.h @@ -0,0 +1,52 @@ +#ifndef TREE_SITTER_LENGTH_H_ +#define TREE_SITTER_LENGTH_H_ + +#include +#include +#include "./point.h" +#include "tree_sitter/api.h" + +typedef struct { + uint32_t bytes; + TSPoint extent; +} Length; + +static const Length LENGTH_UNDEFINED = {0, {0, 1}}; +static const Length LENGTH_MAX = {UINT32_MAX, {UINT32_MAX, UINT32_MAX}}; + +static inline bool length_is_undefined(Length length) { + return length.bytes == 0 && length.extent.column != 0; +} + +static inline Length length_min(Length len1, Length len2) { + return (len1.bytes < len2.bytes) ? len1 : len2; +} + +static inline Length length_add(Length len1, Length len2) { + Length result; + result.bytes = len1.bytes + len2.bytes; + result.extent = point_add(len1.extent, len2.extent); + return result; +} + +static inline Length length_sub(Length len1, Length len2) { + Length result; + result.bytes = (len1.bytes >= len2.bytes) ? len1.bytes - len2.bytes : 0; + result.extent = point_sub(len1.extent, len2.extent); + return result; +} + +static inline Length length_zero(void) { + Length result = {0, {0, 0}}; + return result; +} + +static inline Length length_saturating_sub(Length len1, Length len2) { + if (len1.bytes > len2.bytes) { + return length_sub(len1, len2); + } else { + return length_zero(); + } +} + +#endif diff --git a/vendor/tree-sitter/src/lexer.c b/vendor/tree-sitter/src/lexer.c new file mode 100644 index 0000000..fc2b773 --- /dev/null +++ b/vendor/tree-sitter/src/lexer.c @@ -0,0 +1,483 @@ +#include "./length.h" +#include "./lexer.h" +#include "./unicode.h" + +#include "tree_sitter/api.h" + +#include +#include + +#define LOG(message, character) \ + if (self->logger.log) { \ + snprintf( \ + self->debug_buffer, \ + TREE_SITTER_SERIALIZATION_BUFFER_SIZE, \ + 32 <= character && character < 127 ? \ + message " character:'%c'" : \ + message " character:%d", \ + character \ + ); \ + self->logger.log( \ + self->logger.payload, \ + TSLogTypeLex, \ + self->debug_buffer \ + ); \ + } + +static const int32_t BYTE_ORDER_MARK = 0xFEFF; + +static const TSRange DEFAULT_RANGE = { + .start_point = { + .row = 0, + .column = 0, + }, + .end_point = { + .row = UINT32_MAX, + .column = UINT32_MAX, + }, + .start_byte = 0, + .end_byte = UINT32_MAX +}; + +/** + * Sets the column data to the given value and marks it valid. + * @param self The lexer state. + * @param val The new value of the column data. + */ +static void ts_lexer__set_column_data(Lexer *self, uint32_t val) { + self->column_data.valid = true; + self->column_data.value = val; +} + +/** + * Increments the value of the column data; no-op if invalid. + * @param self The lexer state. + */ +static void ts_lexer__increment_column_data(Lexer *self) { + if (self->column_data.valid) { + self->column_data.value++; + } +} + +/** + * Marks the column data as invalid. + * @param self The lexer state. + */ +static void ts_lexer__invalidate_column_data(Lexer *self) { + self->column_data.valid = false; + self->column_data.value = 0; +} + +// Check if the lexer has reached EOF. This state is stored +// by setting the lexer's `current_included_range_index` such that +// it has consumed all of its available ranges. +static bool ts_lexer__eof(const TSLexer *_self) { + Lexer *self = (Lexer *)_self; + return self->current_included_range_index == self->included_range_count; +} + +// Clear the currently stored chunk of source code, because the lexer's +// position has changed. +static void ts_lexer__clear_chunk(Lexer *self) { + self->chunk = NULL; + self->chunk_size = 0; + self->chunk_start = 0; +} + +// Call the lexer's input callback to obtain a new chunk of source code +// for the current position. +static void ts_lexer__get_chunk(Lexer *self) { + self->chunk_start = self->current_position.bytes; + self->chunk = self->input.read( + self->input.payload, + self->current_position.bytes, + self->current_position.extent, + &self->chunk_size + ); + if (!self->chunk_size) { + self->current_included_range_index = self->included_range_count; + self->chunk = NULL; + } +} + +// Decode the next unicode character in the current chunk of source code. +// This assumes that the lexer has already retrieved a chunk of source +// code that spans the current position. +static void ts_lexer__get_lookahead(Lexer *self) { + uint32_t position_in_chunk = self->current_position.bytes - self->chunk_start; + uint32_t size = self->chunk_size - position_in_chunk; + + if (size == 0) { + self->lookahead_size = 1; + self->data.lookahead = '\0'; + return; + } + + const uint8_t *chunk = (const uint8_t *)self->chunk + position_in_chunk; + DecodeFunction decode = + self->input.encoding == TSInputEncodingUTF8 ? ts_decode_utf8 : + self->input.encoding == TSInputEncodingUTF16LE ? ts_decode_utf16_le : + self->input.encoding == TSInputEncodingUTF16BE ? ts_decode_utf16_be : self->input.decode; + + self->lookahead_size = decode(chunk, size, &self->data.lookahead); + + // If this chunk ended in the middle of a multi-byte character, + // try again with a fresh chunk. + if (self->data.lookahead == TS_DECODE_ERROR && size < 4) { + ts_lexer__get_chunk(self); + chunk = (const uint8_t *)self->chunk; + size = self->chunk_size; + self->lookahead_size = decode(chunk, size, &self->data.lookahead); + } + + if (self->data.lookahead == TS_DECODE_ERROR) { + self->lookahead_size = 1; + } +} + +static void ts_lexer_goto(Lexer *self, Length position) { + if (position.bytes != self->current_position.bytes) { + ts_lexer__invalidate_column_data(self); + } + + self->current_position = position; + + // Move to the first valid position at or after the given position. + bool found_included_range = false; + for (unsigned i = 0; i < self->included_range_count; i++) { + TSRange *included_range = &self->included_ranges[i]; + if ( + included_range->end_byte > self->current_position.bytes && + included_range->end_byte > included_range->start_byte + ) { + if (included_range->start_byte >= self->current_position.bytes) { + self->current_position = (Length) { + .bytes = included_range->start_byte, + .extent = included_range->start_point, + }; + } + + self->current_included_range_index = i; + found_included_range = true; + break; + } + } + + if (found_included_range) { + // If the current position is outside of the current chunk of text, + // then clear out the current chunk of text. + if (self->chunk && ( + self->current_position.bytes < self->chunk_start || + self->current_position.bytes >= self->chunk_start + self->chunk_size + )) { + ts_lexer__clear_chunk(self); + } + + self->lookahead_size = 0; + self->data.lookahead = '\0'; + } + + // If the given position is beyond any of included ranges, move to the EOF + // state - past the end of the included ranges. + else { + self->current_included_range_index = self->included_range_count; + TSRange *last_included_range = &self->included_ranges[self->included_range_count - 1]; + self->current_position = (Length) { + .bytes = last_included_range->end_byte, + .extent = last_included_range->end_point, + }; + ts_lexer__clear_chunk(self); + self->lookahead_size = 1; + self->data.lookahead = '\0'; + } +} + +/** + * Actually advances the lexer. Does not log anything. + * @param self The lexer state. + * @param skip Whether to mark the consumed codepoint as whitespace. + */ +static void ts_lexer__do_advance(Lexer *self, bool skip) { + if (self->lookahead_size) { + if (self->data.lookahead == '\n') { + self->current_position.extent.row++; + self->current_position.extent.column = 0; + ts_lexer__set_column_data(self, 0); + } else { + bool is_bom = self->current_position.bytes == 0 && + self->data.lookahead == BYTE_ORDER_MARK; + if (!is_bom) ts_lexer__increment_column_data(self); + self->current_position.extent.column += self->lookahead_size; + } + self->current_position.bytes += self->lookahead_size; + } + + const TSRange *current_range = &self->included_ranges[self->current_included_range_index]; + while ( + self->current_position.bytes >= current_range->end_byte || + current_range->end_byte == current_range->start_byte + ) { + if (self->current_included_range_index < self->included_range_count) { + self->current_included_range_index++; + } + if (self->current_included_range_index < self->included_range_count) { + current_range++; + self->current_position = (Length) { + current_range->start_byte, + current_range->start_point, + }; + } else { + current_range = NULL; + break; + } + } + + if (skip) self->token_start_position = self->current_position; + + if (current_range) { + if ( + self->current_position.bytes < self->chunk_start || + self->current_position.bytes >= self->chunk_start + self->chunk_size + ) { + ts_lexer__get_chunk(self); + } + ts_lexer__get_lookahead(self); + } else { + ts_lexer__clear_chunk(self); + self->data.lookahead = '\0'; + self->lookahead_size = 1; + } +} + +// Advance to the next character in the source code, retrieving a new +// chunk of source code if needed. +static void ts_lexer__advance(TSLexer *_self, bool skip) { + Lexer *self = (Lexer *)_self; + if (!self->chunk) return; + + if (skip) { + LOG("skip", self->data.lookahead) + } else { + LOG("consume", self->data.lookahead) + } + + ts_lexer__do_advance(self, skip); +} + +// Mark that a token match has completed. This can be called multiple +// times if a longer match is found later. +static void ts_lexer__mark_end(TSLexer *_self) { + Lexer *self = (Lexer *)_self; + if (!ts_lexer__eof(&self->data)) { + // If the lexer is right at the beginning of included range, + // then the token should be considered to end at the *end* of the + // previous included range, rather than here. + TSRange *current_included_range = &self->included_ranges[ + self->current_included_range_index + ]; + if ( + self->current_included_range_index > 0 && + self->current_position.bytes == current_included_range->start_byte + ) { + TSRange *previous_included_range = current_included_range - 1; + self->token_end_position = (Length) { + previous_included_range->end_byte, + previous_included_range->end_point, + }; + return; + } + } + self->token_end_position = self->current_position; +} + +static uint32_t ts_lexer__get_column(TSLexer *_self) { + Lexer *self = (Lexer *)_self; + + self->did_get_column = true; + + if (!self->column_data.valid) { + // Record current position + uint32_t goal_byte = self->current_position.bytes; + + // Back up to the beginning of the line + Length start_of_col = { + self->current_position.bytes - self->current_position.extent.column, + {self->current_position.extent.row, 0}, + }; + ts_lexer_goto(self, start_of_col); + ts_lexer__set_column_data(self, 0); + ts_lexer__get_chunk(self); + + if (!ts_lexer__eof(_self)) { + ts_lexer__get_lookahead(self); + + // Advance to the recorded position + while (self->current_position.bytes < goal_byte && !ts_lexer__eof(_self) && self->chunk) { + ts_lexer__do_advance(self, false); + if (ts_lexer__eof(_self)) break; + } + } + } + + return self->column_data.value; +} + +// Is the lexer at a boundary between two disjoint included ranges of +// source code? This is exposed as an API because some languages' external +// scanners need to perform custom actions at these boundaries. +static bool ts_lexer__is_at_included_range_start(const TSLexer *_self) { + const Lexer *self = (const Lexer *)_self; + if (self->current_included_range_index < self->included_range_count) { + TSRange *current_range = &self->included_ranges[self->current_included_range_index]; + return self->current_position.bytes == current_range->start_byte; + } else { + return false; + } +} + +static void ts_lexer__log(const TSLexer *_self, const char *fmt, ...) { + Lexer *self = (Lexer *)_self; + va_list args; + va_start(args, fmt); + if (self->logger.log) { + vsnprintf(self->debug_buffer, TREE_SITTER_SERIALIZATION_BUFFER_SIZE, fmt, args); + self->logger.log(self->logger.payload, TSLogTypeLex, self->debug_buffer); + } + va_end(args); +} + +void ts_lexer_init(Lexer *self) { + *self = (Lexer) { + .data = { + // The lexer's methods are stored as struct fields so that generated + // parsers can call them without needing to be linked against this + // library. + .advance = ts_lexer__advance, + .mark_end = ts_lexer__mark_end, + .get_column = ts_lexer__get_column, + .is_at_included_range_start = ts_lexer__is_at_included_range_start, + .eof = ts_lexer__eof, + .log = ts_lexer__log, + .lookahead = 0, + .result_symbol = 0, + }, + .chunk = NULL, + .chunk_size = 0, + .chunk_start = 0, + .current_position = {0, {0, 0}}, + .logger = { + .payload = NULL, + .log = NULL + }, + .included_ranges = NULL, + .included_range_count = 0, + .current_included_range_index = 0, + .did_get_column = false, + .column_data = { + .valid = false, + .value = 0 + } + }; + ts_lexer_set_included_ranges(self, NULL, 0); +} + +void ts_lexer_delete(Lexer *self) { + ts_free(self->included_ranges); +} + +void ts_lexer_set_input(Lexer *self, TSInput input) { + self->input = input; + ts_lexer__clear_chunk(self); + ts_lexer_goto(self, self->current_position); +} + +// Move the lexer to the given position. This doesn't do any work +// if the parser is already at the given position. +void ts_lexer_reset(Lexer *self, Length position) { + if (position.bytes != self->current_position.bytes) { + ts_lexer_goto(self, position); + } +} + +void ts_lexer_start(Lexer *self) { + self->token_start_position = self->current_position; + self->token_end_position = LENGTH_UNDEFINED; + self->data.result_symbol = 0; + self->did_get_column = false; + if (!ts_lexer__eof(&self->data)) { + if (!self->chunk_size) ts_lexer__get_chunk(self); + if (!self->lookahead_size) ts_lexer__get_lookahead(self); + if (self->current_position.bytes == 0) { + if (self->data.lookahead == BYTE_ORDER_MARK) { + ts_lexer__advance(&self->data, true); + } + ts_lexer__set_column_data(self, 0); + } + } +} + +void ts_lexer_finish(Lexer *self, uint32_t *lookahead_end_byte) { + if (length_is_undefined(self->token_end_position)) { + ts_lexer__mark_end(&self->data); + } + + // If the token ended at an included range boundary, then its end position + // will have been reset to the end of the preceding range. Reset the start + // position to match. + if (self->token_end_position.bytes < self->token_start_position.bytes) { + self->token_start_position = self->token_end_position; + } + + uint32_t current_lookahead_end_byte = self->current_position.bytes + 1; + + // In order to determine that a byte sequence is invalid UTF8 or UTF16, + // the character decoding algorithm may have looked at the following byte. + // Therefore, the next byte *after* the current (invalid) character + // affects the interpretation of the current character. + if (self->data.lookahead == TS_DECODE_ERROR) { + current_lookahead_end_byte += 4; // the maximum number of bytes read to identify an invalid code point + } + + if (current_lookahead_end_byte > *lookahead_end_byte) { + *lookahead_end_byte = current_lookahead_end_byte; + } +} + +void ts_lexer_mark_end(Lexer *self) { + ts_lexer__mark_end(&self->data); +} + +bool ts_lexer_set_included_ranges( + Lexer *self, + const TSRange *ranges, + uint32_t count +) { + if (count == 0 || !ranges) { + ranges = &DEFAULT_RANGE; + count = 1; + } else { + uint32_t previous_byte = 0; + for (unsigned i = 0; i < count; i++) { + const TSRange *range = &ranges[i]; + if ( + range->start_byte < previous_byte || + range->end_byte < range->start_byte + ) return false; + previous_byte = range->end_byte; + } + } + + size_t size = count * sizeof(TSRange); + self->included_ranges = ts_realloc(self->included_ranges, size); + memcpy(self->included_ranges, ranges, size); + self->included_range_count = count; + ts_lexer_goto(self, self->current_position); + return true; +} + +TSRange *ts_lexer_included_ranges(const Lexer *self, uint32_t *count) { + *count = self->included_range_count; + return self->included_ranges; +} + +#undef LOG diff --git a/vendor/tree-sitter/src/lexer.h b/vendor/tree-sitter/src/lexer.h new file mode 100644 index 0000000..7f451e3 --- /dev/null +++ b/vendor/tree-sitter/src/lexer.h @@ -0,0 +1,54 @@ +#ifndef TREE_SITTER_LEXER_H_ +#define TREE_SITTER_LEXER_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./length.h" +#include "./subtree.h" +#include "tree_sitter/api.h" +#include "./parser.h" + +typedef struct { + uint32_t value; + bool valid; +} ColumnData; + +typedef struct { + TSLexer data; + Length current_position; + Length token_start_position; + Length token_end_position; + + TSRange *included_ranges; + const char *chunk; + TSInput input; + TSLogger logger; + + uint32_t included_range_count; + uint32_t current_included_range_index; + uint32_t chunk_start; + uint32_t chunk_size; + uint32_t lookahead_size; + bool did_get_column; + ColumnData column_data; + + char debug_buffer[TREE_SITTER_SERIALIZATION_BUFFER_SIZE]; +} Lexer; + +void ts_lexer_init(Lexer *self); +void ts_lexer_delete(Lexer *self); +void ts_lexer_set_input(Lexer *self, TSInput input); +void ts_lexer_reset(Lexer *self, Length position); +void ts_lexer_start(Lexer *self); +void ts_lexer_finish(Lexer *self, uint32_t *lookahead_end_byte); +void ts_lexer_mark_end(Lexer *self); +bool ts_lexer_set_included_ranges(Lexer *self, const TSRange *ranges, uint32_t count); +TSRange *ts_lexer_included_ranges(const Lexer *self, uint32_t *count); + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_LEXER_H_ diff --git a/vendor/tree-sitter/src/lib.c b/vendor/tree-sitter/src/lib.c new file mode 100644 index 0000000..9bfb69f --- /dev/null +++ b/vendor/tree-sitter/src/lib.c @@ -0,0 +1,12 @@ +#include "./alloc.c" +#include "./get_changed_ranges.c" +#include "./language.c" +#include "./lexer.c" +#include "./node.c" +#include "./parser.c" +#include "./query.c" +#include "./stack.c" +#include "./subtree.c" +#include "./tree_cursor.c" +#include "./tree.c" +#include "./wasm_store.c" diff --git a/vendor/tree-sitter/src/node.c b/vendor/tree-sitter/src/node.c new file mode 100644 index 0000000..d83fa90 --- /dev/null +++ b/vendor/tree-sitter/src/node.c @@ -0,0 +1,875 @@ +#include +#include "./point.h" +#include "./subtree.h" +#include "./tree.h" +#include "./language.h" + +typedef struct { + Subtree parent; + const TSTree *tree; + Length position; + uint32_t child_index; + uint32_t structural_child_index; + const TSSymbol *alias_sequence; +} NodeChildIterator; + +static inline bool ts_node__is_relevant(TSNode self, bool include_anonymous); + +// TSNode - constructors + +TSNode ts_node_new( + const TSTree *tree, + const Subtree *subtree, + Length position, + TSSymbol alias +) { + return (TSNode) { + {position.bytes, position.extent.row, position.extent.column, alias}, + subtree, + tree, + }; +} + +static inline TSNode ts_node__null(void) { + return ts_node_new(NULL, NULL, length_zero(), 0); +} + +// TSNode - accessors + +uint32_t ts_node_start_byte(TSNode self) { + return self.context[0]; +} + +TSPoint ts_node_start_point(TSNode self) { + return (TSPoint) {self.context[1], self.context[2]}; +} + +static inline uint32_t ts_node__alias(const TSNode *self) { + return self->context[3]; +} + +static inline Subtree ts_node__subtree(TSNode self) { + return *(const Subtree *)self.id; +} + +// NodeChildIterator + +static inline NodeChildIterator ts_node_iterate_children(const TSNode *node) { + Subtree subtree = ts_node__subtree(*node); + if (ts_subtree_child_count(subtree) == 0) { + return (NodeChildIterator) {NULL_SUBTREE, node->tree, length_zero(), 0, 0, NULL}; + } + const TSSymbol *alias_sequence = ts_language_alias_sequence( + node->tree->language, + subtree.ptr->production_id + ); + return (NodeChildIterator) { + .tree = node->tree, + .parent = subtree, + .position = {ts_node_start_byte(*node), ts_node_start_point(*node)}, + .child_index = 0, + .structural_child_index = 0, + .alias_sequence = alias_sequence, + }; +} + +static inline bool ts_node_child_iterator_done(NodeChildIterator *self) { + return self->child_index == self->parent.ptr->child_count; +} + +static inline bool ts_node_child_iterator_next( + NodeChildIterator *self, + TSNode *result +) { + if (!self->parent.ptr || ts_node_child_iterator_done(self)) return false; + const Subtree *child = &ts_subtree_children(self->parent)[self->child_index]; + TSSymbol alias_symbol = 0; + if (!ts_subtree_extra(*child)) { + if (self->alias_sequence) { + alias_symbol = self->alias_sequence[self->structural_child_index]; + } + self->structural_child_index++; + } + if (self->child_index > 0) { + self->position = length_add(self->position, ts_subtree_padding(*child)); + } + *result = ts_node_new( + self->tree, + child, + self->position, + alias_symbol + ); + self->position = length_add(self->position, ts_subtree_size(*child)); + self->child_index++; + return true; +} + +// TSNode - private + +static inline bool ts_node__is_relevant(TSNode self, bool include_anonymous) { + Subtree tree = ts_node__subtree(self); + if (include_anonymous) { + return ts_subtree_visible(tree) || ts_node__alias(&self); + } else { + TSSymbol alias = ts_node__alias(&self); + if (alias) { + return ts_language_symbol_metadata(self.tree->language, alias).named; + } else { + return ts_subtree_visible(tree) && ts_subtree_named(tree); + } + } +} + +static inline uint32_t ts_node__relevant_child_count( + TSNode self, + bool include_anonymous +) { + Subtree tree = ts_node__subtree(self); + if (ts_subtree_child_count(tree) > 0) { + if (include_anonymous) { + return tree.ptr->visible_child_count; + } else { + return tree.ptr->named_child_count; + } + } else { + return 0; + } +} + +static inline TSNode ts_node__child( + TSNode self, + uint32_t child_index, + bool include_anonymous +) { + TSNode result = self; + bool did_descend = true; + + while (did_descend) { + did_descend = false; + + TSNode child; + uint32_t index = 0; + NodeChildIterator iterator = ts_node_iterate_children(&result); + while (ts_node_child_iterator_next(&iterator, &child)) { + if (ts_node__is_relevant(child, include_anonymous)) { + if (index == child_index) { + return child; + } + index++; + } else { + uint32_t grandchild_index = child_index - index; + uint32_t grandchild_count = ts_node__relevant_child_count(child, include_anonymous); + if (grandchild_index < grandchild_count) { + did_descend = true; + result = child; + child_index = grandchild_index; + break; + } + index += grandchild_count; + } + } + } + + return ts_node__null(); +} + +static bool ts_subtree_has_trailing_empty_descendant( + Subtree self, + Subtree other +) { + for (unsigned i = ts_subtree_child_count(self) - 1; i + 1 > 0; i--) { + Subtree child = ts_subtree_children(self)[i]; + if (ts_subtree_total_bytes(child) > 0) break; + if (child.ptr == other.ptr || ts_subtree_has_trailing_empty_descendant(child, other)) { + return true; + } + } + return false; +} + +static inline TSNode ts_node__prev_sibling(TSNode self, bool include_anonymous) { + Subtree self_subtree = ts_node__subtree(self); + bool self_is_empty = ts_subtree_total_bytes(self_subtree) == 0; + uint32_t target_end_byte = ts_node_end_byte(self); + + TSNode node = ts_node_parent(self); + TSNode earlier_node = ts_node__null(); + bool earlier_node_is_relevant = false; + + while (!ts_node_is_null(node)) { + TSNode earlier_child = ts_node__null(); + bool earlier_child_is_relevant = false; + bool found_child_containing_target = false; + + TSNode child; + NodeChildIterator iterator = ts_node_iterate_children(&node); + while (ts_node_child_iterator_next(&iterator, &child)) { + if (child.id == self.id) break; + if (iterator.position.bytes > target_end_byte) { + found_child_containing_target = true; + break; + } + + if (iterator.position.bytes == target_end_byte && + (!self_is_empty || + ts_subtree_has_trailing_empty_descendant(ts_node__subtree(child), self_subtree))) { + found_child_containing_target = true; + break; + } + + if (ts_node__is_relevant(child, include_anonymous)) { + earlier_child = child; + earlier_child_is_relevant = true; + } else if (ts_node__relevant_child_count(child, include_anonymous) > 0) { + earlier_child = child; + earlier_child_is_relevant = false; + } + } + + if (found_child_containing_target) { + if (!ts_node_is_null(earlier_child)) { + earlier_node = earlier_child; + earlier_node_is_relevant = earlier_child_is_relevant; + } + node = child; + } else if (earlier_child_is_relevant) { + return earlier_child; + } else if (!ts_node_is_null(earlier_child)) { + node = earlier_child; + } else if (earlier_node_is_relevant) { + return earlier_node; + } else { + node = earlier_node; + earlier_node = ts_node__null(); + earlier_node_is_relevant = false; + } + } + + return ts_node__null(); +} + +static inline TSNode ts_node__next_sibling(TSNode self, bool include_anonymous) { + uint32_t target_end_byte = ts_node_end_byte(self); + + TSNode node = ts_node_parent(self); + TSNode later_node = ts_node__null(); + bool later_node_is_relevant = false; + + while (!ts_node_is_null(node)) { + TSNode later_child = ts_node__null(); + bool later_child_is_relevant = false; + TSNode child_containing_target = ts_node__null(); + + TSNode child; + NodeChildIterator iterator = ts_node_iterate_children(&node); + while (ts_node_child_iterator_next(&iterator, &child)) { + if (iterator.position.bytes <= target_end_byte) continue; + uint32_t start_byte = ts_node_start_byte(self); + uint32_t child_start_byte = ts_node_start_byte(child); + + bool is_empty = start_byte == target_end_byte; + bool contains_target = is_empty ? + child_start_byte < start_byte : + child_start_byte <= start_byte; + + if (contains_target) { + if (ts_node__subtree(child).ptr != ts_node__subtree(self).ptr) { + child_containing_target = child; + } + } else if (ts_node__is_relevant(child, include_anonymous)) { + later_child = child; + later_child_is_relevant = true; + break; + } else if (ts_node__relevant_child_count(child, include_anonymous) > 0) { + later_child = child; + later_child_is_relevant = false; + break; + } + } + + if (!ts_node_is_null(child_containing_target)) { + if (!ts_node_is_null(later_child)) { + later_node = later_child; + later_node_is_relevant = later_child_is_relevant; + } + node = child_containing_target; + } else if (later_child_is_relevant) { + return later_child; + } else if (!ts_node_is_null(later_child)) { + node = later_child; + } else if (later_node_is_relevant) { + return later_node; + } else { + node = later_node; + } + } + + return ts_node__null(); +} + +static inline TSNode ts_node__first_child_for_byte( + TSNode self, + uint32_t goal, + bool include_anonymous +) { + TSNode node = self; + bool did_descend = true; + + NodeChildIterator last_iterator; + bool has_last_iterator = false; + + while (did_descend) { + did_descend = false; + + TSNode child; + NodeChildIterator iterator = ts_node_iterate_children(&node); + loop: + while (ts_node_child_iterator_next(&iterator, &child)) { + if (ts_node_end_byte(child) > goal) { + if (ts_node__is_relevant(child, include_anonymous)) { + return child; + } else if (ts_node_child_count(child) > 0) { + if (iterator.child_index < ts_subtree_child_count(ts_node__subtree(child))) { + last_iterator = iterator; + has_last_iterator = true; + } + did_descend = true; + node = child; + break; + } + } + } + + if (!did_descend && has_last_iterator) { + iterator = last_iterator; + has_last_iterator = false; + goto loop; + } + } + + return ts_node__null(); +} + +static inline TSNode ts_node__descendant_for_byte_range( + TSNode self, + uint32_t range_start, + uint32_t range_end, + bool include_anonymous +) { + if (range_start > range_end) { + return ts_node__null(); + } + TSNode node = self; + TSNode last_visible_node = self; + + bool did_descend = true; + while (did_descend) { + did_descend = false; + + TSNode child; + NodeChildIterator iterator = ts_node_iterate_children(&node); + while (ts_node_child_iterator_next(&iterator, &child)) { + uint32_t node_end = iterator.position.bytes; + + // The end of this node must extend far enough forward to touch + // the end of the range + if (node_end < range_end) continue; + + // ...and exceed the start of the range, unless the node itself is + // empty, in which case it must at least be equal to the start of the range. + bool is_empty = ts_node_start_byte(child) == node_end; + if (is_empty ? node_end < range_start : node_end <= range_start) continue; + + // The start of this node must extend far enough backward to + // touch the start of the range. + if (range_start < ts_node_start_byte(child)) break; + + node = child; + if (ts_node__is_relevant(node, include_anonymous)) { + last_visible_node = node; + } + did_descend = true; + break; + } + } + + return last_visible_node; +} + +static inline TSNode ts_node__descendant_for_point_range( + TSNode self, + TSPoint range_start, + TSPoint range_end, + bool include_anonymous +) { + if (point_gt(range_start, range_end)) { + return ts_node__null(); + } + TSNode node = self; + TSNode last_visible_node = self; + + bool did_descend = true; + while (did_descend) { + did_descend = false; + + TSNode child; + NodeChildIterator iterator = ts_node_iterate_children(&node); + while (ts_node_child_iterator_next(&iterator, &child)) { + TSPoint node_end = iterator.position.extent; + + // The end of this node must extend far enough forward to touch + // the end of the range + if (point_lt(node_end, range_end)) continue; + + // ...and exceed the start of the range, unless the node itself is + // empty, in which case it must at least be equal to the start of the range. + bool is_empty = point_eq(ts_node_start_point(child), node_end); + if (is_empty ? point_lt(node_end, range_start) : point_lte(node_end, range_start)) { + continue; + } + + // The start of this node must extend far enough backward to + // touch the start of the range. + if (point_lt(range_start, ts_node_start_point(child))) break; + + node = child; + if (ts_node__is_relevant(node, include_anonymous)) { + last_visible_node = node; + } + did_descend = true; + break; + } + } + + return last_visible_node; +} + +// TSNode - public + +uint32_t ts_node_end_byte(TSNode self) { + return ts_node_start_byte(self) + ts_subtree_size(ts_node__subtree(self)).bytes; +} + +TSPoint ts_node_end_point(TSNode self) { + return point_add(ts_node_start_point(self), ts_subtree_size(ts_node__subtree(self)).extent); +} + +TSSymbol ts_node_symbol(TSNode self) { + TSSymbol symbol = ts_node__alias(&self); + if (!symbol) symbol = ts_subtree_symbol(ts_node__subtree(self)); + return ts_language_public_symbol(self.tree->language, symbol); +} + +const char *ts_node_type(TSNode self) { + TSSymbol symbol = ts_node__alias(&self); + if (!symbol) symbol = ts_subtree_symbol(ts_node__subtree(self)); + return ts_language_symbol_name(self.tree->language, symbol); +} + +const TSLanguage *ts_node_language(TSNode self) { + return self.tree->language; +} + +TSSymbol ts_node_grammar_symbol(TSNode self) { + return ts_subtree_symbol(ts_node__subtree(self)); +} + +const char *ts_node_grammar_type(TSNode self) { + TSSymbol symbol = ts_subtree_symbol(ts_node__subtree(self)); + return ts_language_symbol_name(self.tree->language, symbol); +} + +char *ts_node_string(TSNode self) { + TSSymbol alias_symbol = ts_node__alias(&self); + return ts_subtree_string( + ts_node__subtree(self), + alias_symbol, + ts_language_symbol_metadata(self.tree->language, alias_symbol).visible, + self.tree->language, + false + ); +} + +bool ts_node_eq(TSNode self, TSNode other) { + return self.tree == other.tree && self.id == other.id; +} + +bool ts_node_is_null(TSNode self) { + return self.id == 0; +} + +bool ts_node_is_extra(TSNode self) { + return ts_subtree_extra(ts_node__subtree(self)); +} + +bool ts_node_is_named(TSNode self) { + TSSymbol alias = ts_node__alias(&self); + return alias + ? ts_language_symbol_metadata(self.tree->language, alias).named + : ts_subtree_named(ts_node__subtree(self)); +} + +bool ts_node_is_missing(TSNode self) { + return ts_subtree_missing(ts_node__subtree(self)); +} + +bool ts_node_has_changes(TSNode self) { + return ts_subtree_has_changes(ts_node__subtree(self)); +} + +bool ts_node_has_error(TSNode self) { + return ts_subtree_error_cost(ts_node__subtree(self)) > 0; +} + +bool ts_node_is_error(TSNode self) { + TSSymbol symbol = ts_node_symbol(self); + return symbol == ts_builtin_sym_error; +} + +uint32_t ts_node_descendant_count(TSNode self) { + return ts_subtree_visible_descendant_count(ts_node__subtree(self)) + 1; +} + +TSStateId ts_node_parse_state(TSNode self) { + return ts_subtree_parse_state(ts_node__subtree(self)); +} + +TSStateId ts_node_next_parse_state(TSNode self) { + const TSLanguage *language = self.tree->language; + uint16_t state = ts_node_parse_state(self); + if (state == TS_TREE_STATE_NONE) { + return TS_TREE_STATE_NONE; + } + uint16_t symbol = ts_node_grammar_symbol(self); + return ts_language_next_state(language, state, symbol); +} + +TSNode ts_node_parent(TSNode self) { + TSNode node = ts_tree_root_node(self.tree); + if (node.id == self.id) return ts_node__null(); + + while (true) { + TSNode next_node = ts_node_child_with_descendant(node, self); + if (next_node.id == self.id || ts_node_is_null(next_node)) break; + node = next_node; + } + + return node; +} + +TSNode ts_node_child_with_descendant(TSNode self, TSNode descendant) { + uint32_t start_byte = ts_node_start_byte(descendant); + uint32_t end_byte = ts_node_end_byte(descendant); + bool is_empty = start_byte == end_byte; + + do { + NodeChildIterator iter = ts_node_iterate_children(&self); + do { + if ( + !ts_node_child_iterator_next(&iter, &self) + || ts_node_start_byte(self) > start_byte + ) { + return ts_node__null(); + } + if (self.id == descendant.id) { + return self; + } + + // If the descendant is empty, and the end byte is within `self`, + // we check whether `self` contains it or not. + if (is_empty && iter.position.bytes >= end_byte && ts_node_child_count(self) > 0) { + TSNode child = ts_node_child_with_descendant(self, descendant); + // If the child is not null, return self if it's relevant, else return the child + if (!ts_node_is_null(child)) { + return ts_node__is_relevant(self, true) ? self : child; + } + } + } while ((is_empty ? iter.position.bytes <= end_byte : iter.position.bytes < end_byte) || ts_node_child_count(self) == 0); + } while (!ts_node__is_relevant(self, true)); + + return self; +} + +TSNode ts_node_child(TSNode self, uint32_t child_index) { + return ts_node__child(self, child_index, true); +} + +TSNode ts_node_named_child(TSNode self, uint32_t child_index) { + return ts_node__child(self, child_index, false); +} + +TSNode ts_node_child_by_field_id(TSNode self, TSFieldId field_id) { +recur: + if (!field_id || ts_node_child_count(self) == 0) return ts_node__null(); + + const TSFieldMapEntry *field_map, *field_map_end; + ts_language_field_map( + self.tree->language, + ts_node__subtree(self).ptr->production_id, + &field_map, + &field_map_end + ); + if (field_map == field_map_end) return ts_node__null(); + + // The field mappings are sorted by their field id. Scan all + // the mappings to find the ones for the given field id. + while (field_map->field_id < field_id) { + field_map++; + if (field_map == field_map_end) return ts_node__null(); + } + while (field_map_end[-1].field_id > field_id) { + field_map_end--; + if (field_map == field_map_end) return ts_node__null(); + } + + TSNode child; + NodeChildIterator iterator = ts_node_iterate_children(&self); + while (ts_node_child_iterator_next(&iterator, &child)) { + if (!ts_subtree_extra(ts_node__subtree(child))) { + uint32_t index = iterator.structural_child_index - 1; + if (index < field_map->child_index) continue; + + // Hidden nodes' fields are "inherited" by their visible parent. + if (field_map->inherited) { + + // If this is the *last* possible child node for this field, + // then perform a tail call to avoid recursion. + if (field_map + 1 == field_map_end) { + self = child; + goto recur; + } + + // Otherwise, descend into this child, but if it doesn't contain + // the field, continue searching subsequent children. + else { + TSNode result = ts_node_child_by_field_id(child, field_id); + if (result.id) return result; + field_map++; + if (field_map == field_map_end) return ts_node__null(); + } + } + + else if (ts_node__is_relevant(child, true)) { + return child; + } + + // If the field refers to a hidden node with visible children, + // return the first visible child. + else if (ts_node_child_count(child) > 0 ) { + return ts_node_child(child, 0); + } + + // Otherwise, continue searching subsequent children. + else { + field_map++; + if (field_map == field_map_end) return ts_node__null(); + } + } + } + + return ts_node__null(); +} + +static inline const char *ts_node__field_name_from_language(TSNode self, uint32_t structural_child_index) { + const TSFieldMapEntry *field_map, *field_map_end; + ts_language_field_map( + self.tree->language, + ts_node__subtree(self).ptr->production_id, + &field_map, + &field_map_end + ); + for (; field_map != field_map_end; field_map++) { + if (!field_map->inherited && field_map->child_index == structural_child_index) { + return self.tree->language->field_names[field_map->field_id]; + } + } + return NULL; +} + +const char *ts_node_field_name_for_child(TSNode self, uint32_t child_index) { + TSNode result = self; + bool did_descend = true; + const char *inherited_field_name = NULL; + + while (did_descend) { + did_descend = false; + + TSNode child; + uint32_t index = 0; + NodeChildIterator iterator = ts_node_iterate_children(&result); + while (ts_node_child_iterator_next(&iterator, &child)) { + if (ts_node__is_relevant(child, true)) { + if (index == child_index) { + if (ts_node_is_extra(child)) { + return NULL; + } + const char *field_name = ts_node__field_name_from_language(result, iterator.structural_child_index - 1); + if (field_name) return field_name; + return inherited_field_name; + } + index++; + } else { + uint32_t grandchild_index = child_index - index; + uint32_t grandchild_count = ts_node__relevant_child_count(child, true); + if (grandchild_index < grandchild_count) { + const char *field_name = ts_node__field_name_from_language(result, iterator.structural_child_index - 1); + if (field_name) inherited_field_name = field_name; + + did_descend = true; + result = child; + child_index = grandchild_index; + break; + } + index += grandchild_count; + } + } + } + + return NULL; +} + +const char *ts_node_field_name_for_named_child(TSNode self, uint32_t named_child_index) { + TSNode result = self; + bool did_descend = true; + const char *inherited_field_name = NULL; + + while (did_descend) { + did_descend = false; + + TSNode child; + uint32_t index = 0; + NodeChildIterator iterator = ts_node_iterate_children(&result); + while (ts_node_child_iterator_next(&iterator, &child)) { + if (ts_node__is_relevant(child, false)) { + if (index == named_child_index) { + if (ts_node_is_extra(child)) { + return NULL; + } + const char *field_name = ts_node__field_name_from_language(result, iterator.structural_child_index - 1); + if (field_name) return field_name; + return inherited_field_name; + } + index++; + } else { + uint32_t named_grandchild_index = named_child_index - index; + uint32_t grandchild_count = ts_node__relevant_child_count(child, false); + if (named_grandchild_index < grandchild_count) { + const char *field_name = ts_node__field_name_from_language(result, iterator.structural_child_index - 1); + if (field_name) inherited_field_name = field_name; + + did_descend = true; + result = child; + named_child_index = named_grandchild_index; + break; + } + index += grandchild_count; + } + } + } + + return NULL; +} + +TSNode ts_node_child_by_field_name( + TSNode self, + const char *name, + uint32_t name_length +) { + TSFieldId field_id = ts_language_field_id_for_name( + self.tree->language, + name, + name_length + ); + return ts_node_child_by_field_id(self, field_id); +} + +uint32_t ts_node_child_count(TSNode self) { + Subtree tree = ts_node__subtree(self); + if (ts_subtree_child_count(tree) > 0) { + return tree.ptr->visible_child_count; + } else { + return 0; + } +} + +uint32_t ts_node_named_child_count(TSNode self) { + Subtree tree = ts_node__subtree(self); + if (ts_subtree_child_count(tree) > 0) { + return tree.ptr->named_child_count; + } else { + return 0; + } +} + +TSNode ts_node_next_sibling(TSNode self) { + return ts_node__next_sibling(self, true); +} + +TSNode ts_node_next_named_sibling(TSNode self) { + return ts_node__next_sibling(self, false); +} + +TSNode ts_node_prev_sibling(TSNode self) { + return ts_node__prev_sibling(self, true); +} + +TSNode ts_node_prev_named_sibling(TSNode self) { + return ts_node__prev_sibling(self, false); +} + +TSNode ts_node_first_child_for_byte(TSNode self, uint32_t byte) { + return ts_node__first_child_for_byte(self, byte, true); +} + +TSNode ts_node_first_named_child_for_byte(TSNode self, uint32_t byte) { + return ts_node__first_child_for_byte(self, byte, false); +} + +TSNode ts_node_descendant_for_byte_range( + TSNode self, + uint32_t start, + uint32_t end +) { + return ts_node__descendant_for_byte_range(self, start, end, true); +} + +TSNode ts_node_named_descendant_for_byte_range( + TSNode self, + uint32_t start, + uint32_t end +) { + return ts_node__descendant_for_byte_range(self, start, end, false); +} + +TSNode ts_node_descendant_for_point_range( + TSNode self, + TSPoint start, + TSPoint end +) { + return ts_node__descendant_for_point_range(self, start, end, true); +} + +TSNode ts_node_named_descendant_for_point_range( + TSNode self, + TSPoint start, + TSPoint end +) { + return ts_node__descendant_for_point_range(self, start, end, false); +} + +void ts_node_edit(TSNode *self, const TSInputEdit *edit) { + uint32_t start_byte = ts_node_start_byte(*self); + TSPoint start_point = ts_node_start_point(*self); + + if (start_byte >= edit->old_end_byte) { + start_byte = edit->new_end_byte + (start_byte - edit->old_end_byte); + start_point = point_add(edit->new_end_point, point_sub(start_point, edit->old_end_point)); + } else if (start_byte > edit->start_byte) { + start_byte = edit->new_end_byte; + start_point = edit->new_end_point; + } + + self->context[0] = start_byte; + self->context[1] = start_point.row; + self->context[2] = start_point.column; +} diff --git a/vendor/tree-sitter/src/parser.c b/vendor/tree-sitter/src/parser.c new file mode 100644 index 0000000..4a6dc0f --- /dev/null +++ b/vendor/tree-sitter/src/parser.c @@ -0,0 +1,2297 @@ +#include +#include +#include +#include +#include +#include "tree_sitter/api.h" +#include "./alloc.h" +#include "./array.h" +#include "./atomic.h" +#include "./clock.h" +#include "./error_costs.h" +#include "./get_changed_ranges.h" +#include "./language.h" +#include "./length.h" +#include "./lexer.h" +#include "./reduce_action.h" +#include "./reusable_node.h" +#include "./stack.h" +#include "./subtree.h" +#include "./tree.h" +#include "./ts_assert.h" +#include "./wasm_store.h" + +#define LOG(...) \ + if (self->lexer.logger.log || self->dot_graph_file) { \ + snprintf(self->lexer.debug_buffer, TREE_SITTER_SERIALIZATION_BUFFER_SIZE, __VA_ARGS__); \ + ts_parser__log(self); \ + } + +#define LOG_LOOKAHEAD(symbol_name, size) \ + if (self->lexer.logger.log || self->dot_graph_file) { \ + char *buf = self->lexer.debug_buffer; \ + const char *symbol = symbol_name; \ + int off = snprintf( \ + buf, \ + TREE_SITTER_SERIALIZATION_BUFFER_SIZE, \ + "lexed_lookahead sym:" \ + ); \ + for ( \ + int i = 0; \ + symbol[i] != '\0' \ + && off < TREE_SITTER_SERIALIZATION_BUFFER_SIZE; \ + i++ \ + ) { \ + switch (symbol[i]) { \ + case '\t': buf[off++] = '\\'; buf[off++] = 't'; break; \ + case '\n': buf[off++] = '\\'; buf[off++] = 'n'; break; \ + case '\v': buf[off++] = '\\'; buf[off++] = 'v'; break; \ + case '\f': buf[off++] = '\\'; buf[off++] = 'f'; break; \ + case '\r': buf[off++] = '\\'; buf[off++] = 'r'; break; \ + case '\\': buf[off++] = '\\'; buf[off++] = '\\'; break; \ + default: buf[off++] = symbol[i]; break; \ + } \ + } \ + snprintf( \ + buf + off, \ + TREE_SITTER_SERIALIZATION_BUFFER_SIZE - off, \ + ", size:%u", \ + size \ + ); \ + ts_parser__log(self); \ + } + +#define LOG_STACK() \ + if (self->dot_graph_file) { \ + ts_stack_print_dot_graph(self->stack, self->language, self->dot_graph_file); \ + fputs("\n\n", self->dot_graph_file); \ + } + +#define LOG_TREE(tree) \ + if (self->dot_graph_file) { \ + ts_subtree_print_dot_graph(tree, self->language, self->dot_graph_file); \ + fputs("\n", self->dot_graph_file); \ + } + +#define SYM_NAME(symbol) ts_language_symbol_name(self->language, symbol) + +#define TREE_NAME(tree) SYM_NAME(ts_subtree_symbol(tree)) + +static const unsigned MAX_VERSION_COUNT = 6; +static const unsigned MAX_VERSION_COUNT_OVERFLOW = 4; +static const unsigned MAX_SUMMARY_DEPTH = 16; +static const unsigned MAX_COST_DIFFERENCE = 18 * ERROR_COST_PER_SKIPPED_TREE; +static const unsigned OP_COUNT_PER_PARSER_TIMEOUT_CHECK = 100; + +typedef struct { + Subtree token; + Subtree last_external_token; + uint32_t byte_index; +} TokenCache; + +struct TSParser { + Lexer lexer; + Stack *stack; + SubtreePool tree_pool; + const TSLanguage *language; + TSWasmStore *wasm_store; + ReduceActionSet reduce_actions; + Subtree finished_tree; + SubtreeArray trailing_extras; + SubtreeArray trailing_extras2; + SubtreeArray scratch_trees; + TokenCache token_cache; + ReusableNode reusable_node; + void *external_scanner_payload; + FILE *dot_graph_file; + TSClock end_clock; + TSDuration timeout_duration; + unsigned accept_count; + unsigned operation_count; + const volatile size_t *cancellation_flag; + Subtree old_tree; + TSRangeArray included_range_differences; + TSParseOptions parse_options; + TSParseState parse_state; + unsigned included_range_difference_index; + bool has_scanner_error; + bool canceled_balancing; + bool has_error; +}; + +typedef struct { + unsigned cost; + unsigned node_count; + int dynamic_precedence; + bool is_in_error; +} ErrorStatus; + +typedef enum { + ErrorComparisonTakeLeft, + ErrorComparisonPreferLeft, + ErrorComparisonNone, + ErrorComparisonPreferRight, + ErrorComparisonTakeRight, +} ErrorComparison; + +typedef struct { + const char *string; + uint32_t length; +} TSStringInput; + +// StringInput + +static const char *ts_string_input_read( + void *_self, + uint32_t byte, + TSPoint point, + uint32_t *length +) { + (void)point; + TSStringInput *self = (TSStringInput *)_self; + if (byte >= self->length) { + *length = 0; + return ""; + } else { + *length = self->length - byte; + return self->string + byte; + } +} + +// Parser - Private + +static void ts_parser__log(TSParser *self) { + if (self->lexer.logger.log) { + self->lexer.logger.log( + self->lexer.logger.payload, + TSLogTypeParse, + self->lexer.debug_buffer + ); + } + + if (self->dot_graph_file) { + fprintf(self->dot_graph_file, "graph {\nlabel=\""); + for (char *chr = &self->lexer.debug_buffer[0]; *chr != 0; chr++) { + if (*chr == '"' || *chr == '\\') fputc('\\', self->dot_graph_file); + fputc(*chr, self->dot_graph_file); + } + fprintf(self->dot_graph_file, "\"\n}\n\n"); + } +} + +static bool ts_parser__breakdown_top_of_stack( + TSParser *self, + StackVersion version +) { + bool did_break_down = false; + bool pending = false; + + do { + StackSliceArray pop = ts_stack_pop_pending(self->stack, version); + if (!pop.size) break; + + did_break_down = true; + pending = false; + for (uint32_t i = 0; i < pop.size; i++) { + StackSlice slice = *array_get(&pop, i); + TSStateId state = ts_stack_state(self->stack, slice.version); + Subtree parent = *array_front(&slice.subtrees); + + for (uint32_t j = 0, n = ts_subtree_child_count(parent); j < n; j++) { + Subtree child = ts_subtree_children(parent)[j]; + pending = ts_subtree_child_count(child) > 0; + + if (ts_subtree_is_error(child)) { + state = ERROR_STATE; + } else if (!ts_subtree_extra(child)) { + state = ts_language_next_state(self->language, state, ts_subtree_symbol(child)); + } + + ts_subtree_retain(child); + ts_stack_push(self->stack, slice.version, child, pending, state); + } + + for (uint32_t j = 1; j < slice.subtrees.size; j++) { + Subtree tree = *array_get(&slice.subtrees, j); + ts_stack_push(self->stack, slice.version, tree, false, state); + } + + ts_subtree_release(&self->tree_pool, parent); + array_delete(&slice.subtrees); + + LOG("breakdown_top_of_stack tree:%s", TREE_NAME(parent)); + LOG_STACK(); + } + } while (pending); + + return did_break_down; +} + +static void ts_parser__breakdown_lookahead( + TSParser *self, + Subtree *lookahead, + TSStateId state, + ReusableNode *reusable_node +) { + bool did_descend = false; + Subtree tree = reusable_node_tree(reusable_node); + while (ts_subtree_child_count(tree) > 0 && ts_subtree_parse_state(tree) != state) { + LOG("state_mismatch sym:%s", TREE_NAME(tree)); + reusable_node_descend(reusable_node); + tree = reusable_node_tree(reusable_node); + did_descend = true; + } + + if (did_descend) { + ts_subtree_release(&self->tree_pool, *lookahead); + *lookahead = tree; + ts_subtree_retain(*lookahead); + } +} + +static ErrorComparison ts_parser__compare_versions( + TSParser *self, + ErrorStatus a, + ErrorStatus b +) { + (void)self; + if (!a.is_in_error && b.is_in_error) { + if (a.cost < b.cost) { + return ErrorComparisonTakeLeft; + } else { + return ErrorComparisonPreferLeft; + } + } + + if (a.is_in_error && !b.is_in_error) { + if (b.cost < a.cost) { + return ErrorComparisonTakeRight; + } else { + return ErrorComparisonPreferRight; + } + } + + if (a.cost < b.cost) { + if ((b.cost - a.cost) * (1 + a.node_count) > MAX_COST_DIFFERENCE) { + return ErrorComparisonTakeLeft; + } else { + return ErrorComparisonPreferLeft; + } + } + + if (b.cost < a.cost) { + if ((a.cost - b.cost) * (1 + b.node_count) > MAX_COST_DIFFERENCE) { + return ErrorComparisonTakeRight; + } else { + return ErrorComparisonPreferRight; + } + } + + if (a.dynamic_precedence > b.dynamic_precedence) return ErrorComparisonPreferLeft; + if (b.dynamic_precedence > a.dynamic_precedence) return ErrorComparisonPreferRight; + return ErrorComparisonNone; +} + +static ErrorStatus ts_parser__version_status( + TSParser *self, + StackVersion version +) { + unsigned cost = ts_stack_error_cost(self->stack, version); + bool is_paused = ts_stack_is_paused(self->stack, version); + if (is_paused) cost += ERROR_COST_PER_SKIPPED_TREE; + return (ErrorStatus) { + .cost = cost, + .node_count = ts_stack_node_count_since_error(self->stack, version), + .dynamic_precedence = ts_stack_dynamic_precedence(self->stack, version), + .is_in_error = is_paused || ts_stack_state(self->stack, version) == ERROR_STATE + }; +} + +static bool ts_parser__better_version_exists( + TSParser *self, + StackVersion version, + bool is_in_error, + unsigned cost +) { + if (self->finished_tree.ptr && ts_subtree_error_cost(self->finished_tree) <= cost) { + return true; + } + + Length position = ts_stack_position(self->stack, version); + ErrorStatus status = { + .cost = cost, + .is_in_error = is_in_error, + .dynamic_precedence = ts_stack_dynamic_precedence(self->stack, version), + .node_count = ts_stack_node_count_since_error(self->stack, version), + }; + + for (StackVersion i = 0, n = ts_stack_version_count(self->stack); i < n; i++) { + if (i == version || + !ts_stack_is_active(self->stack, i) || + ts_stack_position(self->stack, i).bytes < position.bytes) continue; + ErrorStatus status_i = ts_parser__version_status(self, i); + switch (ts_parser__compare_versions(self, status, status_i)) { + case ErrorComparisonTakeRight: + return true; + case ErrorComparisonPreferRight: + if (ts_stack_can_merge(self->stack, i, version)) return true; + break; + default: + break; + } + } + + return false; +} + +static bool ts_parser__call_main_lex_fn(TSParser *self, TSLexerMode lex_mode) { + if (ts_language_is_wasm(self->language)) { + return ts_wasm_store_call_lex_main(self->wasm_store, lex_mode.lex_state); + } else { + return self->language->lex_fn(&self->lexer.data, lex_mode.lex_state); + } +} + +static bool ts_parser__call_keyword_lex_fn(TSParser *self) { + if (ts_language_is_wasm(self->language)) { + return ts_wasm_store_call_lex_keyword(self->wasm_store, 0); + } else { + return self->language->keyword_lex_fn(&self->lexer.data, 0); + } +} + +static void ts_parser__external_scanner_create( + TSParser *self +) { + if (self->language && self->language->external_scanner.states) { + if (ts_language_is_wasm(self->language)) { + self->external_scanner_payload = (void *)(uintptr_t)ts_wasm_store_call_scanner_create( + self->wasm_store + ); + if (ts_wasm_store_has_error(self->wasm_store)) { + self->has_scanner_error = true; + } + } else if (self->language->external_scanner.create) { + self->external_scanner_payload = self->language->external_scanner.create(); + } + } +} + +static void ts_parser__external_scanner_destroy( + TSParser *self +) { + if ( + self->language && + self->external_scanner_payload && + self->language->external_scanner.destroy && + !ts_language_is_wasm(self->language) + ) { + self->language->external_scanner.destroy( + self->external_scanner_payload + ); + } + self->external_scanner_payload = NULL; +} + +static unsigned ts_parser__external_scanner_serialize( + TSParser *self +) { + uint32_t length; + if (ts_language_is_wasm(self->language)) { + length = ts_wasm_store_call_scanner_serialize( + self->wasm_store, + (uintptr_t)self->external_scanner_payload, + self->lexer.debug_buffer + ); + if (ts_wasm_store_has_error(self->wasm_store)) { + self->has_scanner_error = true; + } + } else { + length = self->language->external_scanner.serialize( + self->external_scanner_payload, + self->lexer.debug_buffer + ); + } + ts_assert(length <= TREE_SITTER_SERIALIZATION_BUFFER_SIZE); + return length; +} + +static void ts_parser__external_scanner_deserialize( + TSParser *self, + Subtree external_token +) { + const char *data = NULL; + uint32_t length = 0; + if (external_token.ptr) { + data = ts_external_scanner_state_data(&external_token.ptr->external_scanner_state); + length = external_token.ptr->external_scanner_state.length; + } + + if (ts_language_is_wasm(self->language)) { + ts_wasm_store_call_scanner_deserialize( + self->wasm_store, + (uintptr_t)self->external_scanner_payload, + data, + length + ); + if (ts_wasm_store_has_error(self->wasm_store)) { + self->has_scanner_error = true; + } + } else { + self->language->external_scanner.deserialize( + self->external_scanner_payload, + data, + length + ); + } +} + +static bool ts_parser__external_scanner_scan( + TSParser *self, + TSStateId external_lex_state +) { + if (ts_language_is_wasm(self->language)) { + bool result = ts_wasm_store_call_scanner_scan( + self->wasm_store, + (uintptr_t)self->external_scanner_payload, + external_lex_state * self->language->external_token_count + ); + if (ts_wasm_store_has_error(self->wasm_store)) { + self->has_scanner_error = true; + } + return result; + } else { + const bool *valid_external_tokens = ts_language_enabled_external_tokens( + self->language, + external_lex_state + ); + return self->language->external_scanner.scan( + self->external_scanner_payload, + &self->lexer.data, + valid_external_tokens + ); + } +} + +static bool ts_parser__can_reuse_first_leaf( + TSParser *self, + TSStateId state, + Subtree tree, + TableEntry *table_entry +) { + TSSymbol leaf_symbol = ts_subtree_leaf_symbol(tree); + TSStateId leaf_state = ts_subtree_leaf_parse_state(tree); + TSLexerMode current_lex_mode = ts_language_lex_mode_for_state(self->language, state); + TSLexerMode leaf_lex_mode = ts_language_lex_mode_for_state(self->language, leaf_state); + + // At the end of a non-terminal extra node, the lexer normally returns + // NULL, which indicates that the parser should look for a reduce action + // at symbol `0`. Avoid reusing tokens in this situation to ensure that + // the same thing happens when incrementally reparsing. + if (current_lex_mode.lex_state == (uint16_t)(-1)) return false; + + // If the token was created in a state with the same set of lookaheads, it is reusable. + if ( + table_entry->action_count > 0 && + memcmp(&leaf_lex_mode, ¤t_lex_mode, sizeof(TSLexerMode)) == 0 && + ( + leaf_symbol != self->language->keyword_capture_token || + (!ts_subtree_is_keyword(tree) && ts_subtree_parse_state(tree) == state) + ) + ) return true; + + // Empty tokens are not reusable in states with different lookaheads. + if (ts_subtree_size(tree).bytes == 0 && leaf_symbol != ts_builtin_sym_end) return false; + + // If the current state allows external tokens or other tokens that conflict with this + // token, this token is not reusable. + return current_lex_mode.external_lex_state == 0 && table_entry->is_reusable; +} + +static Subtree ts_parser__lex( + TSParser *self, + StackVersion version, + TSStateId parse_state +) { + TSLexerMode lex_mode = ts_language_lex_mode_for_state(self->language, parse_state); + if (lex_mode.lex_state == (uint16_t)-1) { + LOG("no_lookahead_after_non_terminal_extra"); + return NULL_SUBTREE; + } + + const Length start_position = ts_stack_position(self->stack, version); + const Subtree external_token = ts_stack_last_external_token(self->stack, version); + + bool found_external_token = false; + bool error_mode = parse_state == ERROR_STATE; + bool skipped_error = false; + bool called_get_column = false; + int32_t first_error_character = 0; + Length error_start_position = length_zero(); + Length error_end_position = length_zero(); + uint32_t lookahead_end_byte = 0; + uint32_t external_scanner_state_len = 0; + bool external_scanner_state_changed = false; + ts_lexer_reset(&self->lexer, start_position); + + for (;;) { + bool found_token = false; + Length current_position = self->lexer.current_position; + ColumnData column_data = self->lexer.column_data; + + if (lex_mode.external_lex_state != 0) { + LOG( + "lex_external state:%d, row:%u, column:%u", + lex_mode.external_lex_state, + current_position.extent.row, + current_position.extent.column + ); + ts_lexer_start(&self->lexer); + ts_parser__external_scanner_deserialize(self, external_token); + found_token = ts_parser__external_scanner_scan(self, lex_mode.external_lex_state); + if (self->has_scanner_error) return NULL_SUBTREE; + ts_lexer_finish(&self->lexer, &lookahead_end_byte); + + if (found_token) { + external_scanner_state_len = ts_parser__external_scanner_serialize(self); + external_scanner_state_changed = !ts_external_scanner_state_eq( + ts_subtree_external_scanner_state(external_token), + self->lexer.debug_buffer, + external_scanner_state_len + ); + + // Avoid infinite loops caused by the external scanner returning empty tokens. + // Empty tokens are needed in some circumstances, e.g. indent/dedent tokens + // in Python. Ignore the following classes of empty tokens: + // + // * Tokens produced during error recovery. When recovering from an error, + // all tokens are allowed, so it's easy to accidentally return unwanted + // empty tokens. + // * Tokens that are marked as 'extra' in the grammar. These don't change + // the parse state, so they would definitely cause an infinite loop. + if ( + self->lexer.token_end_position.bytes <= current_position.bytes && + !external_scanner_state_changed + ) { + TSSymbol symbol = self->language->external_scanner.symbol_map[self->lexer.data.result_symbol]; + TSStateId next_parse_state = ts_language_next_state(self->language, parse_state, symbol); + bool token_is_extra = (next_parse_state == parse_state); + if (error_mode || !ts_stack_has_advanced_since_error(self->stack, version) || token_is_extra) { + LOG( + "ignore_empty_external_token symbol:%s", + SYM_NAME(self->language->external_scanner.symbol_map[self->lexer.data.result_symbol]) + ); + found_token = false; + } + } + } + + if (found_token) { + found_external_token = true; + called_get_column = self->lexer.did_get_column; + break; + } + + ts_lexer_reset(&self->lexer, current_position); + self->lexer.column_data = column_data; + } + + LOG( + "lex_internal state:%d, row:%u, column:%u", + lex_mode.lex_state, + current_position.extent.row, + current_position.extent.column + ); + ts_lexer_start(&self->lexer); + found_token = ts_parser__call_main_lex_fn(self, lex_mode); + ts_lexer_finish(&self->lexer, &lookahead_end_byte); + if (found_token) break; + + if (!error_mode) { + error_mode = true; + lex_mode = ts_language_lex_mode_for_state(self->language, ERROR_STATE); + ts_lexer_reset(&self->lexer, start_position); + continue; + } + + if (!skipped_error) { + LOG("skip_unrecognized_character"); + skipped_error = true; + error_start_position = self->lexer.token_start_position; + error_end_position = self->lexer.token_start_position; + first_error_character = self->lexer.data.lookahead; + } + + if (self->lexer.current_position.bytes == error_end_position.bytes) { + if (self->lexer.data.eof(&self->lexer.data)) { + self->lexer.data.result_symbol = ts_builtin_sym_error; + break; + } + self->lexer.data.advance(&self->lexer.data, false); + } + + error_end_position = self->lexer.current_position; + } + + Subtree result; + if (skipped_error) { + Length padding = length_sub(error_start_position, start_position); + Length size = length_sub(error_end_position, error_start_position); + uint32_t lookahead_bytes = lookahead_end_byte - error_end_position.bytes; + result = ts_subtree_new_error( + &self->tree_pool, + first_error_character, + padding, + size, + lookahead_bytes, + parse_state, + self->language + ); + } else { + bool is_keyword = false; + TSSymbol symbol = self->lexer.data.result_symbol; + Length padding = length_sub(self->lexer.token_start_position, start_position); + Length size = length_sub(self->lexer.token_end_position, self->lexer.token_start_position); + uint32_t lookahead_bytes = lookahead_end_byte - self->lexer.token_end_position.bytes; + + if (found_external_token) { + symbol = self->language->external_scanner.symbol_map[symbol]; + } else if (symbol == self->language->keyword_capture_token && symbol != 0) { + uint32_t end_byte = self->lexer.token_end_position.bytes; + ts_lexer_reset(&self->lexer, self->lexer.token_start_position); + ts_lexer_start(&self->lexer); + + is_keyword = ts_parser__call_keyword_lex_fn(self); + + if ( + is_keyword && + self->lexer.token_end_position.bytes == end_byte && + ( + ts_language_has_actions(self->language, parse_state, self->lexer.data.result_symbol) || + ts_language_is_reserved_word(self->language, parse_state, self->lexer.data.result_symbol) + ) + ) { + symbol = self->lexer.data.result_symbol; + } + } + + result = ts_subtree_new_leaf( + &self->tree_pool, + symbol, + padding, + size, + lookahead_bytes, + parse_state, + found_external_token, + called_get_column, + is_keyword, + self->language + ); + + if (found_external_token) { + MutableSubtree mut_result = ts_subtree_to_mut_unsafe(result); + ts_external_scanner_state_init( + &mut_result.ptr->external_scanner_state, + self->lexer.debug_buffer, + external_scanner_state_len + ); + mut_result.ptr->has_external_scanner_state_change = external_scanner_state_changed; + } + } + + LOG_LOOKAHEAD( + SYM_NAME(ts_subtree_symbol(result)), + ts_subtree_total_size(result).bytes + ); + return result; +} + +static Subtree ts_parser__get_cached_token( + TSParser *self, + TSStateId state, + size_t position, + Subtree last_external_token, + TableEntry *table_entry +) { + TokenCache *cache = &self->token_cache; + if ( + cache->token.ptr && cache->byte_index == position && + ts_subtree_external_scanner_state_eq(cache->last_external_token, last_external_token) + ) { + ts_language_table_entry(self->language, state, ts_subtree_symbol(cache->token), table_entry); + if (ts_parser__can_reuse_first_leaf(self, state, cache->token, table_entry)) { + ts_subtree_retain(cache->token); + return cache->token; + } + } + return NULL_SUBTREE; +} + +static void ts_parser__set_cached_token( + TSParser *self, + uint32_t byte_index, + Subtree last_external_token, + Subtree token +) { + TokenCache *cache = &self->token_cache; + if (token.ptr) ts_subtree_retain(token); + if (last_external_token.ptr) ts_subtree_retain(last_external_token); + if (cache->token.ptr) ts_subtree_release(&self->tree_pool, cache->token); + if (cache->last_external_token.ptr) ts_subtree_release(&self->tree_pool, cache->last_external_token); + cache->token = token; + cache->byte_index = byte_index; + cache->last_external_token = last_external_token; +} + +static bool ts_parser__has_included_range_difference( + const TSParser *self, + uint32_t start_position, + uint32_t end_position +) { + return ts_range_array_intersects( + &self->included_range_differences, + self->included_range_difference_index, + start_position, + end_position + ); +} + +static Subtree ts_parser__reuse_node( + TSParser *self, + StackVersion version, + TSStateId *state, + uint32_t position, + Subtree last_external_token, + TableEntry *table_entry +) { + Subtree result; + while ((result = reusable_node_tree(&self->reusable_node)).ptr) { + uint32_t byte_offset = reusable_node_byte_offset(&self->reusable_node); + uint32_t end_byte_offset = byte_offset + ts_subtree_total_bytes(result); + + // Do not reuse an EOF node if the included ranges array has changes + // later on in the file. + if (ts_subtree_is_eof(result)) end_byte_offset = UINT32_MAX; + + if (byte_offset > position) { + LOG("before_reusable_node symbol:%s", TREE_NAME(result)); + break; + } + + if (byte_offset < position) { + LOG("past_reusable_node symbol:%s", TREE_NAME(result)); + if (end_byte_offset <= position || !reusable_node_descend(&self->reusable_node)) { + reusable_node_advance(&self->reusable_node); + } + continue; + } + + if (!ts_subtree_external_scanner_state_eq(self->reusable_node.last_external_token, last_external_token)) { + LOG("reusable_node_has_different_external_scanner_state symbol:%s", TREE_NAME(result)); + reusable_node_advance(&self->reusable_node); + continue; + } + + const char *reason = NULL; + if (ts_subtree_has_changes(result)) { + reason = "has_changes"; + } else if (ts_subtree_is_error(result)) { + reason = "is_error"; + } else if (ts_subtree_missing(result)) { + reason = "is_missing"; + } else if (ts_subtree_is_fragile(result)) { + reason = "is_fragile"; + } else if (ts_parser__has_included_range_difference(self, byte_offset, end_byte_offset)) { + reason = "contains_different_included_range"; + } + + if (reason) { + LOG("cant_reuse_node_%s tree:%s", reason, TREE_NAME(result)); + if (!reusable_node_descend(&self->reusable_node)) { + reusable_node_advance(&self->reusable_node); + ts_parser__breakdown_top_of_stack(self, version); + *state = ts_stack_state(self->stack, version); + } + continue; + } + + TSSymbol leaf_symbol = ts_subtree_leaf_symbol(result); + ts_language_table_entry(self->language, *state, leaf_symbol, table_entry); + if (!ts_parser__can_reuse_first_leaf(self, *state, result, table_entry)) { + LOG( + "cant_reuse_node symbol:%s, first_leaf_symbol:%s", + TREE_NAME(result), + SYM_NAME(leaf_symbol) + ); + reusable_node_advance_past_leaf(&self->reusable_node); + break; + } + + LOG("reuse_node symbol:%s", TREE_NAME(result)); + ts_subtree_retain(result); + return result; + } + + return NULL_SUBTREE; +} + +// Determine if a given tree should be replaced by an alternative tree. +// +// The decision is based on the trees' error costs (if any), their dynamic precedence, +// and finally, as a default, by a recursive comparison of the trees' symbols. +static bool ts_parser__select_tree(TSParser *self, Subtree left, Subtree right) { + if (!left.ptr) return true; + if (!right.ptr) return false; + + if (ts_subtree_error_cost(right) < ts_subtree_error_cost(left)) { + LOG("select_smaller_error symbol:%s, over_symbol:%s", TREE_NAME(right), TREE_NAME(left)); + return true; + } + + if (ts_subtree_error_cost(left) < ts_subtree_error_cost(right)) { + LOG("select_smaller_error symbol:%s, over_symbol:%s", TREE_NAME(left), TREE_NAME(right)); + return false; + } + + if (ts_subtree_dynamic_precedence(right) > ts_subtree_dynamic_precedence(left)) { + LOG("select_higher_precedence symbol:%s, prec:%" PRId32 ", over_symbol:%s, other_prec:%" PRId32, + TREE_NAME(right), ts_subtree_dynamic_precedence(right), TREE_NAME(left), + ts_subtree_dynamic_precedence(left)); + return true; + } + + if (ts_subtree_dynamic_precedence(left) > ts_subtree_dynamic_precedence(right)) { + LOG("select_higher_precedence symbol:%s, prec:%" PRId32 ", over_symbol:%s, other_prec:%" PRId32, + TREE_NAME(left), ts_subtree_dynamic_precedence(left), TREE_NAME(right), + ts_subtree_dynamic_precedence(right)); + return false; + } + + if (ts_subtree_error_cost(left) > 0) return true; + + int comparison = ts_subtree_compare(left, right, &self->tree_pool); + switch (comparison) { + case -1: + LOG("select_earlier symbol:%s, over_symbol:%s", TREE_NAME(left), TREE_NAME(right)); + return false; + break; + case 1: + LOG("select_earlier symbol:%s, over_symbol:%s", TREE_NAME(right), TREE_NAME(left)); + return true; + default: + LOG("select_existing symbol:%s, over_symbol:%s", TREE_NAME(left), TREE_NAME(right)); + return false; + } +} + +// Determine if a given tree's children should be replaced by an alternative +// array of children. +static bool ts_parser__select_children( + TSParser *self, + Subtree left, + const SubtreeArray *children +) { + array_assign(&self->scratch_trees, children); + + // Create a temporary subtree using the scratch trees array. This node does + // not perform any allocation except for possibly growing the array to make + // room for its own heap data. The scratch tree is never explicitly released, + // so the same 'scratch trees' array can be reused again later. + MutableSubtree scratch_tree = ts_subtree_new_node( + ts_subtree_symbol(left), + &self->scratch_trees, + 0, + self->language + ); + + return ts_parser__select_tree( + self, + left, + ts_subtree_from_mut(scratch_tree) + ); +} + +static void ts_parser__shift( + TSParser *self, + StackVersion version, + TSStateId state, + Subtree lookahead, + bool extra +) { + bool is_leaf = ts_subtree_child_count(lookahead) == 0; + Subtree subtree_to_push = lookahead; + if (extra != ts_subtree_extra(lookahead) && is_leaf) { + MutableSubtree result = ts_subtree_make_mut(&self->tree_pool, lookahead); + ts_subtree_set_extra(&result, extra); + subtree_to_push = ts_subtree_from_mut(result); + } + + ts_stack_push(self->stack, version, subtree_to_push, !is_leaf, state); + if (ts_subtree_has_external_tokens(subtree_to_push)) { + ts_stack_set_last_external_token( + self->stack, version, ts_subtree_last_external_token(subtree_to_push) + ); + } +} + +static StackVersion ts_parser__reduce( + TSParser *self, + StackVersion version, + TSSymbol symbol, + uint32_t count, + int dynamic_precedence, + uint16_t production_id, + bool is_fragile, + bool end_of_non_terminal_extra +) { + uint32_t initial_version_count = ts_stack_version_count(self->stack); + + // Pop the given number of nodes from the given version of the parse stack. + // If stack versions have previously merged, then there may be more than one + // path back through the stack. For each path, create a new parent node to + // contain the popped children, and push it onto the stack in place of the + // children. + StackSliceArray pop = ts_stack_pop_count(self->stack, version, count); + uint32_t removed_version_count = 0; + uint32_t halted_version_count = ts_stack_halted_version_count(self->stack); + for (uint32_t i = 0; i < pop.size; i++) { + StackSlice slice = *array_get(&pop, i); + StackVersion slice_version = slice.version - removed_version_count; + + // This is where new versions are added to the parse stack. The versions + // will all be sorted and truncated at the end of the outer parsing loop. + // Allow the maximum version count to be temporarily exceeded, but only + // by a limited threshold. + if (slice_version > MAX_VERSION_COUNT + MAX_VERSION_COUNT_OVERFLOW + halted_version_count) { + ts_stack_remove_version(self->stack, slice_version); + ts_subtree_array_delete(&self->tree_pool, &slice.subtrees); + removed_version_count++; + while (i + 1 < pop.size) { + LOG("aborting reduce with too many versions") + StackSlice next_slice = *array_get(&pop, i + 1); + if (next_slice.version != slice.version) break; + ts_subtree_array_delete(&self->tree_pool, &next_slice.subtrees); + i++; + } + continue; + } + + // Extra tokens on top of the stack should not be included in this new parent + // node. They will be re-pushed onto the stack after the parent node is + // created and pushed. + SubtreeArray children = slice.subtrees; + ts_subtree_array_remove_trailing_extras(&children, &self->trailing_extras); + + MutableSubtree parent = ts_subtree_new_node( + symbol, &children, production_id, self->language + ); + + // This pop operation may have caused multiple stack versions to collapse + // into one, because they all diverged from a common state. In that case, + // choose one of the arrays of trees to be the parent node's children, and + // delete the rest of the tree arrays. + while (i + 1 < pop.size) { + StackSlice next_slice = *array_get(&pop, i + 1); + if (next_slice.version != slice.version) break; + i++; + + SubtreeArray next_slice_children = next_slice.subtrees; + ts_subtree_array_remove_trailing_extras(&next_slice_children, &self->trailing_extras2); + + if (ts_parser__select_children( + self, + ts_subtree_from_mut(parent), + &next_slice_children + )) { + ts_subtree_array_clear(&self->tree_pool, &self->trailing_extras); + ts_subtree_release(&self->tree_pool, ts_subtree_from_mut(parent)); + array_swap(&self->trailing_extras, &self->trailing_extras2); + parent = ts_subtree_new_node( + symbol, &next_slice_children, production_id, self->language + ); + } else { + array_clear(&self->trailing_extras2); + ts_subtree_array_delete(&self->tree_pool, &next_slice.subtrees); + } + } + + TSStateId state = ts_stack_state(self->stack, slice_version); + TSStateId next_state = ts_language_next_state(self->language, state, symbol); + if (end_of_non_terminal_extra && next_state == state) { + parent.ptr->extra = true; + } + if (is_fragile || pop.size > 1 || initial_version_count > 1) { + parent.ptr->fragile_left = true; + parent.ptr->fragile_right = true; + parent.ptr->parse_state = TS_TREE_STATE_NONE; + } else { + parent.ptr->parse_state = state; + } + parent.ptr->dynamic_precedence += dynamic_precedence; + + // Push the parent node onto the stack, along with any extra tokens that + // were previously on top of the stack. + ts_stack_push(self->stack, slice_version, ts_subtree_from_mut(parent), false, next_state); + for (uint32_t j = 0; j < self->trailing_extras.size; j++) { + ts_stack_push(self->stack, slice_version, *array_get(&self->trailing_extras, j), false, next_state); + } + + for (StackVersion j = 0; j < slice_version; j++) { + if (j == version) continue; + if (ts_stack_merge(self->stack, j, slice_version)) { + removed_version_count++; + break; + } + } + } + + // Return the first new stack version that was created. + return ts_stack_version_count(self->stack) > initial_version_count + ? initial_version_count + : STACK_VERSION_NONE; +} + +static void ts_parser__accept( + TSParser *self, + StackVersion version, + Subtree lookahead +) { + ts_assert(ts_subtree_is_eof(lookahead)); + ts_stack_push(self->stack, version, lookahead, false, 1); + + StackSliceArray pop = ts_stack_pop_all(self->stack, version); + for (uint32_t i = 0; i < pop.size; i++) { + SubtreeArray trees = array_get(&pop, i)->subtrees; + + Subtree root = NULL_SUBTREE; + for (uint32_t j = trees.size - 1; j + 1 > 0; j--) { + Subtree tree = *array_get(&trees, j); + if (!ts_subtree_extra(tree)) { + ts_assert(!tree.data.is_inline); + uint32_t child_count = ts_subtree_child_count(tree); + const Subtree *children = ts_subtree_children(tree); + for (uint32_t k = 0; k < child_count; k++) { + ts_subtree_retain(children[k]); + } + array_splice(&trees, j, 1, child_count, children); + root = ts_subtree_from_mut(ts_subtree_new_node( + ts_subtree_symbol(tree), + &trees, + tree.ptr->production_id, + self->language + )); + ts_subtree_release(&self->tree_pool, tree); + break; + } + } + + ts_assert(root.ptr); + self->accept_count++; + + if (self->finished_tree.ptr) { + if (ts_parser__select_tree(self, self->finished_tree, root)) { + ts_subtree_release(&self->tree_pool, self->finished_tree); + self->finished_tree = root; + } else { + ts_subtree_release(&self->tree_pool, root); + } + } else { + self->finished_tree = root; + } + } + + ts_stack_remove_version(self->stack, array_get(&pop, 0)->version); + ts_stack_halt(self->stack, version); +} + +static bool ts_parser__do_all_potential_reductions( + TSParser *self, + StackVersion starting_version, + TSSymbol lookahead_symbol +) { + uint32_t initial_version_count = ts_stack_version_count(self->stack); + + bool can_shift_lookahead_symbol = false; + StackVersion version = starting_version; + for (unsigned i = 0; true; i++) { + uint32_t version_count = ts_stack_version_count(self->stack); + if (version >= version_count) break; + + bool merged = false; + for (StackVersion j = initial_version_count; j < version; j++) { + if (ts_stack_merge(self->stack, j, version)) { + merged = true; + break; + } + } + if (merged) continue; + + TSStateId state = ts_stack_state(self->stack, version); + bool has_shift_action = false; + array_clear(&self->reduce_actions); + + TSSymbol first_symbol, end_symbol; + if (lookahead_symbol != 0) { + first_symbol = lookahead_symbol; + end_symbol = lookahead_symbol + 1; + } else { + first_symbol = 1; + end_symbol = self->language->token_count; + } + + for (TSSymbol symbol = first_symbol; symbol < end_symbol; symbol++) { + TableEntry entry; + ts_language_table_entry(self->language, state, symbol, &entry); + for (uint32_t j = 0; j < entry.action_count; j++) { + TSParseAction action = entry.actions[j]; + switch (action.type) { + case TSParseActionTypeShift: + case TSParseActionTypeRecover: + if (!action.shift.extra && !action.shift.repetition) has_shift_action = true; + break; + case TSParseActionTypeReduce: + if (action.reduce.child_count > 0) + ts_reduce_action_set_add(&self->reduce_actions, (ReduceAction) { + .symbol = action.reduce.symbol, + .count = action.reduce.child_count, + .dynamic_precedence = action.reduce.dynamic_precedence, + .production_id = action.reduce.production_id, + }); + break; + default: + break; + } + } + } + + StackVersion reduction_version = STACK_VERSION_NONE; + for (uint32_t j = 0; j < self->reduce_actions.size; j++) { + ReduceAction action = *array_get(&self->reduce_actions, j); + + reduction_version = ts_parser__reduce( + self, version, action.symbol, action.count, + action.dynamic_precedence, action.production_id, + true, false + ); + } + + if (has_shift_action) { + can_shift_lookahead_symbol = true; + } else if (reduction_version != STACK_VERSION_NONE && i < MAX_VERSION_COUNT) { + ts_stack_renumber_version(self->stack, reduction_version, version); + continue; + } else if (lookahead_symbol != 0) { + ts_stack_remove_version(self->stack, version); + } + + if (version == starting_version) { + version = version_count; + } else { + version++; + } + } + + return can_shift_lookahead_symbol; +} + +static bool ts_parser__recover_to_state( + TSParser *self, + StackVersion version, + unsigned depth, + TSStateId goal_state +) { + StackSliceArray pop = ts_stack_pop_count(self->stack, version, depth); + StackVersion previous_version = STACK_VERSION_NONE; + + for (unsigned i = 0; i < pop.size; i++) { + StackSlice slice = *array_get(&pop, i); + + if (slice.version == previous_version) { + ts_subtree_array_delete(&self->tree_pool, &slice.subtrees); + array_erase(&pop, i--); + continue; + } + + if (ts_stack_state(self->stack, slice.version) != goal_state) { + ts_stack_halt(self->stack, slice.version); + ts_subtree_array_delete(&self->tree_pool, &slice.subtrees); + array_erase(&pop, i--); + continue; + } + + SubtreeArray error_trees = ts_stack_pop_error(self->stack, slice.version); + if (error_trees.size > 0) { + ts_assert(error_trees.size == 1); + Subtree error_tree = *array_get(&error_trees, 0); + uint32_t error_child_count = ts_subtree_child_count(error_tree); + if (error_child_count > 0) { + array_splice(&slice.subtrees, 0, 0, error_child_count, ts_subtree_children(error_tree)); + for (unsigned j = 0; j < error_child_count; j++) { + ts_subtree_retain(*array_get(&slice.subtrees, j)); + } + } + ts_subtree_array_delete(&self->tree_pool, &error_trees); + } + + ts_subtree_array_remove_trailing_extras(&slice.subtrees, &self->trailing_extras); + + if (slice.subtrees.size > 0) { + Subtree error = ts_subtree_new_error_node(&slice.subtrees, true, self->language); + ts_stack_push(self->stack, slice.version, error, false, goal_state); + } else { + array_delete(&slice.subtrees); + } + + for (unsigned j = 0; j < self->trailing_extras.size; j++) { + Subtree tree = *array_get(&self->trailing_extras, j); + ts_stack_push(self->stack, slice.version, tree, false, goal_state); + } + + previous_version = slice.version; + } + + return previous_version != STACK_VERSION_NONE; +} + +static void ts_parser__recover( + TSParser *self, + StackVersion version, + Subtree lookahead +) { + bool did_recover = false; + unsigned previous_version_count = ts_stack_version_count(self->stack); + Length position = ts_stack_position(self->stack, version); + StackSummary *summary = ts_stack_get_summary(self->stack, version); + unsigned node_count_since_error = ts_stack_node_count_since_error(self->stack, version); + unsigned current_error_cost = ts_stack_error_cost(self->stack, version); + + // When the parser is in the error state, there are two strategies for recovering with a + // given lookahead token: + // 1. Find a previous state on the stack in which that lookahead token would be valid. Then, + // create a new stack version that is in that state again. This entails popping all of the + // subtrees that have been pushed onto the stack since that previous state, and wrapping + // them in an ERROR node. + // 2. Wrap the lookahead token in an ERROR node, push that ERROR node onto the stack, and + // move on to the next lookahead token, remaining in the error state. + // + // First, try the strategy 1. Upon entering the error state, the parser recorded a summary + // of the previous parse states and their depths. Look at each state in the summary, to see + // if the current lookahead token would be valid in that state. + if (summary && !ts_subtree_is_error(lookahead)) { + for (unsigned i = 0; i < summary->size; i++) { + StackSummaryEntry entry = *array_get(summary, i); + + if (entry.state == ERROR_STATE) continue; + if (entry.position.bytes == position.bytes) continue; + unsigned depth = entry.depth; + if (node_count_since_error > 0) depth++; + + // Do not recover in ways that create redundant stack versions. + bool would_merge = false; + for (unsigned j = 0; j < previous_version_count; j++) { + if ( + ts_stack_state(self->stack, j) == entry.state && + ts_stack_position(self->stack, j).bytes == position.bytes + ) { + would_merge = true; + break; + } + } + if (would_merge) continue; + + // Do not recover if the result would clearly be worse than some existing stack version. + unsigned new_cost = + current_error_cost + + entry.depth * ERROR_COST_PER_SKIPPED_TREE + + (position.bytes - entry.position.bytes) * ERROR_COST_PER_SKIPPED_CHAR + + (position.extent.row - entry.position.extent.row) * ERROR_COST_PER_SKIPPED_LINE; + if (ts_parser__better_version_exists(self, version, false, new_cost)) break; + + // If the current lookahead token is valid in some previous state, recover to that state. + // Then stop looking for further recoveries. + if (ts_language_has_actions(self->language, entry.state, ts_subtree_symbol(lookahead))) { + if (ts_parser__recover_to_state(self, version, depth, entry.state)) { + did_recover = true; + LOG("recover_to_previous state:%u, depth:%u", entry.state, depth); + LOG_STACK(); + break; + } + } + } + } + + // In the process of attempting to recover, some stack versions may have been created + // and subsequently halted. Remove those versions. + for (unsigned i = previous_version_count; i < ts_stack_version_count(self->stack); i++) { + if (!ts_stack_is_active(self->stack, i)) { + LOG("removed paused version:%u", i); + ts_stack_remove_version(self->stack, i--); + LOG_STACK(); + } + } + + // If the parser is still in the error state at the end of the file, just wrap everything + // in an ERROR node and terminate. + if (ts_subtree_is_eof(lookahead)) { + LOG("recover_eof"); + SubtreeArray children = array_new(); + Subtree parent = ts_subtree_new_error_node(&children, false, self->language); + ts_stack_push(self->stack, version, parent, false, 1); + ts_parser__accept(self, version, lookahead); + return; + } + + // If strategy 1 succeeded, a new stack version will have been created which is able to handle + // the current lookahead token. Now, in addition, try strategy 2 described above: skip the + // current lookahead token by wrapping it in an ERROR node. + + // Don't pursue this additional strategy if there are already too many stack versions. + if (did_recover && ts_stack_version_count(self->stack) > MAX_VERSION_COUNT) { + ts_stack_halt(self->stack, version); + ts_subtree_release(&self->tree_pool, lookahead); + return; + } + + if ( + did_recover && + ts_subtree_has_external_scanner_state_change(lookahead) + ) { + ts_stack_halt(self->stack, version); + ts_subtree_release(&self->tree_pool, lookahead); + return; + } + + // Do not recover if the result would clearly be worse than some existing stack version. + unsigned new_cost = + current_error_cost + ERROR_COST_PER_SKIPPED_TREE + + ts_subtree_total_bytes(lookahead) * ERROR_COST_PER_SKIPPED_CHAR + + ts_subtree_total_size(lookahead).extent.row * ERROR_COST_PER_SKIPPED_LINE; + if (ts_parser__better_version_exists(self, version, false, new_cost)) { + ts_stack_halt(self->stack, version); + ts_subtree_release(&self->tree_pool, lookahead); + return; + } + + // If the current lookahead token is an extra token, mark it as extra. This means it won't + // be counted in error cost calculations. + unsigned n; + const TSParseAction *actions = ts_language_actions(self->language, 1, ts_subtree_symbol(lookahead), &n); + if (n > 0 && actions[n - 1].type == TSParseActionTypeShift && actions[n - 1].shift.extra) { + MutableSubtree mutable_lookahead = ts_subtree_make_mut(&self->tree_pool, lookahead); + ts_subtree_set_extra(&mutable_lookahead, true); + lookahead = ts_subtree_from_mut(mutable_lookahead); + } + + // Wrap the lookahead token in an ERROR. + LOG("skip_token symbol:%s", TREE_NAME(lookahead)); + SubtreeArray children = array_new(); + array_reserve(&children, 1); + array_push(&children, lookahead); + MutableSubtree error_repeat = ts_subtree_new_node( + ts_builtin_sym_error_repeat, + &children, + 0, + self->language + ); + + // If other tokens have already been skipped, so there is already an ERROR at the top of the + // stack, then pop that ERROR off the stack and wrap the two ERRORs together into one larger + // ERROR. + if (node_count_since_error > 0) { + StackSliceArray pop = ts_stack_pop_count(self->stack, version, 1); + + // TODO: Figure out how to make this condition occur. + // See https://github.com/atom/atom/issues/18450#issuecomment-439579778 + // If multiple stack versions have merged at this point, just pick one of the errors + // arbitrarily and discard the rest. + if (pop.size > 1) { + for (unsigned i = 1; i < pop.size; i++) { + ts_subtree_array_delete(&self->tree_pool, &array_get(&pop, i)->subtrees); + } + while (ts_stack_version_count(self->stack) > array_get(&pop, 0)->version + 1) { + ts_stack_remove_version(self->stack, array_get(&pop, 0)->version + 1); + } + } + + ts_stack_renumber_version(self->stack, array_get(&pop, 0)->version, version); + array_push(&array_get(&pop, 0)->subtrees, ts_subtree_from_mut(error_repeat)); + error_repeat = ts_subtree_new_node( + ts_builtin_sym_error_repeat, + &array_get(&pop, 0)->subtrees, + 0, + self->language + ); + } + + // Push the new ERROR onto the stack. + ts_stack_push(self->stack, version, ts_subtree_from_mut(error_repeat), false, ERROR_STATE); + if (ts_subtree_has_external_tokens(lookahead)) { + ts_stack_set_last_external_token( + self->stack, version, ts_subtree_last_external_token(lookahead) + ); + } + + bool has_error = true; + for (unsigned i = 0; i < ts_stack_version_count(self->stack); i++) { + ErrorStatus status = ts_parser__version_status(self, i); + if (!status.is_in_error) { + has_error = false; + break; + } + } + self->has_error = has_error; +} + +static void ts_parser__handle_error( + TSParser *self, + StackVersion version, + Subtree lookahead +) { + uint32_t previous_version_count = ts_stack_version_count(self->stack); + + // Perform any reductions that can happen in this state, regardless of the lookahead. After + // skipping one or more invalid tokens, the parser might find a token that would have allowed + // a reduction to take place. + ts_parser__do_all_potential_reductions(self, version, 0); + uint32_t version_count = ts_stack_version_count(self->stack); + Length position = ts_stack_position(self->stack, version); + + // Push a discontinuity onto the stack. Merge all of the stack versions that + // were created in the previous step. + bool did_insert_missing_token = false; + for (StackVersion v = version; v < version_count;) { + if (!did_insert_missing_token) { + TSStateId state = ts_stack_state(self->stack, v); + for ( + TSSymbol missing_symbol = 1; + missing_symbol < (uint16_t)self->language->token_count; + missing_symbol++ + ) { + TSStateId state_after_missing_symbol = ts_language_next_state( + self->language, state, missing_symbol + ); + if (state_after_missing_symbol == 0 || state_after_missing_symbol == state) { + continue; + } + + if (ts_language_has_reduce_action( + self->language, + state_after_missing_symbol, + ts_subtree_leaf_symbol(lookahead) + )) { + // In case the parser is currently outside of any included range, the lexer will + // snap to the beginning of the next included range. The missing token's padding + // must be assigned to position it within the next included range. + ts_lexer_reset(&self->lexer, position); + ts_lexer_mark_end(&self->lexer); + Length padding = length_sub(self->lexer.token_end_position, position); + uint32_t lookahead_bytes = ts_subtree_total_bytes(lookahead) + ts_subtree_lookahead_bytes(lookahead); + + StackVersion version_with_missing_tree = ts_stack_copy_version(self->stack, v); + Subtree missing_tree = ts_subtree_new_missing_leaf( + &self->tree_pool, missing_symbol, + padding, lookahead_bytes, + self->language + ); + ts_stack_push( + self->stack, version_with_missing_tree, + missing_tree, false, + state_after_missing_symbol + ); + + if (ts_parser__do_all_potential_reductions( + self, version_with_missing_tree, + ts_subtree_leaf_symbol(lookahead) + )) { + LOG( + "recover_with_missing symbol:%s, state:%u", + SYM_NAME(missing_symbol), + ts_stack_state(self->stack, version_with_missing_tree) + ); + did_insert_missing_token = true; + break; + } + } + } + } + + ts_stack_push(self->stack, v, NULL_SUBTREE, false, ERROR_STATE); + v = (v == version) ? previous_version_count : v + 1; + } + + for (unsigned i = previous_version_count; i < version_count; i++) { + bool did_merge = ts_stack_merge(self->stack, version, previous_version_count); + ts_assert(did_merge); + } + + ts_stack_record_summary(self->stack, version, MAX_SUMMARY_DEPTH); + + // Begin recovery with the current lookahead node, rather than waiting for the + // next turn of the parse loop. This ensures that the tree accounts for the + // current lookahead token's "lookahead bytes" value, which describes how far + // the lexer needed to look ahead beyond the content of the token in order to + // recognize it. + if (ts_subtree_child_count(lookahead) > 0) { + ts_parser__breakdown_lookahead(self, &lookahead, ERROR_STATE, &self->reusable_node); + } + ts_parser__recover(self, version, lookahead); + + LOG_STACK(); +} + +static bool ts_parser__check_progress(TSParser *self, Subtree *lookahead, const uint32_t *position, unsigned operations) { + self->operation_count += operations; + if (self->operation_count >= OP_COUNT_PER_PARSER_TIMEOUT_CHECK) { + self->operation_count = 0; + } + if (position != NULL) { + self->parse_state.current_byte_offset = *position; + self->parse_state.has_error = self->has_error; + } + if ( + self->operation_count == 0 && + ( + // TODO(amaanq): remove cancellation flag & clock checks before 0.26 + (self->cancellation_flag && atomic_load(self->cancellation_flag)) || + (!clock_is_null(self->end_clock) && clock_is_gt(clock_now(), self->end_clock)) || + (self->parse_options.progress_callback && self->parse_options.progress_callback(&self->parse_state)) + ) + ) { + if (lookahead && lookahead->ptr) { + ts_subtree_release(&self->tree_pool, *lookahead); + } + return false; + } + return true; +} + +static bool ts_parser__advance( + TSParser *self, + StackVersion version, + bool allow_node_reuse +) { + TSStateId state = ts_stack_state(self->stack, version); + uint32_t position = ts_stack_position(self->stack, version).bytes; + Subtree last_external_token = ts_stack_last_external_token(self->stack, version); + + bool did_reuse = true; + Subtree lookahead = NULL_SUBTREE; + TableEntry table_entry = {.action_count = 0}; + + // If possible, reuse a node from the previous syntax tree. + if (allow_node_reuse) { + lookahead = ts_parser__reuse_node( + self, version, &state, position, last_external_token, &table_entry + ); + } + + // If no node from the previous syntax tree could be reused, then try to + // reuse the token previously returned by the lexer. + if (!lookahead.ptr) { + did_reuse = false; + lookahead = ts_parser__get_cached_token( + self, state, position, last_external_token, &table_entry + ); + } + + bool needs_lex = !lookahead.ptr; + for (;;) { + // Otherwise, re-run the lexer. + if (needs_lex) { + needs_lex = false; + lookahead = ts_parser__lex(self, version, state); + if (self->has_scanner_error) return false; + + if (lookahead.ptr) { + ts_parser__set_cached_token(self, position, last_external_token, lookahead); + ts_language_table_entry(self->language, state, ts_subtree_symbol(lookahead), &table_entry); + } + + // When parsing a non-terminal extra, a null lookahead indicates the + // end of the rule. The reduction is stored in the EOF table entry. + // After the reduction, the lexer needs to be run again. + else { + ts_language_table_entry(self->language, state, ts_builtin_sym_end, &table_entry); + } + } + + // If a cancellation flag, timeout, or progress callback was provided, then check every + // time a fixed number of parse actions has been processed. + if (!ts_parser__check_progress(self, &lookahead, &position, 1)) { + return false; + } + + // Process each parse action for the current lookahead token in + // the current state. If there are multiple actions, then this is + // an ambiguous state. REDUCE actions always create a new stack + // version, whereas SHIFT actions update the existing stack version + // and terminate this loop. + bool did_reduce = false; + StackVersion last_reduction_version = STACK_VERSION_NONE; + for (uint32_t i = 0; i < table_entry.action_count; i++) { + TSParseAction action = table_entry.actions[i]; + + switch (action.type) { + case TSParseActionTypeShift: { + if (action.shift.repetition) break; + TSStateId next_state; + if (action.shift.extra) { + next_state = state; + LOG("shift_extra"); + } else { + next_state = action.shift.state; + LOG("shift state:%u", next_state); + } + + if (ts_subtree_child_count(lookahead) > 0) { + ts_parser__breakdown_lookahead(self, &lookahead, state, &self->reusable_node); + next_state = ts_language_next_state(self->language, state, ts_subtree_symbol(lookahead)); + } + + ts_parser__shift(self, version, next_state, lookahead, action.shift.extra); + if (did_reuse) reusable_node_advance(&self->reusable_node); + return true; + } + + case TSParseActionTypeReduce: { + bool is_fragile = table_entry.action_count > 1; + bool end_of_non_terminal_extra = lookahead.ptr == NULL; + LOG("reduce sym:%s, child_count:%u", SYM_NAME(action.reduce.symbol), action.reduce.child_count); + StackVersion reduction_version = ts_parser__reduce( + self, version, action.reduce.symbol, action.reduce.child_count, + action.reduce.dynamic_precedence, action.reduce.production_id, + is_fragile, end_of_non_terminal_extra + ); + did_reduce = true; + if (reduction_version != STACK_VERSION_NONE) { + last_reduction_version = reduction_version; + } + break; + } + + case TSParseActionTypeAccept: { + LOG("accept"); + ts_parser__accept(self, version, lookahead); + return true; + } + + case TSParseActionTypeRecover: { + if (ts_subtree_child_count(lookahead) > 0) { + ts_parser__breakdown_lookahead(self, &lookahead, ERROR_STATE, &self->reusable_node); + } + + ts_parser__recover(self, version, lookahead); + if (did_reuse) reusable_node_advance(&self->reusable_node); + return true; + } + } + } + + // If a reduction was performed, then replace the current stack version + // with one of the stack versions created by a reduction, and continue + // processing this version of the stack with the same lookahead symbol. + if (last_reduction_version != STACK_VERSION_NONE) { + ts_stack_renumber_version(self->stack, last_reduction_version, version); + LOG_STACK(); + state = ts_stack_state(self->stack, version); + + // At the end of a non-terminal extra rule, the lexer will return a + // null subtree, because the parser needs to perform a fixed reduction + // regardless of the lookahead node. After performing that reduction, + // (and completing the non-terminal extra rule) run the lexer again based + // on the current parse state. + if (!lookahead.ptr) { + needs_lex = true; + } else { + ts_language_table_entry( + self->language, + state, + ts_subtree_leaf_symbol(lookahead), + &table_entry + ); + } + + continue; + } + + // A reduction was performed, but was merged into an existing stack version. + // This version can be discarded. + if (did_reduce) { + if (lookahead.ptr) { + ts_subtree_release(&self->tree_pool, lookahead); + } + ts_stack_halt(self->stack, version); + return true; + } + + // If the current lookahead token is a keyword that is not valid, but the + // default word token *is* valid, then treat the lookahead token as the word + // token instead. + if ( + ts_subtree_is_keyword(lookahead) && + ts_subtree_symbol(lookahead) != self->language->keyword_capture_token && + !ts_language_is_reserved_word(self->language, state, ts_subtree_symbol(lookahead)) + ) { + ts_language_table_entry( + self->language, + state, + self->language->keyword_capture_token, + &table_entry + ); + if (table_entry.action_count > 0) { + LOG( + "switch from_keyword:%s, to_word_token:%s", + TREE_NAME(lookahead), + SYM_NAME(self->language->keyword_capture_token) + ); + + MutableSubtree mutable_lookahead = ts_subtree_make_mut(&self->tree_pool, lookahead); + ts_subtree_set_symbol(&mutable_lookahead, self->language->keyword_capture_token, self->language); + lookahead = ts_subtree_from_mut(mutable_lookahead); + continue; + } + } + + // If the current lookahead token is not valid and the previous subtree on + // the stack was reused from an old tree, then it wasn't actually valid to + // reuse that previous subtree. Remove it from the stack, and in its place, + // push each of its children. Then try again to process the current lookahead. + if (ts_parser__breakdown_top_of_stack(self, version)) { + state = ts_stack_state(self->stack, version); + ts_subtree_release(&self->tree_pool, lookahead); + needs_lex = true; + continue; + } + + // Otherwise, there is definitely an error in this version of the parse stack. + // Mark this version as paused and continue processing any other stack + // versions that exist. If some other version advances successfully, then + // this version can simply be removed. But if all versions end up paused, + // then error recovery is needed. + LOG("detect_error lookahead:%s", TREE_NAME(lookahead)); + ts_stack_pause(self->stack, version, lookahead); + return true; + } +} + +static unsigned ts_parser__condense_stack(TSParser *self) { + bool made_changes = false; + unsigned min_error_cost = UINT_MAX; + for (StackVersion i = 0; i < ts_stack_version_count(self->stack); i++) { + // Prune any versions that have been marked for removal. + if (ts_stack_is_halted(self->stack, i)) { + ts_stack_remove_version(self->stack, i); + i--; + continue; + } + + // Keep track of the minimum error cost of any stack version so + // that it can be returned. + ErrorStatus status_i = ts_parser__version_status(self, i); + if (!status_i.is_in_error && status_i.cost < min_error_cost) { + min_error_cost = status_i.cost; + } + + // Examine each pair of stack versions, removing any versions that + // are clearly worse than another version. Ensure that the versions + // are ordered from most promising to least promising. + for (StackVersion j = 0; j < i; j++) { + ErrorStatus status_j = ts_parser__version_status(self, j); + + switch (ts_parser__compare_versions(self, status_j, status_i)) { + case ErrorComparisonTakeLeft: + made_changes = true; + ts_stack_remove_version(self->stack, i); + i--; + j = i; + break; + + case ErrorComparisonPreferLeft: + case ErrorComparisonNone: + if (ts_stack_merge(self->stack, j, i)) { + made_changes = true; + i--; + j = i; + } + break; + + case ErrorComparisonPreferRight: + made_changes = true; + if (ts_stack_merge(self->stack, j, i)) { + i--; + j = i; + } else { + ts_stack_swap_versions(self->stack, i, j); + } + break; + + case ErrorComparisonTakeRight: + made_changes = true; + ts_stack_remove_version(self->stack, j); + i--; + j--; + break; + } + } + } + + // Enforce a hard upper bound on the number of stack versions by + // discarding the least promising versions. + while (ts_stack_version_count(self->stack) > MAX_VERSION_COUNT) { + ts_stack_remove_version(self->stack, MAX_VERSION_COUNT); + made_changes = true; + } + + // If the best-performing stack version is currently paused, or all + // versions are paused, then resume the best paused version and begin + // the error recovery process. Otherwise, remove the paused versions. + if (ts_stack_version_count(self->stack) > 0) { + bool has_unpaused_version = false; + for (StackVersion i = 0, n = ts_stack_version_count(self->stack); i < n; i++) { + if (ts_stack_is_paused(self->stack, i)) { + if (!has_unpaused_version && self->accept_count < MAX_VERSION_COUNT) { + LOG("resume version:%u", i); + min_error_cost = ts_stack_error_cost(self->stack, i); + Subtree lookahead = ts_stack_resume(self->stack, i); + ts_parser__handle_error(self, i, lookahead); + has_unpaused_version = true; + } else { + ts_stack_remove_version(self->stack, i); + made_changes = true; + i--; + n--; + } + } else { + has_unpaused_version = true; + } + } + } + + if (made_changes) { + LOG("condense"); + LOG_STACK(); + } + + return min_error_cost; +} + +static bool ts_parser__balance_subtree(TSParser *self) { + Subtree finished_tree = self->finished_tree; + + // If we haven't canceled balancing in progress before, then we want to clear the tree stack and + // push the initial finished tree onto it. Otherwise, if we're resuming balancing after a + // cancellation, we don't want to clear the tree stack. + if (!self->canceled_balancing) { + array_clear(&self->tree_pool.tree_stack); + if (ts_subtree_child_count(finished_tree) > 0 && finished_tree.ptr->ref_count == 1) { + array_push(&self->tree_pool.tree_stack, ts_subtree_to_mut_unsafe(finished_tree)); + } + } + + while (self->tree_pool.tree_stack.size > 0) { + if (!ts_parser__check_progress(self, NULL, NULL, 1)) { + return false; + } + + MutableSubtree tree = *array_get(&self->tree_pool.tree_stack, + self->tree_pool.tree_stack.size - 1 + ); + + if (tree.ptr->repeat_depth > 0) { + Subtree child1 = ts_subtree_children(tree)[0]; + Subtree child2 = ts_subtree_children(tree)[tree.ptr->child_count - 1]; + long repeat_delta = (long)ts_subtree_repeat_depth(child1) - (long)ts_subtree_repeat_depth(child2); + if (repeat_delta > 0) { + unsigned n = (unsigned)repeat_delta; + + for (unsigned i = n / 2; i > 0; i /= 2) { + ts_subtree_compress(tree, i, self->language, &self->tree_pool.tree_stack); + n -= i; + + // We scale the operation count increment in `ts_parser__check_progress` proportionately to the compression + // size since larger values of i take longer to process. Shifting by 4 empirically provides good check + // intervals (e.g. 193 operations when i=3100) to prevent blocking during large compressions. + uint8_t operations = i >> 4 > 0 ? i >> 4 : 1; + if (!ts_parser__check_progress(self, NULL, NULL, operations)) { + return false; + } + } + } + } + + (void)array_pop(&self->tree_pool.tree_stack); + + for (uint32_t i = 0; i < tree.ptr->child_count; i++) { + Subtree child = ts_subtree_children(tree)[i]; + if (ts_subtree_child_count(child) > 0 && child.ptr->ref_count == 1) { + array_push(&self->tree_pool.tree_stack, ts_subtree_to_mut_unsafe(child)); + } + } + } + + return true; +} + +static bool ts_parser_has_outstanding_parse(TSParser *self) { + return ( + self->canceled_balancing || + self->external_scanner_payload || + ts_stack_state(self->stack, 0) != 1 || + ts_stack_node_count_since_error(self->stack, 0) != 0 + ); +} + +// Parser - Public + +TSParser *ts_parser_new(void) { + TSParser *self = ts_calloc(1, sizeof(TSParser)); + ts_lexer_init(&self->lexer); + array_init(&self->reduce_actions); + array_reserve(&self->reduce_actions, 4); + self->tree_pool = ts_subtree_pool_new(32); + self->stack = ts_stack_new(&self->tree_pool); + self->finished_tree = NULL_SUBTREE; + self->reusable_node = reusable_node_new(); + self->dot_graph_file = NULL; + self->cancellation_flag = NULL; + self->timeout_duration = 0; + self->language = NULL; + self->has_scanner_error = false; + self->has_error = false; + self->canceled_balancing = false; + self->external_scanner_payload = NULL; + self->end_clock = clock_null(); + self->operation_count = 0; + self->old_tree = NULL_SUBTREE; + self->included_range_differences = (TSRangeArray) array_new(); + self->included_range_difference_index = 0; + ts_parser__set_cached_token(self, 0, NULL_SUBTREE, NULL_SUBTREE); + return self; +} + +void ts_parser_delete(TSParser *self) { + if (!self) return; + + ts_parser_set_language(self, NULL); + ts_stack_delete(self->stack); + if (self->reduce_actions.contents) { + array_delete(&self->reduce_actions); + } + if (self->included_range_differences.contents) { + array_delete(&self->included_range_differences); + } + if (self->old_tree.ptr) { + ts_subtree_release(&self->tree_pool, self->old_tree); + self->old_tree = NULL_SUBTREE; + } + ts_wasm_store_delete(self->wasm_store); + ts_lexer_delete(&self->lexer); + ts_parser__set_cached_token(self, 0, NULL_SUBTREE, NULL_SUBTREE); + ts_subtree_pool_delete(&self->tree_pool); + reusable_node_delete(&self->reusable_node); + array_delete(&self->trailing_extras); + array_delete(&self->trailing_extras2); + array_delete(&self->scratch_trees); + ts_free(self); +} + +const TSLanguage *ts_parser_language(const TSParser *self) { + return self->language; +} + +bool ts_parser_set_language(TSParser *self, const TSLanguage *language) { + ts_parser_reset(self); + ts_language_delete(self->language); + self->language = NULL; + + if (language) { + if ( + language->abi_version > TREE_SITTER_LANGUAGE_VERSION || + language->abi_version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION + ) return false; + + if (ts_language_is_wasm(language)) { + if ( + !self->wasm_store || + !ts_wasm_store_start(self->wasm_store, &self->lexer.data, language) + ) return false; + } + } + + self->language = ts_language_copy(language); + return true; +} + +TSLogger ts_parser_logger(const TSParser *self) { + return self->lexer.logger; +} + +void ts_parser_set_logger(TSParser *self, TSLogger logger) { + self->lexer.logger = logger; +} + +void ts_parser_print_dot_graphs(TSParser *self, int fd) { + if (self->dot_graph_file) { + fclose(self->dot_graph_file); + } + + if (fd >= 0) { + #ifdef _WIN32 + self->dot_graph_file = _fdopen(fd, "a"); + #else + self->dot_graph_file = fdopen(fd, "a"); + #endif + } else { + self->dot_graph_file = NULL; + } +} + +const size_t *ts_parser_cancellation_flag(const TSParser *self) { + return (const size_t *)self->cancellation_flag; +} + +void ts_parser_set_cancellation_flag(TSParser *self, const size_t *flag) { + self->cancellation_flag = (const volatile size_t *)flag; +} + +uint64_t ts_parser_timeout_micros(const TSParser *self) { + return duration_to_micros(self->timeout_duration); +} + +void ts_parser_set_timeout_micros(TSParser *self, uint64_t timeout_micros) { + self->timeout_duration = duration_from_micros(timeout_micros); +} + +bool ts_parser_set_included_ranges( + TSParser *self, + const TSRange *ranges, + uint32_t count +) { + return ts_lexer_set_included_ranges(&self->lexer, ranges, count); +} + +const TSRange *ts_parser_included_ranges(const TSParser *self, uint32_t *count) { + return ts_lexer_included_ranges(&self->lexer, count); +} + +void ts_parser_reset(TSParser *self) { + ts_parser__external_scanner_destroy(self); + if (self->wasm_store) { + ts_wasm_store_reset(self->wasm_store); + } + + if (self->old_tree.ptr) { + ts_subtree_release(&self->tree_pool, self->old_tree); + self->old_tree = NULL_SUBTREE; + } + + reusable_node_clear(&self->reusable_node); + ts_lexer_reset(&self->lexer, length_zero()); + ts_stack_clear(self->stack); + ts_parser__set_cached_token(self, 0, NULL_SUBTREE, NULL_SUBTREE); + if (self->finished_tree.ptr) { + ts_subtree_release(&self->tree_pool, self->finished_tree); + self->finished_tree = NULL_SUBTREE; + } + self->accept_count = 0; + self->has_scanner_error = false; + self->has_error = false; + self->canceled_balancing = false; + self->parse_options = (TSParseOptions) {0}; + self->parse_state = (TSParseState) {0}; +} + +TSTree *ts_parser_parse( + TSParser *self, + const TSTree *old_tree, + TSInput input +) { + TSTree *result = NULL; + if (!self->language || !input.read) return NULL; + + if (ts_language_is_wasm(self->language)) { + if (!self->wasm_store) return NULL; + ts_wasm_store_start(self->wasm_store, &self->lexer.data, self->language); + } + + ts_lexer_set_input(&self->lexer, input); + array_clear(&self->included_range_differences); + self->included_range_difference_index = 0; + + self->operation_count = 0; + if (self->timeout_duration) { + self->end_clock = clock_after(clock_now(), self->timeout_duration); + } else { + self->end_clock = clock_null(); + } + + if (ts_parser_has_outstanding_parse(self)) { + LOG("resume_parsing"); + if (self->canceled_balancing) goto balance; + } else { + ts_parser__external_scanner_create(self); + if (self->has_scanner_error) goto exit; + + if (old_tree) { + ts_subtree_retain(old_tree->root); + self->old_tree = old_tree->root; + ts_range_array_get_changed_ranges( + old_tree->included_ranges, old_tree->included_range_count, + self->lexer.included_ranges, self->lexer.included_range_count, + &self->included_range_differences + ); + reusable_node_reset(&self->reusable_node, old_tree->root); + LOG("parse_after_edit"); + LOG_TREE(self->old_tree); + for (unsigned i = 0; i < self->included_range_differences.size; i++) { + TSRange *range = array_get(&self->included_range_differences, i); + LOG("different_included_range %u - %u", range->start_byte, range->end_byte); + } + } else { + reusable_node_clear(&self->reusable_node); + LOG("new_parse"); + } + } + + uint32_t position = 0, last_position = 0, version_count = 0; + do { + for ( + StackVersion version = 0; + version_count = ts_stack_version_count(self->stack), + version < version_count; + version++ + ) { + bool allow_node_reuse = version_count == 1; + while (ts_stack_is_active(self->stack, version)) { + LOG( + "process version:%u, version_count:%u, state:%d, row:%u, col:%u", + version, + ts_stack_version_count(self->stack), + ts_stack_state(self->stack, version), + ts_stack_position(self->stack, version).extent.row, + ts_stack_position(self->stack, version).extent.column + ); + + if (!ts_parser__advance(self, version, allow_node_reuse)) { + if (self->has_scanner_error) goto exit; + return NULL; + } + + LOG_STACK(); + + position = ts_stack_position(self->stack, version).bytes; + if (position > last_position || (version > 0 && position == last_position)) { + last_position = position; + break; + } + } + } + + // After advancing each version of the stack, re-sort the versions by their cost, + // removing any versions that are no longer worth pursuing. + unsigned min_error_cost = ts_parser__condense_stack(self); + + // If there's already a finished parse tree that's better than any in-progress version, + // then terminate parsing. Clear the parse stack to remove any extra references to subtrees + // within the finished tree, ensuring that these subtrees can be safely mutated in-place + // for rebalancing. + if (self->finished_tree.ptr && ts_subtree_error_cost(self->finished_tree) < min_error_cost) { + ts_stack_clear(self->stack); + break; + } + + while (self->included_range_difference_index < self->included_range_differences.size) { + TSRange *range = array_get(&self->included_range_differences, self->included_range_difference_index); + if (range->end_byte <= position) { + self->included_range_difference_index++; + } else { + break; + } + } + } while (version_count != 0); + +balance: + ts_assert(self->finished_tree.ptr); + if (!ts_parser__balance_subtree(self)) { + self->canceled_balancing = true; + return false; + } + self->canceled_balancing = false; + LOG("done"); + LOG_TREE(self->finished_tree); + + result = ts_tree_new( + self->finished_tree, + self->language, + self->lexer.included_ranges, + self->lexer.included_range_count + ); + self->finished_tree = NULL_SUBTREE; + +exit: + ts_parser_reset(self); + return result; +} + +TSTree *ts_parser_parse_with_options( + TSParser *self, + const TSTree *old_tree, + TSInput input, + TSParseOptions parse_options +) { + self->parse_options = parse_options; + self->parse_state.payload = parse_options.payload; + TSTree *result = ts_parser_parse(self, old_tree, input); + // Reset parser options before further parse calls. + self->parse_options = (TSParseOptions) {0}; + return result; +} + +TSTree *ts_parser_parse_string( + TSParser *self, + const TSTree *old_tree, + const char *string, + uint32_t length +) { + return ts_parser_parse_string_encoding(self, old_tree, string, length, TSInputEncodingUTF8); +} + +TSTree *ts_parser_parse_string_encoding( + TSParser *self, + const TSTree *old_tree, + const char *string, + uint32_t length, + TSInputEncoding encoding +) { + TSStringInput input = {string, length}; + return ts_parser_parse(self, old_tree, (TSInput) { + &input, + ts_string_input_read, + encoding, + NULL, + }); +} + +void ts_parser_set_wasm_store(TSParser *self, TSWasmStore *store) { + if (self->language && ts_language_is_wasm(self->language)) { + // Copy the assigned language into the new store. + const TSLanguage *copy = ts_language_copy(self->language); + ts_parser_set_language(self, copy); + ts_language_delete(copy); + } + + ts_wasm_store_delete(self->wasm_store); + self->wasm_store = store; +} + +TSWasmStore *ts_parser_take_wasm_store(TSParser *self) { + if (self->language && ts_language_is_wasm(self->language)) { + ts_parser_set_language(self, NULL); + } + + TSWasmStore *result = self->wasm_store; + self->wasm_store = NULL; + return result; +} + +#undef LOG diff --git a/vendor/tree-sitter/src/parser.h b/vendor/tree-sitter/src/parser.h new file mode 100644 index 0000000..858107d --- /dev/null +++ b/vendor/tree-sitter/src/parser.h @@ -0,0 +1,286 @@ +#ifndef TREE_SITTER_PARSER_H_ +#define TREE_SITTER_PARSER_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#define ts_builtin_sym_error ((TSSymbol)-1) +#define ts_builtin_sym_end 0 +#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024 + +#ifndef TREE_SITTER_API_H_ +typedef uint16_t TSStateId; +typedef uint16_t TSSymbol; +typedef uint16_t TSFieldId; +typedef struct TSLanguage TSLanguage; +typedef struct TSLanguageMetadata { + uint8_t major_version; + uint8_t minor_version; + uint8_t patch_version; +} TSLanguageMetadata; +#endif + +typedef struct { + TSFieldId field_id; + uint8_t child_index; + bool inherited; +} TSFieldMapEntry; + +// Used to index the field and supertype maps. +typedef struct { + uint16_t index; + uint16_t length; +} TSMapSlice; + +typedef struct { + bool visible; + bool named; + bool supertype; +} TSSymbolMetadata; + +typedef struct TSLexer TSLexer; + +struct TSLexer { + int32_t lookahead; + TSSymbol result_symbol; + void (*advance)(TSLexer *, bool); + void (*mark_end)(TSLexer *); + uint32_t (*get_column)(TSLexer *); + bool (*is_at_included_range_start)(const TSLexer *); + bool (*eof)(const TSLexer *); + void (*log)(const TSLexer *, const char *, ...); +}; + +typedef enum { + TSParseActionTypeShift, + TSParseActionTypeReduce, + TSParseActionTypeAccept, + TSParseActionTypeRecover, +} TSParseActionType; + +typedef union { + struct { + uint8_t type; + TSStateId state; + bool extra; + bool repetition; + } shift; + struct { + uint8_t type; + uint8_t child_count; + TSSymbol symbol; + int16_t dynamic_precedence; + uint16_t production_id; + } reduce; + uint8_t type; +} TSParseAction; + +typedef struct { + uint16_t lex_state; + uint16_t external_lex_state; +} TSLexMode; + +typedef struct { + uint16_t lex_state; + uint16_t external_lex_state; + uint16_t reserved_word_set_id; +} TSLexerMode; + +typedef union { + TSParseAction action; + struct { + uint8_t count; + bool reusable; + } entry; +} TSParseActionEntry; + +typedef struct { + int32_t start; + int32_t end; +} TSCharacterRange; + +struct TSLanguage { + uint32_t abi_version; + uint32_t symbol_count; + uint32_t alias_count; + uint32_t token_count; + uint32_t external_token_count; + uint32_t state_count; + uint32_t large_state_count; + uint32_t production_id_count; + uint32_t field_count; + uint16_t max_alias_sequence_length; + const uint16_t *parse_table; + const uint16_t *small_parse_table; + const uint32_t *small_parse_table_map; + const TSParseActionEntry *parse_actions; + const char * const *symbol_names; + const char * const *field_names; + const TSMapSlice *field_map_slices; + const TSFieldMapEntry *field_map_entries; + const TSSymbolMetadata *symbol_metadata; + const TSSymbol *public_symbol_map; + const uint16_t *alias_map; + const TSSymbol *alias_sequences; + const TSLexerMode *lex_modes; + bool (*lex_fn)(TSLexer *, TSStateId); + bool (*keyword_lex_fn)(TSLexer *, TSStateId); + TSSymbol keyword_capture_token; + struct { + const bool *states; + const TSSymbol *symbol_map; + void *(*create)(void); + void (*destroy)(void *); + bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist); + unsigned (*serialize)(void *, char *); + void (*deserialize)(void *, const char *, unsigned); + } external_scanner; + const TSStateId *primary_state_ids; + const char *name; + const TSSymbol *reserved_words; + uint16_t max_reserved_word_set_size; + uint32_t supertype_count; + const TSSymbol *supertype_symbols; + const TSMapSlice *supertype_map_slices; + const TSSymbol *supertype_map_entries; + TSLanguageMetadata metadata; +}; + +static inline bool set_contains(const TSCharacterRange *ranges, uint32_t len, int32_t lookahead) { + uint32_t index = 0; + uint32_t size = len - index; + while (size > 1) { + uint32_t half_size = size / 2; + uint32_t mid_index = index + half_size; + const TSCharacterRange *range = &ranges[mid_index]; + if (lookahead >= range->start && lookahead <= range->end) { + return true; + } else if (lookahead > range->end) { + index = mid_index; + } + size -= half_size; + } + const TSCharacterRange *range = &ranges[index]; + return (lookahead >= range->start && lookahead <= range->end); +} + +/* + * Lexer Macros + */ + +#ifdef _MSC_VER +#define UNUSED __pragma(warning(suppress : 4101)) +#else +#define UNUSED __attribute__((unused)) +#endif + +#define START_LEXER() \ + bool result = false; \ + bool skip = false; \ + UNUSED \ + bool eof = false; \ + int32_t lookahead; \ + goto start; \ + next_state: \ + lexer->advance(lexer, skip); \ + start: \ + skip = false; \ + lookahead = lexer->lookahead; + +#define ADVANCE(state_value) \ + { \ + state = state_value; \ + goto next_state; \ + } + +#define ADVANCE_MAP(...) \ + { \ + static const uint16_t map[] = { __VA_ARGS__ }; \ + for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \ + if (map[i] == lookahead) { \ + state = map[i + 1]; \ + goto next_state; \ + } \ + } \ + } + +#define SKIP(state_value) \ + { \ + skip = true; \ + state = state_value; \ + goto next_state; \ + } + +#define ACCEPT_TOKEN(symbol_value) \ + result = true; \ + lexer->result_symbol = symbol_value; \ + lexer->mark_end(lexer); + +#define END_STATE() return result; + +/* + * Parse Table Macros + */ + +#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT) + +#define STATE(id) id + +#define ACTIONS(id) id + +#define SHIFT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value) \ + } \ + }} + +#define SHIFT_REPEAT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value), \ + .repetition = true \ + } \ + }} + +#define SHIFT_EXTRA() \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .extra = true \ + } \ + }} + +#define REDUCE(symbol_name, children, precedence, prod_id) \ + {{ \ + .reduce = { \ + .type = TSParseActionTypeReduce, \ + .symbol = symbol_name, \ + .child_count = children, \ + .dynamic_precedence = precedence, \ + .production_id = prod_id \ + }, \ + }} + +#define RECOVER() \ + {{ \ + .type = TSParseActionTypeRecover \ + }} + +#define ACCEPT_INPUT() \ + {{ \ + .type = TSParseActionTypeAccept \ + }} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_PARSER_H_ diff --git a/vendor/tree-sitter/src/point.h b/vendor/tree-sitter/src/point.h new file mode 100644 index 0000000..3958198 --- /dev/null +++ b/vendor/tree-sitter/src/point.h @@ -0,0 +1,48 @@ +#ifndef TREE_SITTER_POINT_H_ +#define TREE_SITTER_POINT_H_ + +#include "tree_sitter/api.h" + +#define POINT_ZERO ((TSPoint) {0, 0}) +#define POINT_MAX ((TSPoint) {UINT32_MAX, UINT32_MAX}) + +static inline TSPoint point__new(unsigned row, unsigned column) { + TSPoint result = {row, column}; + return result; +} + +static inline TSPoint point_add(TSPoint a, TSPoint b) { + if (b.row > 0) + return point__new(a.row + b.row, b.column); + else + return point__new(a.row, a.column + b.column); +} + +static inline TSPoint point_sub(TSPoint a, TSPoint b) { + if (a.row > b.row) + return point__new(a.row - b.row, a.column); + else + return point__new(0, (a.column >= b.column) ? a.column - b.column : 0); +} + +static inline bool point_lte(TSPoint a, TSPoint b) { + return (a.row < b.row) || (a.row == b.row && a.column <= b.column); +} + +static inline bool point_lt(TSPoint a, TSPoint b) { + return (a.row < b.row) || (a.row == b.row && a.column < b.column); +} + +static inline bool point_gt(TSPoint a, TSPoint b) { + return (a.row > b.row) || (a.row == b.row && a.column > b.column); +} + +static inline bool point_gte(TSPoint a, TSPoint b) { + return (a.row > b.row) || (a.row == b.row && a.column >= b.column); +} + +static inline bool point_eq(TSPoint a, TSPoint b) { + return a.row == b.row && a.column == b.column; +} + +#endif diff --git a/vendor/tree-sitter/src/portable/endian.h b/vendor/tree-sitter/src/portable/endian.h new file mode 100644 index 0000000..a656082 --- /dev/null +++ b/vendor/tree-sitter/src/portable/endian.h @@ -0,0 +1,241 @@ +// "License": Public Domain +// I, Mathias Panzenböck, place this file hereby into the public domain. Use it at your own risk for whatever you like. +// In case there are jurisdictions that don't support putting things in the public domain you can also consider it to +// be "dual licensed" under the BSD, MIT and Apache licenses, if you want to. This code is trivial anyway. Consider it +// an example on how to get the endian conversion functions on different platforms. + +// updates from https://github.com/mikepb/endian.h/issues/4 + +#ifndef ENDIAN_H +#define ENDIAN_H + +#if (defined(_WIN16) || defined(_WIN32) || defined(_WIN64)) && !defined(__WINDOWS__) + +# define __WINDOWS__ + +#endif + +#if defined(HAVE_ENDIAN_H) || \ + defined(__linux__) || \ + defined(__GNU__) || \ + defined(__HAIKU__) || \ + defined(__illumos__) || \ + defined(__NetBSD__) || \ + defined(__OpenBSD__) || \ + defined(__CYGWIN__) || \ + defined(__MSYS__) || \ + defined(__EMSCRIPTEN__) || \ + defined(__wasi__) || \ + defined(__wasm__) + +#if defined(__NetBSD__) +#define _NETBSD_SOURCE 1 +#endif + +# include + +#elif defined(HAVE_SYS_ENDIAN_H) || \ + defined(__FreeBSD__) || \ + defined(__DragonFly__) + +# include + +#elif defined(__APPLE__) +# define __BYTE_ORDER BYTE_ORDER +# define __BIG_ENDIAN BIG_ENDIAN +# define __LITTLE_ENDIAN LITTLE_ENDIAN +# define __PDP_ENDIAN PDP_ENDIAN + +# if !defined(_POSIX_C_SOURCE) +# include + +# define htobe16(x) OSSwapHostToBigInt16(x) +# define htole16(x) OSSwapHostToLittleInt16(x) +# define be16toh(x) OSSwapBigToHostInt16(x) +# define le16toh(x) OSSwapLittleToHostInt16(x) + +# define htobe32(x) OSSwapHostToBigInt32(x) +# define htole32(x) OSSwapHostToLittleInt32(x) +# define be32toh(x) OSSwapBigToHostInt32(x) +# define le32toh(x) OSSwapLittleToHostInt32(x) + +# define htobe64(x) OSSwapHostToBigInt64(x) +# define htole64(x) OSSwapHostToLittleInt64(x) +# define be64toh(x) OSSwapBigToHostInt64(x) +# define le64toh(x) OSSwapLittleToHostInt64(x) +# else +# if BYTE_ORDER == LITTLE_ENDIAN +# define htobe16(x) __builtin_bswap16(x) +# define htole16(x) (x) +# define be16toh(x) __builtin_bswap16(x) +# define le16toh(x) (x) + +# define htobe32(x) __builtin_bswap32(x) +# define htole32(x) (x) +# define be32toh(x) __builtin_bswap32(x) +# define le32toh(x) (x) + +# define htobe64(x) __builtin_bswap64(x) +# define htole64(x) (x) +# define be64toh(x) __builtin_bswap64(x) +# define le64toh(x) (x) +# elif BYTE_ORDER == BIG_ENDIAN +# define htobe16(x) (x) +# define htole16(x) __builtin_bswap16(x) +# define be16toh(x) (x) +# define le16toh(x) __builtin_bswap16(x) + +# define htobe32(x) (x) +# define htole32(x) __builtin_bswap32(x) +# define be32toh(x) (x) +# define le32toh(x) __builtin_bswap32(x) + +# define htobe64(x) (x) +# define htole64(x) __builtin_bswap64(x) +# define be64toh(x) (x) +# define le64toh(x) __builtin_bswap64(x) +# else +# error byte order not supported +# endif +# endif + +#elif defined(__WINDOWS__) + +# if defined(_MSC_VER) && !defined(__clang__) +# include +# define B_SWAP_16(x) _byteswap_ushort(x) +# define B_SWAP_32(x) _byteswap_ulong(x) +# define B_SWAP_64(x) _byteswap_uint64(x) +# else +# define B_SWAP_16(x) __builtin_bswap16(x) +# define B_SWAP_32(x) __builtin_bswap32(x) +# define B_SWAP_64(x) __builtin_bswap64(x) +# endif + +# if defined(__MINGW32__) || defined(HAVE_SYS_PARAM_H) +# include +# endif + +# ifndef BIG_ENDIAN +# ifdef __BIG_ENDIAN +# define BIG_ENDIAN __BIG_ENDIAN +# elif defined(__ORDER_BIG_ENDIAN__) +# define BIG_ENDIAN __ORDER_BIG_ENDIAN__ +# else +# define BIG_ENDIAN 4321 +# endif +# endif + +# ifndef LITTLE_ENDIAN +# ifdef __LITTLE_ENDIAN +# define LITTLE_ENDIAN __LITTLE_ENDIAN +# elif defined(__ORDER_LITTLE_ENDIAN__) +# define LITTLE_ENDIAN __ORDER_LITTLE_ENDIAN__ +# else +# define LITTLE_ENDIAN 1234 +# endif +# endif + +# ifndef BYTE_ORDER +# ifdef __BYTE_ORDER +# define BYTE_ORDER __BYTE_ORDER +# elif defined(__BYTE_ORDER__) +# define BYTE_ORDER __BYTE_ORDER__ +# else + /* assume LE on Windows if nothing was defined */ +# define BYTE_ORDER LITTLE_ENDIAN +# endif +# endif + +# if BYTE_ORDER == LITTLE_ENDIAN + +# define htobe16(x) B_SWAP_16(x) +# define htole16(x) (x) +# define be16toh(x) B_SWAP_16(x) +# define le16toh(x) (x) + +# define htobe32(x) B_SWAP_32(x) +# define htole32(x) (x) +# define be32toh(x) B_SWAP_32(x) +# define le32toh(x) (x) + +# define htobe64(x) B_SWAP_64(x) +# define htole64(x) (x) +# define be64toh(x) B_SWAP_64(x) +# define le64toh(x) (x) + +# elif BYTE_ORDER == BIG_ENDIAN + +# define htobe16(x) (x) +# define htole16(x) B_SWAP_16(x) +# define be16toh(x) (x) +# define le16toh(x) B_SWAP_16(x) + +# define htobe32(x) (x) +# define htole32(x) B_SWAP_32(x) +# define be32toh(x) (x) +# define le32toh(x) B_SWAP_32(x) + +# define htobe64(x) (x) +# define htole64(x) B_SWAP_64(x) +# define be64toh(x) (x) +# define le64toh(x) B_SWAP_64(x) + +# else + +# error byte order not supported + +# endif + +#elif defined(__QNXNTO__) + +# include + +# define __LITTLE_ENDIAN 1234 +# define __BIG_ENDIAN 4321 +# define __PDP_ENDIAN 3412 + +# if defined(__BIGENDIAN__) + +# define __BYTE_ORDER __BIG_ENDIAN + +# define htobe16(x) (x) +# define htobe32(x) (x) +# define htobe64(x) (x) + +# define htole16(x) ENDIAN_SWAP16(x) +# define htole32(x) ENDIAN_SWAP32(x) +# define htole64(x) ENDIAN_SWAP64(x) + +# elif defined(__LITTLEENDIAN__) + +# define __BYTE_ORDER __LITTLE_ENDIAN + +# define htole16(x) (x) +# define htole32(x) (x) +# define htole64(x) (x) + +# define htobe16(x) ENDIAN_SWAP16(x) +# define htobe32(x) ENDIAN_SWAP32(x) +# define htobe64(x) ENDIAN_SWAP64(x) + +# else + +# error byte order not supported + +# endif + +# define be16toh(x) ENDIAN_BE16(x) +# define be32toh(x) ENDIAN_BE32(x) +# define be64toh(x) ENDIAN_BE64(x) +# define le16toh(x) ENDIAN_LE16(x) +# define le32toh(x) ENDIAN_LE32(x) +# define le64toh(x) ENDIAN_LE64(x) + +#else + +# error platform not supported + +#endif + +#endif diff --git a/vendor/tree-sitter/src/query.c b/vendor/tree-sitter/src/query.c new file mode 100644 index 0000000..2155c74 --- /dev/null +++ b/vendor/tree-sitter/src/query.c @@ -0,0 +1,4347 @@ +/* + * On NetBSD, defining standard requirements like this removes symbols + * from the namespace; however, we need non-standard symbols for + * endian.h. + */ +#if defined(__NetBSD__) && defined(_POSIX_C_SOURCE) +#undef _POSIX_C_SOURCE +#endif + +#include "tree_sitter/api.h" +#include "./alloc.h" +#include "./array.h" +#include "./clock.h" +#include "./language.h" +#include "./point.h" +#include "./tree_cursor.h" +#include "./unicode.h" +#include + +// #define DEBUG_ANALYZE_QUERY +// #define DEBUG_EXECUTE_QUERY + +#define MAX_STEP_CAPTURE_COUNT 3 +#define MAX_NEGATED_FIELD_COUNT 8 +#define MAX_STATE_PREDECESSOR_COUNT 256 +#define MAX_ANALYSIS_STATE_DEPTH 8 +#define MAX_ANALYSIS_ITERATION_COUNT 256 + +/* + * Stream - A sequence of unicode characters derived from a UTF8 string. + * This struct is used in parsing queries from S-expressions. + */ +typedef struct { + const char *input; + const char *start; + const char *end; + int32_t next; + uint8_t next_size; +} Stream; + +/* + * QueryStep - A step in the process of matching a query. Each node within + * a query S-expression corresponds to one of these steps. An entire pattern + * is represented as a sequence of these steps. The basic properties of a + * node are represented by these fields: + * - `symbol` - The grammar symbol to match. A zero value represents the + * wildcard symbol, '_'. + * - `field` - The field name to match. A zero value means that a field name + * was not specified. + * - `capture_ids` - An array of integers representing the names of captures + * associated with this node in the pattern, terminated by a `NONE` value. + * - `depth` - The depth where this node occurs in the pattern. The root node + * of the pattern has depth zero. + * - `negated_field_list_id` - An id representing a set of fields that must + * not be present on a node matching this step. + * + * Steps have some additional fields in order to handle the `.` (or "anchor") operator, + * which forbids additional child nodes: + * - `is_immediate` - Indicates that the node matching this step cannot be preceded + * by other sibling nodes that weren't specified in the pattern. + * - `is_last_child` - Indicates that the node matching this step cannot have any + * subsequent named siblings. + * + * For simple patterns, steps are matched in sequential order. But in order to + * handle alternative/repeated/optional sub-patterns, query steps are not always + * structured as a linear sequence; they sometimes need to split and merge. This + * is done using the following fields: + * - `alternative_index` - The index of a different query step that serves as + * an alternative to this step. A `NONE` value represents no alternative. + * When a query state reaches a step with an alternative index, the state + * is duplicated, with one copy remaining at the original step, and one copy + * moving to the alternative step. The alternative may have its own alternative + * step, so this splitting is an iterative process. + * - `is_dead_end` - Indicates that this state cannot be passed directly, and + * exists only in order to redirect to an alternative index, with no splitting. + * - `is_pass_through` - Indicates that state has no matching logic of its own, + * and exists only to split a state. One copy of the state advances immediately + * to the next step, and one moves to the alternative step. + * - `alternative_is_immediate` - Indicates that this step's alternative step + * should be treated as if `is_immediate` is true. + * + * Steps also store some derived state that summarizes how they relate to other + * steps within the same pattern. This is used to optimize the matching process: + * - `contains_captures` - Indicates that this step or one of its child steps + * has a non-empty `capture_ids` list. + * - `parent_pattern_guaranteed` - Indicates that if this step is reached, then + * it and all of its subsequent sibling steps within the same parent pattern + * are guaranteed to match. + * - `root_pattern_guaranteed` - Similar to `parent_pattern_guaranteed`, but + * for the entire top-level pattern. When iterating through a query's + * captures using `ts_query_cursor_next_capture`, this field is used to + * detect that a capture can safely be returned from a match that has not + * even completed yet. + */ +typedef struct { + TSSymbol symbol; + TSSymbol supertype_symbol; + TSFieldId field; + uint16_t capture_ids[MAX_STEP_CAPTURE_COUNT]; + uint16_t depth; + uint16_t alternative_index; + uint16_t negated_field_list_id; + bool is_named: 1; + bool is_immediate: 1; + bool is_last_child: 1; + bool is_pass_through: 1; + bool is_dead_end: 1; + bool alternative_is_immediate: 1; + bool contains_captures: 1; + bool root_pattern_guaranteed: 1; + bool parent_pattern_guaranteed: 1; + bool is_missing: 1; +} QueryStep; + +/* + * Slice - A slice of an external array. Within a query, capture names, + * literal string values, and predicate step information are stored in three + * contiguous arrays. Individual captures, string values, and predicates are + * represented as slices of these three arrays. + */ +typedef struct { + uint32_t offset; + uint32_t length; +} Slice; + +/* + * SymbolTable - a two-way mapping of strings to ids. + */ +typedef struct { + Array(char) characters; + Array(Slice) slices; +} SymbolTable; + +/** + * CaptureQuantifiers - a data structure holding the quantifiers of pattern captures. + */ +typedef Array(uint8_t) CaptureQuantifiers; + +/* + * PatternEntry - Information about the starting point for matching a particular + * pattern. These entries are stored in a 'pattern map' - a sorted array that + * makes it possible to efficiently lookup patterns based on the symbol for their + * first step. The entry consists of the following fields: + * - `pattern_index` - the index of the pattern within the query + * - `step_index` - the index of the pattern's first step in the shared `steps` array + * - `is_rooted` - whether or not the pattern has a single root node. This property + * affects decisions about whether or not to start the pattern for nodes outside + * of a QueryCursor's range restriction. + */ +typedef struct { + uint16_t step_index; + uint16_t pattern_index; + bool is_rooted; +} PatternEntry; + +typedef struct { + Slice steps; + Slice predicate_steps; + uint32_t start_byte; + uint32_t end_byte; + bool is_non_local; +} QueryPattern; + +typedef struct { + uint32_t byte_offset; + uint16_t step_index; +} StepOffset; + +/* + * QueryState - The state of an in-progress match of a particular pattern + * in a query. While executing, a `TSQueryCursor` must keep track of a number + * of possible in-progress matches. Each of those possible matches is + * represented as one of these states. Fields: + * - `id` - A numeric id that is exposed to the public API. This allows the + * caller to remove a given match, preventing any more of its captures + * from being returned. + * - `start_depth` - The depth in the tree where the first step of the state's + * pattern was matched. + * - `pattern_index` - The pattern that the state is matching. + * - `consumed_capture_count` - The number of captures from this match that + * have already been returned. + * - `capture_list_id` - A numeric id that can be used to retrieve the state's + * list of captures from the `CaptureListPool`. + * - `seeking_immediate_match` - A flag that indicates that the state's next + * step must be matched by the very next sibling. This is used when + * processing repetitions, or when processing a wildcard node followed by + * an anchor. + * - `has_in_progress_alternatives` - A flag that indicates that there is are + * other states that have the same captures as this state, but are at + * different steps in their pattern. This means that in order to obey the + * 'longest-match' rule, this state should not be returned as a match until + * it is clear that there can be no other alternative match with more captures. + */ +typedef struct { + uint32_t id; + uint32_t capture_list_id; + uint16_t start_depth; + uint16_t step_index; + uint16_t pattern_index; + uint16_t consumed_capture_count: 12; + bool seeking_immediate_match: 1; + bool has_in_progress_alternatives: 1; + bool dead: 1; + bool needs_parent: 1; +} QueryState; + +typedef Array(TSQueryCapture) CaptureList; + +/* + * CaptureListPool - A collection of *lists* of captures. Each query state needs + * to maintain its own list of captures. To avoid repeated allocations, this struct + * maintains a fixed set of capture lists, and keeps track of which ones are + * currently in use by a query state. + */ +typedef struct { + Array(CaptureList) list; + CaptureList empty_list; + // The maximum number of capture lists that we are allowed to allocate. We + // never allow `list` to allocate more entries than this, dropping pending + // matches if needed to stay under the limit. + uint32_t max_capture_list_count; + // The number of capture lists allocated in `list` that are not currently in + // use. We reuse those existing-but-unused capture lists before trying to + // allocate any new ones. We use an invalid value (UINT32_MAX) for a capture + // list's length to indicate that it's not in use. + uint32_t free_capture_list_count; +} CaptureListPool; + +/* + * AnalysisState - The state needed for walking the parse table when analyzing + * a query pattern, to determine at which steps the pattern might fail to match. + */ +typedef struct { + TSStateId parse_state; + TSSymbol parent_symbol; + uint16_t child_index; + TSFieldId field_id: 15; + bool done: 1; +} AnalysisStateEntry; + +typedef struct { + AnalysisStateEntry stack[MAX_ANALYSIS_STATE_DEPTH]; + uint16_t depth; + uint16_t step_index; + TSSymbol root_symbol; +} AnalysisState; + +typedef Array(AnalysisState *) AnalysisStateSet; + +typedef struct { + AnalysisStateSet states; + AnalysisStateSet next_states; + AnalysisStateSet deeper_states; + AnalysisStateSet state_pool; + Array(uint16_t) final_step_indices; + Array(TSSymbol) finished_parent_symbols; + bool did_abort; +} QueryAnalysis; + +/* + * AnalysisSubgraph - A subset of the states in the parse table that are used + * in constructing nodes with a certain symbol. Each state is accompanied by + * some information about the possible node that could be produced in + * downstream states. + */ +typedef struct { + TSStateId state; + uint16_t production_id; + uint8_t child_index: 7; + bool done: 1; +} AnalysisSubgraphNode; + +typedef struct { + TSSymbol symbol; + Array(TSStateId) start_states; + Array(AnalysisSubgraphNode) nodes; +} AnalysisSubgraph; + +typedef Array(AnalysisSubgraph) AnalysisSubgraphArray; + +/* + * StatePredecessorMap - A map that stores the predecessors of each parse state. + * This is used during query analysis to determine which parse states can lead + * to which reduce actions. + */ +typedef struct { + TSStateId *contents; +} StatePredecessorMap; + +/* + * TSQuery - A tree query, compiled from a string of S-expressions. The query + * itself is immutable. The mutable state used in the process of executing the + * query is stored in a `TSQueryCursor`. + */ +struct TSQuery { + SymbolTable captures; + SymbolTable predicate_values; + Array(CaptureQuantifiers) capture_quantifiers; + Array(QueryStep) steps; + Array(PatternEntry) pattern_map; + Array(TSQueryPredicateStep) predicate_steps; + Array(QueryPattern) patterns; + Array(StepOffset) step_offsets; + Array(TSFieldId) negated_fields; + Array(char) string_buffer; + Array(TSSymbol) repeat_symbols_with_rootless_patterns; + const TSLanguage *language; + uint16_t wildcard_root_pattern_count; +}; + +/* + * TSQueryCursor - A stateful struct used to execute a query on a tree. + */ +struct TSQueryCursor { + const TSQuery *query; + TSTreeCursor cursor; + Array(QueryState) states; + Array(QueryState) finished_states; + CaptureListPool capture_list_pool; + uint32_t depth; + uint32_t max_start_depth; + uint32_t start_byte; + uint32_t end_byte; + TSPoint start_point; + TSPoint end_point; + uint32_t next_state_id; + TSClock end_clock; + TSDuration timeout_duration; + const TSQueryCursorOptions *query_options; + TSQueryCursorState query_state; + unsigned operation_count; + bool on_visible_node; + bool ascending; + bool halted; + bool did_exceed_match_limit; +}; + +static const TSQueryError PARENT_DONE = -1; +static const uint16_t PATTERN_DONE_MARKER = UINT16_MAX; +static const uint16_t NONE = UINT16_MAX; +static const TSSymbol WILDCARD_SYMBOL = 0; +static const unsigned OP_COUNT_PER_QUERY_TIMEOUT_CHECK = 100; + +/********** + * Stream + **********/ + +// Advance to the next unicode code point in the stream. +static bool stream_advance(Stream *self) { + self->input += self->next_size; + if (self->input < self->end) { + uint32_t size = ts_decode_utf8( + (const uint8_t *)self->input, + (uint32_t)(self->end - self->input), + &self->next + ); + if (size > 0) { + self->next_size = size; + return true; + } + } else { + self->next_size = 0; + self->next = '\0'; + } + return false; +} + +// Reset the stream to the given input position, represented as a pointer +// into the input string. +static void stream_reset(Stream *self, const char *input) { + self->input = input; + self->next_size = 0; + stream_advance(self); +} + +static Stream stream_new(const char *string, uint32_t length) { + Stream self = { + .next = 0, + .input = string, + .start = string, + .end = string + length, + }; + stream_advance(&self); + return self; +} + +static void stream_skip_whitespace(Stream *self) { + for (;;) { + if (iswspace(self->next)) { + stream_advance(self); + } else if (self->next == ';') { + // skip over comments + stream_advance(self); + while (self->next && self->next != '\n') { + if (!stream_advance(self)) break; + } + } else { + break; + } + } +} + +static bool stream_is_ident_start(Stream *self) { + return iswalnum(self->next) || self->next == '_' || self->next == '-'; +} + +static void stream_scan_identifier(Stream *stream) { + do { + stream_advance(stream); + } while ( + iswalnum(stream->next) || + stream->next == '_' || + stream->next == '-' || + stream->next == '.' || + stream->next == '?' || + stream->next == '!' + ); +} + +static uint32_t stream_offset(Stream *self) { + return (uint32_t)(self->input - self->start); +} + +/****************** + * CaptureListPool + ******************/ + +static CaptureListPool capture_list_pool_new(void) { + return (CaptureListPool) { + .list = array_new(), + .empty_list = array_new(), + .max_capture_list_count = UINT32_MAX, + .free_capture_list_count = 0, + }; +} + +static void capture_list_pool_reset(CaptureListPool *self) { + for (uint16_t i = 0; i < (uint16_t)self->list.size; i++) { + // This invalid size means that the list is not in use. + array_get(&self->list, i)->size = UINT32_MAX; + } + self->free_capture_list_count = self->list.size; +} + +static void capture_list_pool_delete(CaptureListPool *self) { + for (uint16_t i = 0; i < (uint16_t)self->list.size; i++) { + array_delete(array_get(&self->list, i)); + } + array_delete(&self->list); +} + +static const CaptureList *capture_list_pool_get(const CaptureListPool *self, uint16_t id) { + if (id >= self->list.size) return &self->empty_list; + return array_get(&self->list, id); +} + +static CaptureList *capture_list_pool_get_mut(CaptureListPool *self, uint16_t id) { + ts_assert(id < self->list.size); + return array_get(&self->list, id); +} + +static bool capture_list_pool_is_empty(const CaptureListPool *self) { + // The capture list pool is empty if all allocated lists are in use, and we + // have reached the maximum allowed number of allocated lists. + return self->free_capture_list_count == 0 && self->list.size >= self->max_capture_list_count; +} + +static uint16_t capture_list_pool_acquire(CaptureListPool *self) { + // First see if any already allocated capture list is currently unused. + if (self->free_capture_list_count > 0) { + for (uint16_t i = 0; i < (uint16_t)self->list.size; i++) { + if (array_get(&self->list, i)->size == UINT32_MAX) { + array_clear(array_get(&self->list, i)); + self->free_capture_list_count--; + return i; + } + } + } + + // Otherwise allocate and initialize a new capture list, as long as that + // doesn't put us over the requested maximum. + uint32_t i = self->list.size; + if (i >= self->max_capture_list_count) { + return NONE; + } + CaptureList list; + array_init(&list); + array_push(&self->list, list); + return i; +} + +static void capture_list_pool_release(CaptureListPool *self, uint16_t id) { + if (id >= self->list.size) return; + array_get(&self->list, id)->size = UINT32_MAX; + self->free_capture_list_count++; +} + +/************** + * Quantifiers + **************/ + +static TSQuantifier quantifier_mul( + TSQuantifier left, + TSQuantifier right +) { + switch (left) + { + case TSQuantifierZero: + return TSQuantifierZero; + case TSQuantifierZeroOrOne: + switch (right) { + case TSQuantifierZero: + return TSQuantifierZero; + case TSQuantifierZeroOrOne: + case TSQuantifierOne: + return TSQuantifierZeroOrOne; + case TSQuantifierZeroOrMore: + case TSQuantifierOneOrMore: + return TSQuantifierZeroOrMore; + }; + break; + case TSQuantifierZeroOrMore: + switch (right) { + case TSQuantifierZero: + return TSQuantifierZero; + case TSQuantifierZeroOrOne: + case TSQuantifierZeroOrMore: + case TSQuantifierOne: + case TSQuantifierOneOrMore: + return TSQuantifierZeroOrMore; + }; + break; + case TSQuantifierOne: + return right; + case TSQuantifierOneOrMore: + switch (right) { + case TSQuantifierZero: + return TSQuantifierZero; + case TSQuantifierZeroOrOne: + case TSQuantifierZeroOrMore: + return TSQuantifierZeroOrMore; + case TSQuantifierOne: + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + }; + break; + } + return TSQuantifierZero; // to make compiler happy, but all cases should be covered above! +} + +static TSQuantifier quantifier_join( + TSQuantifier left, + TSQuantifier right +) { + switch (left) + { + case TSQuantifierZero: + switch (right) { + case TSQuantifierZero: + return TSQuantifierZero; + case TSQuantifierZeroOrOne: + case TSQuantifierOne: + return TSQuantifierZeroOrOne; + case TSQuantifierZeroOrMore: + case TSQuantifierOneOrMore: + return TSQuantifierZeroOrMore; + }; + break; + case TSQuantifierZeroOrOne: + switch (right) { + case TSQuantifierZero: + case TSQuantifierZeroOrOne: + case TSQuantifierOne: + return TSQuantifierZeroOrOne; + break; + case TSQuantifierZeroOrMore: + case TSQuantifierOneOrMore: + return TSQuantifierZeroOrMore; + break; + }; + break; + case TSQuantifierZeroOrMore: + return TSQuantifierZeroOrMore; + case TSQuantifierOne: + switch (right) { + case TSQuantifierZero: + case TSQuantifierZeroOrOne: + return TSQuantifierZeroOrOne; + case TSQuantifierZeroOrMore: + return TSQuantifierZeroOrMore; + case TSQuantifierOne: + return TSQuantifierOne; + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + }; + break; + case TSQuantifierOneOrMore: + switch (right) { + case TSQuantifierZero: + case TSQuantifierZeroOrOne: + case TSQuantifierZeroOrMore: + return TSQuantifierZeroOrMore; + case TSQuantifierOne: + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + }; + break; + } + return TSQuantifierZero; // to make compiler happy, but all cases should be covered above! +} + +static TSQuantifier quantifier_add( + TSQuantifier left, + TSQuantifier right +) { + switch (left) + { + case TSQuantifierZero: + return right; + case TSQuantifierZeroOrOne: + switch (right) { + case TSQuantifierZero: + return TSQuantifierZeroOrOne; + case TSQuantifierZeroOrOne: + case TSQuantifierZeroOrMore: + return TSQuantifierZeroOrMore; + case TSQuantifierOne: + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + }; + break; + case TSQuantifierZeroOrMore: + switch (right) { + case TSQuantifierZero: + return TSQuantifierZeroOrMore; + case TSQuantifierZeroOrOne: + case TSQuantifierZeroOrMore: + return TSQuantifierZeroOrMore; + case TSQuantifierOne: + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + }; + break; + case TSQuantifierOne: + switch (right) { + case TSQuantifierZero: + return TSQuantifierOne; + case TSQuantifierZeroOrOne: + case TSQuantifierZeroOrMore: + case TSQuantifierOne: + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + }; + break; + case TSQuantifierOneOrMore: + return TSQuantifierOneOrMore; + } + return TSQuantifierZero; // to make compiler happy, but all cases should be covered above! +} + +// Create new capture quantifiers structure +static CaptureQuantifiers capture_quantifiers_new(void) { + return (CaptureQuantifiers) array_new(); +} + +// Delete capture quantifiers structure +static void capture_quantifiers_delete( + CaptureQuantifiers *self +) { + array_delete(self); +} + +// Clear capture quantifiers structure +static void capture_quantifiers_clear( + CaptureQuantifiers *self +) { + array_clear(self); +} + +// Replace capture quantifiers with the given quantifiers +static void capture_quantifiers_replace( + CaptureQuantifiers *self, + CaptureQuantifiers *quantifiers +) { + array_clear(self); + array_push_all(self, quantifiers); +} + +// Return capture quantifier for the given capture id +static TSQuantifier capture_quantifier_for_id( + const CaptureQuantifiers *self, + uint16_t id +) { + return (self->size <= id) ? TSQuantifierZero : (TSQuantifier) *array_get(self, id); +} + +// Add the given quantifier to the current value for id +static void capture_quantifiers_add_for_id( + CaptureQuantifiers *self, + uint16_t id, + TSQuantifier quantifier +) { + if (self->size <= id) { + array_grow_by(self, id + 1 - self->size); + } + uint8_t *own_quantifier = array_get(self, id); + *own_quantifier = (uint8_t) quantifier_add((TSQuantifier) *own_quantifier, quantifier); +} + +// Point-wise add the given quantifiers to the current values +static void capture_quantifiers_add_all( + CaptureQuantifiers *self, + CaptureQuantifiers *quantifiers +) { + if (self->size < quantifiers->size) { + array_grow_by(self, quantifiers->size - self->size); + } + for (uint16_t id = 0; id < (uint16_t)quantifiers->size; id++) { + uint8_t *quantifier = array_get(quantifiers, id); + uint8_t *own_quantifier = array_get(self, id); + *own_quantifier = (uint8_t) quantifier_add((TSQuantifier) *own_quantifier, (TSQuantifier) *quantifier); + } +} + +// Join the given quantifier with the current values +static void capture_quantifiers_mul( + CaptureQuantifiers *self, + TSQuantifier quantifier +) { + for (uint16_t id = 0; id < (uint16_t)self->size; id++) { + uint8_t *own_quantifier = array_get(self, id); + *own_quantifier = (uint8_t) quantifier_mul((TSQuantifier) *own_quantifier, quantifier); + } +} + +// Point-wise join the quantifiers from a list of alternatives with the current values +static void capture_quantifiers_join_all( + CaptureQuantifiers *self, + CaptureQuantifiers *quantifiers +) { + if (self->size < quantifiers->size) { + array_grow_by(self, quantifiers->size - self->size); + } + for (uint32_t id = 0; id < quantifiers->size; id++) { + uint8_t *quantifier = array_get(quantifiers, id); + uint8_t *own_quantifier = array_get(self, id); + *own_quantifier = (uint8_t) quantifier_join((TSQuantifier) *own_quantifier, (TSQuantifier) *quantifier); + } + for (uint32_t id = quantifiers->size; id < self->size; id++) { + uint8_t *own_quantifier = array_get(self, id); + *own_quantifier = (uint8_t) quantifier_join((TSQuantifier) *own_quantifier, TSQuantifierZero); + } +} + +/************** + * SymbolTable + **************/ + +static SymbolTable symbol_table_new(void) { + return (SymbolTable) { + .characters = array_new(), + .slices = array_new(), + }; +} + +static void symbol_table_delete(SymbolTable *self) { + array_delete(&self->characters); + array_delete(&self->slices); +} + +static int symbol_table_id_for_name( + const SymbolTable *self, + const char *name, + uint32_t length +) { + for (unsigned i = 0; i < self->slices.size; i++) { + Slice slice = *array_get(&self->slices, i); + if ( + slice.length == length && + !strncmp(array_get(&self->characters, slice.offset), name, length) + ) return i; + } + return -1; +} + +static const char *symbol_table_name_for_id( + const SymbolTable *self, + uint16_t id, + uint32_t *length +) { + Slice slice = *(array_get(&self->slices,id)); + *length = slice.length; + return array_get(&self->characters, slice.offset); +} + +static uint16_t symbol_table_insert_name( + SymbolTable *self, + const char *name, + uint32_t length +) { + int id = symbol_table_id_for_name(self, name, length); + if (id >= 0) return (uint16_t)id; + Slice slice = { + .offset = self->characters.size, + .length = length, + }; + array_grow_by(&self->characters, length + 1); + memcpy(array_get(&self->characters, slice.offset), name, length); + *array_get(&self->characters, self->characters.size - 1) = 0; + array_push(&self->slices, slice); + return self->slices.size - 1; +} + +/************ + * QueryStep + ************/ + +static QueryStep query_step__new( + TSSymbol symbol, + uint16_t depth, + bool is_immediate +) { + QueryStep step = { + .symbol = symbol, + .depth = depth, + .field = 0, + .alternative_index = NONE, + .negated_field_list_id = 0, + .contains_captures = false, + .is_last_child = false, + .is_named = false, + .is_pass_through = false, + .is_dead_end = false, + .root_pattern_guaranteed = false, + .is_immediate = is_immediate, + .alternative_is_immediate = false, + }; + for (unsigned i = 0; i < MAX_STEP_CAPTURE_COUNT; i++) { + step.capture_ids[i] = NONE; + } + return step; +} + +static void query_step__add_capture(QueryStep *self, uint16_t capture_id) { + for (unsigned i = 0; i < MAX_STEP_CAPTURE_COUNT; i++) { + if (self->capture_ids[i] == NONE) { + self->capture_ids[i] = capture_id; + break; + } + } +} + +static void query_step__remove_capture(QueryStep *self, uint16_t capture_id) { + for (unsigned i = 0; i < MAX_STEP_CAPTURE_COUNT; i++) { + if (self->capture_ids[i] == capture_id) { + self->capture_ids[i] = NONE; + while (i + 1 < MAX_STEP_CAPTURE_COUNT) { + if (self->capture_ids[i + 1] == NONE) break; + self->capture_ids[i] = self->capture_ids[i + 1]; + self->capture_ids[i + 1] = NONE; + i++; + } + break; + } + } +} + +/********************** + * StatePredecessorMap + **********************/ + +static inline StatePredecessorMap state_predecessor_map_new( + const TSLanguage *language +) { + return (StatePredecessorMap) { + .contents = ts_calloc( + (size_t)language->state_count * (MAX_STATE_PREDECESSOR_COUNT + 1), + sizeof(TSStateId) + ), + }; +} + +static inline void state_predecessor_map_delete(StatePredecessorMap *self) { + ts_free(self->contents); +} + +static inline void state_predecessor_map_add( + StatePredecessorMap *self, + TSStateId state, + TSStateId predecessor +) { + size_t index = (size_t)state * (MAX_STATE_PREDECESSOR_COUNT + 1); + TSStateId *count = &self->contents[index]; + if ( + *count == 0 || + (*count < MAX_STATE_PREDECESSOR_COUNT && self->contents[index + *count] != predecessor) + ) { + (*count)++; + self->contents[index + *count] = predecessor; + } +} + +static inline const TSStateId *state_predecessor_map_get( + const StatePredecessorMap *self, + TSStateId state, + unsigned *count +) { + size_t index = (size_t)state * (MAX_STATE_PREDECESSOR_COUNT + 1); + *count = self->contents[index]; + return &self->contents[index + 1]; +} + +/**************** + * AnalysisState + ****************/ + +static unsigned analysis_state__recursion_depth(const AnalysisState *self) { + unsigned result = 0; + for (unsigned i = 0; i < self->depth; i++) { + TSSymbol symbol = self->stack[i].parent_symbol; + for (unsigned j = 0; j < i; j++) { + if (self->stack[j].parent_symbol == symbol) { + result++; + break; + } + } + } + return result; +} + +static inline int analysis_state__compare( + AnalysisState *const *self, + AnalysisState *const *other +) { + if ((*self)->depth < (*other)->depth) return 1; + for (unsigned i = 0; i < (*self)->depth; i++) { + if (i >= (*other)->depth) return -1; + AnalysisStateEntry s1 = (*self)->stack[i]; + AnalysisStateEntry s2 = (*other)->stack[i]; + if (s1.child_index < s2.child_index) return -1; + if (s1.child_index > s2.child_index) return 1; + if (s1.parent_symbol < s2.parent_symbol) return -1; + if (s1.parent_symbol > s2.parent_symbol) return 1; + if (s1.parse_state < s2.parse_state) return -1; + if (s1.parse_state > s2.parse_state) return 1; + if (s1.field_id < s2.field_id) return -1; + if (s1.field_id > s2.field_id) return 1; + } + if ((*self)->step_index < (*other)->step_index) return -1; + if ((*self)->step_index > (*other)->step_index) return 1; + return 0; +} + +static inline AnalysisStateEntry *analysis_state__top(AnalysisState *self) { + if (self->depth == 0) { + return &self->stack[0]; + } + return &self->stack[self->depth - 1]; +} + +static inline bool analysis_state__has_supertype(AnalysisState *self, TSSymbol symbol) { + for (unsigned i = 0; i < self->depth; i++) { + if (self->stack[i].parent_symbol == symbol) return true; + } + return false; +} + +/****************** + * AnalysisStateSet + ******************/ + +// Obtains an `AnalysisState` instance, either by consuming one from this set's object pool, or by +// cloning one from scratch. +static inline AnalysisState *analysis_state_pool__clone_or_reuse( + AnalysisStateSet *self, + AnalysisState *borrowed_item +) { + AnalysisState *new_item; + if (self->size) { + new_item = array_pop(self); + } else { + new_item = ts_malloc(sizeof(AnalysisState)); + } + *new_item = *borrowed_item; + return new_item; +} + +// Inserts a clone of the passed-in item at the appropriate position to maintain ordering in this +// set. The set does not contain duplicates, so if the item is already present, it will not be +// inserted, and no clone will be made. +// +// The caller retains ownership of the passed-in memory. However, the clone that is created by this +// function will be managed by the state set. +static inline void analysis_state_set__insert_sorted( + AnalysisStateSet *self, + AnalysisStateSet *pool, + AnalysisState *borrowed_item +) { + unsigned index, exists; + array_search_sorted_with(self, analysis_state__compare, &borrowed_item, &index, &exists); + if (!exists) { + AnalysisState *new_item = analysis_state_pool__clone_or_reuse(pool, borrowed_item); + array_insert(self, index, new_item); + } +} + +// Inserts a clone of the passed-in item at the end position of this list. +// +// IMPORTANT: The caller MUST ENSURE that this item is larger (by the comparison function +// `analysis_state__compare`) than largest item already in this set. If items are inserted in the +// wrong order, the set will not function properly for future use. +// +// The caller retains ownership of the passed-in memory. However, the clone that is created by this +// function will be managed by the state set. +static inline void analysis_state_set__push( + AnalysisStateSet *self, + AnalysisStateSet *pool, + AnalysisState *borrowed_item +) { + AnalysisState *new_item = analysis_state_pool__clone_or_reuse(pool, borrowed_item); + array_push(self, new_item); +} + +// Removes all items from this set, returning it to an empty state. +static inline void analysis_state_set__clear(AnalysisStateSet *self, AnalysisStateSet *pool) { + array_push_all(pool, self); + array_clear(self); +} + +// Releases all memory that is managed with this state set, including any items currently present. +// After calling this function, the set is no longer suitable for use. +static inline void analysis_state_set__delete(AnalysisStateSet *self) { + for (unsigned i = 0; i < self->size; i++) { + ts_free(self->contents[i]); + } + array_delete(self); +} + +/**************** + * QueryAnalyzer + ****************/ + +static inline QueryAnalysis query_analysis__new(void) { + return (QueryAnalysis) { + .states = array_new(), + .next_states = array_new(), + .deeper_states = array_new(), + .state_pool = array_new(), + .final_step_indices = array_new(), + .finished_parent_symbols = array_new(), + .did_abort = false, + }; +} + +static inline void query_analysis__delete(QueryAnalysis *self) { + analysis_state_set__delete(&self->states); + analysis_state_set__delete(&self->next_states); + analysis_state_set__delete(&self->deeper_states); + analysis_state_set__delete(&self->state_pool); + array_delete(&self->final_step_indices); + array_delete(&self->finished_parent_symbols); +} + +/*********************** + * AnalysisSubgraphNode + ***********************/ + +static inline int analysis_subgraph_node__compare(const AnalysisSubgraphNode *self, const AnalysisSubgraphNode *other) { + if (self->state < other->state) return -1; + if (self->state > other->state) return 1; + if (self->child_index < other->child_index) return -1; + if (self->child_index > other->child_index) return 1; + if (self->done < other->done) return -1; + if (self->done > other->done) return 1; + if (self->production_id < other->production_id) return -1; + if (self->production_id > other->production_id) return 1; + return 0; +} + +/********* + * Query + *********/ + +// The `pattern_map` contains a mapping from TSSymbol values to indices in the +// `steps` array. For a given syntax node, the `pattern_map` makes it possible +// to quickly find the starting steps of all of the patterns whose root matches +// that node. Each entry has two fields: a `pattern_index`, which identifies one +// of the patterns in the query, and a `step_index`, which indicates the start +// offset of that pattern's steps within the `steps` array. +// +// The entries are sorted by the patterns' root symbols, and lookups use a +// binary search. This ensures that the cost of this initial lookup step +// scales logarithmically with the number of patterns in the query. +// +// This returns `true` if the symbol is present and `false` otherwise. +// If the symbol is not present `*result` is set to the index where the +// symbol should be inserted. +static inline bool ts_query__pattern_map_search( + const TSQuery *self, + TSSymbol needle, + uint32_t *result +) { + uint32_t base_index = self->wildcard_root_pattern_count; + uint32_t size = self->pattern_map.size - base_index; + if (size == 0) { + *result = base_index; + return false; + } + while (size > 1) { + uint32_t half_size = size / 2; + uint32_t mid_index = base_index + half_size; + TSSymbol mid_symbol = array_get(&self->steps, + array_get(&self->pattern_map, mid_index)->step_index + )->symbol; + if (needle > mid_symbol) base_index = mid_index; + size -= half_size; + } + + TSSymbol symbol = array_get(&self->steps, + array_get(&self->pattern_map, base_index)->step_index + )->symbol; + + if (needle > symbol) { + base_index++; + if (base_index < self->pattern_map.size) { + symbol = array_get(&self->steps, + array_get(&self->pattern_map, base_index)->step_index + )->symbol; + } + } + + *result = base_index; + return needle == symbol; +} + +// Insert a new pattern's start index into the pattern map, maintaining +// the pattern map's ordering invariant. +static inline void ts_query__pattern_map_insert( + TSQuery *self, + TSSymbol symbol, + PatternEntry new_entry +) { + uint32_t index; + ts_query__pattern_map_search(self, symbol, &index); + + // Ensure that the entries are sorted not only by symbol, but also + // by pattern_index. This way, states for earlier patterns will be + // initiated first, which allows the ordering of the states array + // to be maintained more efficiently. + while (index < self->pattern_map.size) { + PatternEntry *entry = array_get(&self->pattern_map, index); + if ( + array_get(&self->steps, entry->step_index)->symbol == symbol && + entry->pattern_index < new_entry.pattern_index + ) { + index++; + } else { + break; + } + } + + array_insert(&self->pattern_map, index, new_entry); +} + +// Walk the subgraph for this non-terminal, tracking all of the possible +// sequences of progress within the pattern. +static void ts_query__perform_analysis( + TSQuery *self, + const AnalysisSubgraphArray *subgraphs, + QueryAnalysis *analysis +) { + unsigned recursion_depth_limit = 0; + unsigned prev_final_step_count = 0; + array_clear(&analysis->final_step_indices); + array_clear(&analysis->finished_parent_symbols); + + for (unsigned iteration = 0;; iteration++) { + if (iteration == MAX_ANALYSIS_ITERATION_COUNT) { + analysis->did_abort = true; + break; + } + + #ifdef DEBUG_ANALYZE_QUERY + printf("Iteration: %u. Final step indices:", iteration); + for (unsigned j = 0; j < analysis->final_step_indices.size; j++) { + printf(" %4u", *array_get(&analysis->final_step_indices, j)); + } + printf("\n"); + for (unsigned j = 0; j < analysis->states.size; j++) { + AnalysisState *state = *array_get(&analysis->states, j); + printf(" %3u: step: %u, stack: [", j, state->step_index); + for (unsigned k = 0; k < state->depth; k++) { + printf( + " {%s, child: %u, state: %4u", + self->language->symbol_names[state->stack[k].parent_symbol], + state->stack[k].child_index, + state->stack[k].parse_state + ); + if (state->stack[k].field_id) printf(", field: %s", self->language->field_names[state->stack[k].field_id]); + if (state->stack[k].done) printf(", DONE"); + printf("}"); + } + printf(" ]\n"); + } + #endif + + // If no further progress can be made within the current recursion depth limit, then + // bump the depth limit by one, and continue to process the states the exceeded the + // limit. But only allow this if progress has been made since the last time the depth + // limit was increased. + if (analysis->states.size == 0) { + if ( + analysis->deeper_states.size > 0 && + analysis->final_step_indices.size > prev_final_step_count + ) { + #ifdef DEBUG_ANALYZE_QUERY + printf("Increase recursion depth limit to %u\n", recursion_depth_limit + 1); + #endif + + prev_final_step_count = analysis->final_step_indices.size; + recursion_depth_limit++; + AnalysisStateSet _states = analysis->states; + analysis->states = analysis->deeper_states; + analysis->deeper_states = _states; + continue; + } + + break; + } + + analysis_state_set__clear(&analysis->next_states, &analysis->state_pool); + for (unsigned j = 0; j < analysis->states.size; j++) { + AnalysisState * const state = *array_get(&analysis->states, j); + + // For efficiency, it's important to avoid processing the same analysis state more + // than once. To achieve this, keep the states in order of ascending position within + // their hypothetical syntax trees. In each iteration of this loop, start by advancing + // the states that have made the least progress. Avoid advancing states that have already + // made more progress. + if (analysis->next_states.size > 0) { + int comparison = analysis_state__compare( + &state, + array_back(&analysis->next_states) + ); + if (comparison == 0) { + analysis_state_set__insert_sorted(&analysis->next_states, &analysis->state_pool, state); + continue; + } else if (comparison > 0) { + #ifdef DEBUG_ANALYZE_QUERY + printf("Terminate iteration at state %u\n", j); + #endif + while (j < analysis->states.size) { + analysis_state_set__push( + &analysis->next_states, + &analysis->state_pool, + *array_get(&analysis->states, j) + ); + j++; + } + break; + } + } + + const TSStateId parse_state = analysis_state__top(state)->parse_state; + const TSSymbol parent_symbol = analysis_state__top(state)->parent_symbol; + const TSFieldId parent_field_id = analysis_state__top(state)->field_id; + const unsigned child_index = analysis_state__top(state)->child_index; + const QueryStep * const step = array_get(&self->steps, state->step_index); + + unsigned subgraph_index, exists; + array_search_sorted_by(subgraphs, .symbol, parent_symbol, &subgraph_index, &exists); + if (!exists) continue; + const AnalysisSubgraph *subgraph = array_get(subgraphs, subgraph_index); + + // Follow every possible path in the parse table, but only visit states that + // are part of the subgraph for the current symbol. + LookaheadIterator lookahead_iterator = ts_language_lookaheads(self->language, parse_state); + while (ts_lookahead_iterator__next(&lookahead_iterator)) { + TSSymbol sym = lookahead_iterator.symbol; + + AnalysisSubgraphNode successor = { + .state = parse_state, + .child_index = child_index, + }; + if (lookahead_iterator.action_count) { + const TSParseAction *action = &lookahead_iterator.actions[lookahead_iterator.action_count - 1]; + if (action->type == TSParseActionTypeShift) { + if (!action->shift.extra) { + successor.state = action->shift.state; + successor.child_index++; + } + } else { + continue; + } + } else if (lookahead_iterator.next_state != 0) { + successor.state = lookahead_iterator.next_state; + successor.child_index++; + } else { + continue; + } + + unsigned node_index; + array_search_sorted_with( + &subgraph->nodes, + analysis_subgraph_node__compare, &successor, + &node_index, &exists + ); + while (node_index < subgraph->nodes.size) { + AnalysisSubgraphNode *node = array_get(&subgraph->nodes, node_index); + node_index++; + if (node->state != successor.state || node->child_index != successor.child_index) break; + + // Use the subgraph to determine what alias and field will eventually be applied + // to this child node. + TSSymbol alias = ts_language_alias_at(self->language, node->production_id, child_index); + TSSymbol visible_symbol = alias + ? alias + : self->language->symbol_metadata[sym].visible + ? self->language->public_symbol_map[sym] + : 0; + TSFieldId field_id = parent_field_id; + if (!field_id) { + const TSFieldMapEntry *field_map, *field_map_end; + ts_language_field_map(self->language, node->production_id, &field_map, &field_map_end); + for (; field_map != field_map_end; field_map++) { + if (!field_map->inherited && field_map->child_index == child_index) { + field_id = field_map->field_id; + break; + } + } + } + + // Create a new state that has advanced past this hypothetical subtree. + AnalysisState next_state = *state; + AnalysisStateEntry *next_state_top = analysis_state__top(&next_state); + next_state_top->child_index = successor.child_index; + next_state_top->parse_state = successor.state; + if (node->done) next_state_top->done = true; + + // Determine if this hypothetical child node would match the current step + // of the query pattern. + bool does_match = false; + + // ERROR nodes can appear anywhere, so if the step is + // looking for an ERROR node, consider it potentially matchable. + if (step->symbol == ts_builtin_sym_error) { + does_match = true; + } else if (visible_symbol) { + does_match = true; + if (step->symbol == WILDCARD_SYMBOL) { + if ( + step->is_named && + !self->language->symbol_metadata[visible_symbol].named + ) does_match = false; + } else if (step->symbol != visible_symbol) { + does_match = false; + } + if (step->field && step->field != field_id) { + does_match = false; + } + if ( + step->supertype_symbol && + !analysis_state__has_supertype(state, step->supertype_symbol) + ) does_match = false; + } + + // If this child is hidden, then descend into it and walk through its children. + // If the top entry of the stack is at the end of its rule, then that entry can + // be replaced. Otherwise, push a new entry onto the stack. + else if (sym >= self->language->token_count) { + if (!next_state_top->done) { + if (next_state.depth + 1 >= MAX_ANALYSIS_STATE_DEPTH) { + #ifdef DEBUG_ANALYZE_QUERY + printf("Exceeded depth limit for state %u\n", j); + #endif + + analysis->did_abort = true; + continue; + } + + next_state.depth++; + next_state_top = analysis_state__top(&next_state); + } + + *next_state_top = (AnalysisStateEntry) { + .parse_state = parse_state, + .parent_symbol = sym, + .child_index = 0, + .field_id = field_id, + .done = false, + }; + + if (analysis_state__recursion_depth(&next_state) > recursion_depth_limit) { + analysis_state_set__insert_sorted( + &analysis->deeper_states, + &analysis->state_pool, + &next_state + ); + continue; + } + } + + // Pop from the stack when this state reached the end of its current syntax node. + while (next_state.depth > 0 && next_state_top->done) { + next_state.depth--; + next_state_top = analysis_state__top(&next_state); + } + + // If this hypothetical child did match the current step of the query pattern, + // then advance to the next step at the current depth. This involves skipping + // over any descendant steps of the current child. + const QueryStep *next_step = step; + if (does_match) { + for (;;) { + next_state.step_index++; + next_step = array_get(&self->steps, next_state.step_index); + if ( + next_step->depth == PATTERN_DONE_MARKER || + next_step->depth <= step->depth + ) break; + } + } else if (successor.state == parse_state) { + continue; + } + + for (;;) { + // Skip pass-through states. Although these states have alternatives, they are only + // used to implement repetitions, and query analysis does not need to process + // repetitions in order to determine whether steps are possible and definite. + if (next_step->is_pass_through) { + next_state.step_index++; + next_step++; + continue; + } + + // If the pattern is finished or hypothetical parent node is complete, then + // record that matching can terminate at this step of the pattern. Otherwise, + // add this state to the list of states to process on the next iteration. + if (!next_step->is_dead_end) { + bool did_finish_pattern = array_get(&self->steps, next_state.step_index)->depth != step->depth; + if (did_finish_pattern) { + array_insert_sorted_by(&analysis->finished_parent_symbols, , state->root_symbol); + } else if (next_state.depth == 0) { + array_insert_sorted_by(&analysis->final_step_indices, , next_state.step_index); + } else { + analysis_state_set__insert_sorted(&analysis->next_states, &analysis->state_pool, &next_state); + } + } + + // If the state has advanced to a step with an alternative step, then add another state + // at that alternative step. This process is simpler than the process of actually matching a + // pattern during query execution, because for the purposes of query analysis, there is no + // need to process repetitions. + if ( + does_match && + next_step->alternative_index != NONE && + next_step->alternative_index > next_state.step_index + ) { + next_state.step_index = next_step->alternative_index; + next_step = array_get(&self->steps, next_state.step_index); + } else { + break; + } + } + } + } + } + + AnalysisStateSet _states = analysis->states; + analysis->states = analysis->next_states; + analysis->next_states = _states; + } +} + +static bool ts_query__analyze_patterns(TSQuery *self, unsigned *error_offset) { + Array(uint16_t) non_rooted_pattern_start_steps = array_new(); + for (unsigned i = 0; i < self->pattern_map.size; i++) { + PatternEntry *pattern = array_get(&self->pattern_map, i); + if (!pattern->is_rooted) { + QueryStep *step = array_get(&self->steps, pattern->step_index); + if (step->symbol != WILDCARD_SYMBOL) { + array_push(&non_rooted_pattern_start_steps, i); + } + } + } + + // Walk forward through all of the steps in the query, computing some + // basic information about each step. Mark all of the steps that contain + // captures, and record the indices of all of the steps that have child steps. + Array(uint32_t) parent_step_indices = array_new(); + for (unsigned i = 0; i < self->steps.size; i++) { + QueryStep *step = array_get(&self->steps, i); + if (step->depth == PATTERN_DONE_MARKER) { + step->parent_pattern_guaranteed = true; + step->root_pattern_guaranteed = true; + continue; + } + + bool has_children = false; + bool is_wildcard = step->symbol == WILDCARD_SYMBOL; + step->contains_captures = step->capture_ids[0] != NONE; + for (unsigned j = i + 1; j < self->steps.size; j++) { + QueryStep *next_step = array_get(&self->steps, j); + if ( + next_step->depth == PATTERN_DONE_MARKER || + next_step->depth <= step->depth + ) break; + if (next_step->capture_ids[0] != NONE) { + step->contains_captures = true; + } + if (!is_wildcard) { + next_step->root_pattern_guaranteed = true; + next_step->parent_pattern_guaranteed = true; + } + has_children = true; + } + + if (has_children && !is_wildcard) { + array_push(&parent_step_indices, i); + } + } + + // For every parent symbol in the query, initialize an 'analysis subgraph'. + // This subgraph lists all of the states in the parse table that are directly + // involved in building subtrees for this symbol. + // + // In addition to the parent symbols in the query, construct subgraphs for all + // of the hidden symbols in the grammar, because these might occur within + // one of the parent nodes, such that their children appear to belong to the + // parent. + AnalysisSubgraphArray subgraphs = array_new(); + for (unsigned i = 0; i < parent_step_indices.size; i++) { + uint32_t parent_step_index = *array_get(&parent_step_indices, i); + TSSymbol parent_symbol = array_get(&self->steps, parent_step_index)->symbol; + AnalysisSubgraph subgraph = { .symbol = parent_symbol }; + array_insert_sorted_by(&subgraphs, .symbol, subgraph); + } + for (TSSymbol sym = (uint16_t)self->language->token_count; sym < (uint16_t)self->language->symbol_count; sym++) { + if (!ts_language_symbol_metadata(self->language, sym).visible) { + AnalysisSubgraph subgraph = { .symbol = sym }; + array_insert_sorted_by(&subgraphs, .symbol, subgraph); + } + } + + // Scan the parse table to find the data needed to populate these subgraphs. + // Collect three things during this scan: + // 1) All of the parse states where one of these symbols can start. + // 2) All of the parse states where one of these symbols can end, along + // with information about the node that would be created. + // 3) A list of predecessor states for each state. + StatePredecessorMap predecessor_map = state_predecessor_map_new(self->language); + for (TSStateId state = 1; state < (uint16_t)self->language->state_count; state++) { + unsigned subgraph_index, exists; + LookaheadIterator lookahead_iterator = ts_language_lookaheads(self->language, state); + while (ts_lookahead_iterator__next(&lookahead_iterator)) { + if (lookahead_iterator.action_count) { + for (unsigned i = 0; i < lookahead_iterator.action_count; i++) { + const TSParseAction *action = &lookahead_iterator.actions[i]; + if (action->type == TSParseActionTypeReduce) { + const TSSymbol *aliases, *aliases_end; + ts_language_aliases_for_symbol( + self->language, + action->reduce.symbol, + &aliases, + &aliases_end + ); + for (const TSSymbol *symbol = aliases; symbol < aliases_end; symbol++) { + array_search_sorted_by( + &subgraphs, + .symbol, + *symbol, + &subgraph_index, + &exists + ); + if (exists) { + AnalysisSubgraph *subgraph = array_get(&subgraphs, subgraph_index); + if (subgraph->nodes.size == 0 || array_back(&subgraph->nodes)->state != state) { + array_push(&subgraph->nodes, ((AnalysisSubgraphNode) { + .state = state, + .production_id = action->reduce.production_id, + .child_index = action->reduce.child_count, + .done = true, + })); + } + } + } + } else if (action->type == TSParseActionTypeShift && !action->shift.extra) { + TSStateId next_state = action->shift.state; + state_predecessor_map_add(&predecessor_map, next_state, state); + } + } + } else if (lookahead_iterator.next_state != 0) { + if (lookahead_iterator.next_state != state) { + state_predecessor_map_add(&predecessor_map, lookahead_iterator.next_state, state); + } + if (ts_language_state_is_primary(self->language, state)) { + const TSSymbol *aliases, *aliases_end; + ts_language_aliases_for_symbol( + self->language, + lookahead_iterator.symbol, + &aliases, + &aliases_end + ); + for (const TSSymbol *symbol = aliases; symbol < aliases_end; symbol++) { + array_search_sorted_by( + &subgraphs, + .symbol, + *symbol, + &subgraph_index, + &exists + ); + if (exists) { + AnalysisSubgraph *subgraph = array_get(&subgraphs, subgraph_index); + if ( + subgraph->start_states.size == 0 || + *array_back(&subgraph->start_states) != state + ) + array_push(&subgraph->start_states, state); + } + } + } + } + } + } + + // For each subgraph, compute the preceding states by walking backward + // from the end states using the predecessor map. + Array(AnalysisSubgraphNode) next_nodes = array_new(); + for (unsigned i = 0; i < subgraphs.size; i++) { + AnalysisSubgraph *subgraph = array_get(&subgraphs, i); + if (subgraph->nodes.size == 0) { + array_delete(&subgraph->start_states); + array_erase(&subgraphs, i); + i--; + continue; + } + array_assign(&next_nodes, &subgraph->nodes); + while (next_nodes.size > 0) { + AnalysisSubgraphNode node = array_pop(&next_nodes); + if (node.child_index > 1) { + unsigned predecessor_count; + const TSStateId *predecessors = state_predecessor_map_get( + &predecessor_map, + node.state, + &predecessor_count + ); + for (unsigned j = 0; j < predecessor_count; j++) { + AnalysisSubgraphNode predecessor_node = { + .state = predecessors[j], + .child_index = node.child_index - 1, + .production_id = node.production_id, + .done = false, + }; + unsigned index, exists; + array_search_sorted_with( + &subgraph->nodes, analysis_subgraph_node__compare, &predecessor_node, + &index, &exists + ); + if (!exists) { + array_insert(&subgraph->nodes, index, predecessor_node); + array_push(&next_nodes, predecessor_node); + } + } + } + } + } + + #ifdef DEBUG_ANALYZE_QUERY + printf("\nSubgraphs:\n"); + for (unsigned i = 0; i < subgraphs.size; i++) { + AnalysisSubgraph *subgraph = array_get(&subgraphs, i); + printf(" %u, %s:\n", subgraph->symbol, ts_language_symbol_name(self->language, subgraph->symbol)); + for (unsigned j = 0; j < subgraph->start_states.size; j++) { + printf( + " {state: %u}\n", + *array_get(&subgraph->start_states, j) + ); + } + for (unsigned j = 0; j < subgraph->nodes.size; j++) { + AnalysisSubgraphNode *node = array_get(&subgraph->nodes, j); + printf( + " {state: %u, child_index: %u, production_id: %u, done: %d}\n", + node->state, node->child_index, node->production_id, node->done + ); + } + printf("\n"); + } + #endif + + // For each non-terminal pattern, determine if the pattern can successfully match, + // and identify all of the possible children within the pattern where matching could fail. + bool all_patterns_are_valid = true; + QueryAnalysis analysis = query_analysis__new(); + for (unsigned i = 0; i < parent_step_indices.size; i++) { + uint16_t parent_step_index = *array_get(&parent_step_indices, i); + uint16_t parent_depth = array_get(&self->steps, parent_step_index)->depth; + TSSymbol parent_symbol = array_get(&self->steps, parent_step_index)->symbol; + if (parent_symbol == ts_builtin_sym_error) continue; + + // Find the subgraph that corresponds to this pattern's root symbol. If the pattern's + // root symbol is a terminal, then return an error. + unsigned subgraph_index, exists; + array_search_sorted_by(&subgraphs, .symbol, parent_symbol, &subgraph_index, &exists); + if (!exists) { + unsigned first_child_step_index = parent_step_index + 1; + uint32_t j, child_exists; + array_search_sorted_by(&self->step_offsets, .step_index, first_child_step_index, &j, &child_exists); + ts_assert(child_exists); + *error_offset = array_get(&self->step_offsets, j)->byte_offset; + all_patterns_are_valid = false; + break; + } + + // Initialize an analysis state at every parse state in the table where + // this parent symbol can occur. + AnalysisSubgraph *subgraph = array_get(&subgraphs, subgraph_index); + analysis_state_set__clear(&analysis.states, &analysis.state_pool); + analysis_state_set__clear(&analysis.deeper_states, &analysis.state_pool); + for (unsigned j = 0; j < subgraph->start_states.size; j++) { + TSStateId parse_state = *array_get(&subgraph->start_states, j); + analysis_state_set__push(&analysis.states, &analysis.state_pool, &((AnalysisState) { + .step_index = parent_step_index + 1, + .stack = { + [0] = { + .parse_state = parse_state, + .parent_symbol = parent_symbol, + .child_index = 0, + .field_id = 0, + .done = false, + }, + }, + .depth = 1, + .root_symbol = parent_symbol, + })); + } + + #ifdef DEBUG_ANALYZE_QUERY + printf( + "\nWalk states for %s:\n", + ts_language_symbol_name(self->language, (*array_get(&analysis.states, 0))->stack[0].parent_symbol) + ); + #endif + + analysis.did_abort = false; + ts_query__perform_analysis(self, &subgraphs, &analysis); + + // If this pattern could not be fully analyzed, then every step should + // be considered fallible. + if (analysis.did_abort) { + for (unsigned j = parent_step_index + 1; j < self->steps.size; j++) { + QueryStep *step = array_get(&self->steps, j); + if ( + step->depth <= parent_depth || + step->depth == PATTERN_DONE_MARKER + ) break; + if (!step->is_dead_end) { + step->parent_pattern_guaranteed = false; + step->root_pattern_guaranteed = false; + } + } + continue; + } + + // If this pattern cannot match, store the pattern index so that it can be + // returned to the caller. + if (analysis.finished_parent_symbols.size == 0) { + ts_assert(analysis.final_step_indices.size > 0); + uint16_t impossible_step_index = *array_back(&analysis.final_step_indices); + uint32_t j, impossible_exists; + array_search_sorted_by(&self->step_offsets, .step_index, impossible_step_index, &j, &impossible_exists); + if (j >= self->step_offsets.size) j = self->step_offsets.size - 1; + *error_offset = array_get(&self->step_offsets, j)->byte_offset; + all_patterns_are_valid = false; + break; + } + + // Mark as fallible any step where a match terminated. + // Later, this property will be propagated to all of the step's predecessors. + for (unsigned j = 0; j < analysis.final_step_indices.size; j++) { + uint32_t final_step_index = *array_get(&analysis.final_step_indices, j); + QueryStep *step = array_get(&self->steps, final_step_index); + if ( + step->depth != PATTERN_DONE_MARKER && + step->depth > parent_depth && + !step->is_dead_end + ) { + step->parent_pattern_guaranteed = false; + step->root_pattern_guaranteed = false; + } + } + } + + // Mark as indefinite any step with captures that are used in predicates. + Array(uint16_t) predicate_capture_ids = array_new(); + for (unsigned i = 0; i < self->patterns.size; i++) { + QueryPattern *pattern = array_get(&self->patterns, i); + + // Gather all of the captures that are used in predicates for this pattern. + array_clear(&predicate_capture_ids); + for ( + unsigned start = pattern->predicate_steps.offset, + end = start + pattern->predicate_steps.length, + j = start; j < end; j++ + ) { + TSQueryPredicateStep *step = array_get(&self->predicate_steps, j); + if (step->type == TSQueryPredicateStepTypeCapture) { + uint16_t value_id = step->value_id; + array_insert_sorted_by(&predicate_capture_ids, , value_id); + } + } + + // Find all of the steps that have these captures. + for ( + unsigned start = pattern->steps.offset, + end = start + pattern->steps.length, + j = start; j < end; j++ + ) { + QueryStep *step = array_get(&self->steps, j); + for (unsigned k = 0; k < MAX_STEP_CAPTURE_COUNT; k++) { + uint16_t capture_id = step->capture_ids[k]; + if (capture_id == NONE) break; + unsigned index, exists; + array_search_sorted_by(&predicate_capture_ids, , capture_id, &index, &exists); + if (exists) { + step->root_pattern_guaranteed = false; + break; + } + } + } + } + + // Propagate fallibility. If a pattern is fallible at a given step, then it is + // fallible at all of its preceding steps. + bool done = self->steps.size == 0; + while (!done) { + done = true; + for (unsigned i = self->steps.size - 1; i > 0; i--) { + QueryStep *step = array_get(&self->steps, i); + if (step->depth == PATTERN_DONE_MARKER) continue; + + // Determine if this step is definite or has definite alternatives. + bool parent_pattern_guaranteed = false; + for (;;) { + if (step->root_pattern_guaranteed) { + parent_pattern_guaranteed = true; + break; + } + if (step->alternative_index == NONE || step->alternative_index < i) { + break; + } + step = array_get(&self->steps, step->alternative_index); + } + + // If not, mark its predecessor as indefinite. + if (!parent_pattern_guaranteed) { + QueryStep *prev_step = array_get(&self->steps, i - 1); + if ( + !prev_step->is_dead_end && + prev_step->depth != PATTERN_DONE_MARKER && + prev_step->root_pattern_guaranteed + ) { + prev_step->root_pattern_guaranteed = false; + done = false; + } + } + } + } + + #ifdef DEBUG_ANALYZE_QUERY + printf("Steps:\n"); + for (unsigned i = 0; i < self->steps.size; i++) { + QueryStep *step = array_get(&self->steps, i); + if (step->depth == PATTERN_DONE_MARKER) { + printf(" %u: DONE\n", i); + } else { + printf( + " %u: {symbol: %s, field: %s, depth: %u, parent_pattern_guaranteed: %d, root_pattern_guaranteed: %d}\n", + i, + (step->symbol == WILDCARD_SYMBOL) + ? "ANY" + : ts_language_symbol_name(self->language, step->symbol), + (step->field ? ts_language_field_name_for_id(self->language, step->field) : "-"), + step->depth, + step->parent_pattern_guaranteed, + step->root_pattern_guaranteed + ); + } + } + #endif + + // Determine which repetition symbols in this language have the possibility + // of matching non-rooted patterns in this query. These repetition symbols + // prevent certain optimizations with range restrictions. + analysis.did_abort = false; + for (uint32_t i = 0; i < non_rooted_pattern_start_steps.size; i++) { + uint16_t pattern_entry_index = *array_get(&non_rooted_pattern_start_steps, i); + PatternEntry *pattern_entry = array_get(&self->pattern_map, pattern_entry_index); + + analysis_state_set__clear(&analysis.states, &analysis.state_pool); + analysis_state_set__clear(&analysis.deeper_states, &analysis.state_pool); + for (unsigned j = 0; j < subgraphs.size; j++) { + AnalysisSubgraph *subgraph = array_get(&subgraphs, j); + TSSymbolMetadata metadata = ts_language_symbol_metadata(self->language, subgraph->symbol); + if (metadata.visible || metadata.named) continue; + + for (uint32_t k = 0; k < subgraph->start_states.size; k++) { + TSStateId parse_state = *array_get(&subgraph->start_states, k); + analysis_state_set__push(&analysis.states, &analysis.state_pool, &((AnalysisState) { + .step_index = pattern_entry->step_index, + .stack = { + [0] = { + .parse_state = parse_state, + .parent_symbol = subgraph->symbol, + .child_index = 0, + .field_id = 0, + .done = false, + }, + }, + .root_symbol = subgraph->symbol, + .depth = 1, + })); + } + } + + #ifdef DEBUG_ANALYZE_QUERY + printf("\nWalk states for rootless pattern step %u:\n", pattern_entry->step_index); + #endif + + ts_query__perform_analysis( + self, + &subgraphs, + &analysis + ); + + if (analysis.finished_parent_symbols.size > 0) { + array_get(&self->patterns, pattern_entry->pattern_index)->is_non_local = true; + } + + for (unsigned k = 0; k < analysis.finished_parent_symbols.size; k++) { + TSSymbol symbol = *array_get(&analysis.finished_parent_symbols, k); + array_insert_sorted_by(&self->repeat_symbols_with_rootless_patterns, , symbol); + } + } + + #ifdef DEBUG_ANALYZE_QUERY + if (self->repeat_symbols_with_rootless_patterns.size > 0) { + printf("\nRepetition symbols with rootless patterns:\n"); + printf("aborted analysis: %d\n", analysis.did_abort); + for (unsigned i = 0; i < self->repeat_symbols_with_rootless_patterns.size; i++) { + TSSymbol symbol = *array_get(&self->repeat_symbols_with_rootless_patterns, i); + printf(" %u, %s\n", symbol, ts_language_symbol_name(self->language, symbol)); + } + printf("\n"); + } + #endif + + // Cleanup + for (unsigned i = 0; i < subgraphs.size; i++) { + array_delete(&array_get(&subgraphs, i)->start_states); + array_delete(&array_get(&subgraphs, i)->nodes); + } + array_delete(&subgraphs); + query_analysis__delete(&analysis); + array_delete(&next_nodes); + array_delete(&non_rooted_pattern_start_steps); + array_delete(&parent_step_indices); + array_delete(&predicate_capture_ids); + state_predecessor_map_delete(&predecessor_map); + + return all_patterns_are_valid; +} + +static void ts_query__add_negated_fields( + TSQuery *self, + uint16_t step_index, + TSFieldId *field_ids, + uint16_t field_count +) { + QueryStep *step = array_get(&self->steps, step_index); + + // The negated field array stores a list of field lists, separated by zeros. + // Try to find the start index of an existing list that matches this new list. + bool failed_match = false; + unsigned match_count = 0; + unsigned start_i = 0; + for (unsigned i = 0; i < self->negated_fields.size; i++) { + TSFieldId existing_field_id = *array_get(&self->negated_fields, i); + + // At each zero value, terminate the match attempt. If we've exactly + // matched the new field list, then reuse this index. Otherwise, + // start over the matching process. + if (existing_field_id == 0) { + if (match_count == field_count) { + step->negated_field_list_id = start_i; + return; + } else { + start_i = i + 1; + match_count = 0; + failed_match = false; + } + } + + // If the existing list matches our new list so far, then advance + // to the next element of the new list. + else if ( + match_count < field_count && + existing_field_id == field_ids[match_count] && + !failed_match + ) { + match_count++; + } + + // Otherwise, this existing list has failed to match. + else { + match_count = 0; + failed_match = true; + } + } + + step->negated_field_list_id = self->negated_fields.size; + array_extend(&self->negated_fields, field_count, field_ids); + array_push(&self->negated_fields, 0); +} + +static TSQueryError ts_query__parse_string_literal( + TSQuery *self, + Stream *stream +) { + const char *string_start = stream->input; + if (stream->next != '"') return TSQueryErrorSyntax; + stream_advance(stream); + const char *prev_position = stream->input; + + bool is_escaped = false; + array_clear(&self->string_buffer); + for (;;) { + if (is_escaped) { + is_escaped = false; + switch (stream->next) { + case 'n': + array_push(&self->string_buffer, '\n'); + break; + case 'r': + array_push(&self->string_buffer, '\r'); + break; + case 't': + array_push(&self->string_buffer, '\t'); + break; + case '0': + array_push(&self->string_buffer, '\0'); + break; + default: + array_extend(&self->string_buffer, stream->next_size, stream->input); + break; + } + prev_position = stream->input + stream->next_size; + } else { + if (stream->next == '\\') { + array_extend(&self->string_buffer, (uint32_t)(stream->input - prev_position), prev_position); + prev_position = stream->input + 1; + is_escaped = true; + } else if (stream->next == '"') { + array_extend(&self->string_buffer, (uint32_t)(stream->input - prev_position), prev_position); + stream_advance(stream); + return TSQueryErrorNone; + } else if (stream->next == '\n') { + stream_reset(stream, string_start); + return TSQueryErrorSyntax; + } + } + if (!stream_advance(stream)) { + stream_reset(stream, string_start); + return TSQueryErrorSyntax; + } + } +} + +// Parse a single predicate associated with a pattern, adding it to the +// query's internal `predicate_steps` array. Predicates are arbitrary +// S-expressions associated with a pattern which are meant to be handled at +// a higher level of abstraction, such as the Rust/JavaScript bindings. They +// can contain '@'-prefixed capture names, double-quoted strings, and bare +// symbols, which also represent strings. +static TSQueryError ts_query__parse_predicate( + TSQuery *self, + Stream *stream +) { + if (!stream_is_ident_start(stream)) return TSQueryErrorSyntax; + const char *predicate_name = stream->input; + stream_scan_identifier(stream); + uint32_t length = (uint32_t)(stream->input - predicate_name); + uint16_t id = symbol_table_insert_name( + &self->predicate_values, + predicate_name, + length + ); + array_push(&self->predicate_steps, ((TSQueryPredicateStep) { + .type = TSQueryPredicateStepTypeString, + .value_id = id, + })); + stream_skip_whitespace(stream); + + for (;;) { + if (stream->next == ')') { + stream_advance(stream); + stream_skip_whitespace(stream); + array_push(&self->predicate_steps, ((TSQueryPredicateStep) { + .type = TSQueryPredicateStepTypeDone, + .value_id = 0, + })); + break; + } + + // Parse an '@'-prefixed capture name + else if (stream->next == '@') { + stream_advance(stream); + + // Parse the capture name + if (!stream_is_ident_start(stream)) return TSQueryErrorSyntax; + const char *capture_name = stream->input; + stream_scan_identifier(stream); + uint32_t capture_length = (uint32_t)(stream->input - capture_name); + + // Add the capture id to the first step of the pattern + int capture_id = symbol_table_id_for_name( + &self->captures, + capture_name, + capture_length + ); + if (capture_id == -1) { + stream_reset(stream, capture_name); + return TSQueryErrorCapture; + } + + array_push(&self->predicate_steps, ((TSQueryPredicateStep) { + .type = TSQueryPredicateStepTypeCapture, + .value_id = capture_id, + })); + } + + // Parse a string literal + else if (stream->next == '"') { + TSQueryError e = ts_query__parse_string_literal(self, stream); + if (e) return e; + uint16_t query_id = symbol_table_insert_name( + &self->predicate_values, + self->string_buffer.contents, + self->string_buffer.size + ); + array_push(&self->predicate_steps, ((TSQueryPredicateStep) { + .type = TSQueryPredicateStepTypeString, + .value_id = query_id, + })); + } + + // Parse a bare symbol + else if (stream_is_ident_start(stream)) { + const char *symbol_start = stream->input; + stream_scan_identifier(stream); + uint32_t symbol_length = (uint32_t)(stream->input - symbol_start); + uint16_t query_id = symbol_table_insert_name( + &self->predicate_values, + symbol_start, + symbol_length + ); + array_push(&self->predicate_steps, ((TSQueryPredicateStep) { + .type = TSQueryPredicateStepTypeString, + .value_id = query_id, + })); + } + + else { + return TSQueryErrorSyntax; + } + + stream_skip_whitespace(stream); + } + + return 0; +} + +// Read one S-expression pattern from the stream, and incorporate it into +// the query's internal state machine representation. For nested patterns, +// this function calls itself recursively. +// +// The caller is responsible for passing in a dedicated CaptureQuantifiers. +// These should not be shared between different calls to ts_query__parse_pattern! +static TSQueryError ts_query__parse_pattern( + TSQuery *self, + Stream *stream, + uint32_t depth, + bool is_immediate, + CaptureQuantifiers *capture_quantifiers +) { + if (stream->next == 0) return TSQueryErrorSyntax; + if (stream->next == ')' || stream->next == ']') return PARENT_DONE; + + const uint32_t starting_step_index = self->steps.size; + + // Store the byte offset of each step in the query. + if ( + self->step_offsets.size == 0 || + array_back(&self->step_offsets)->step_index != starting_step_index + ) { + array_push(&self->step_offsets, ((StepOffset) { + .step_index = starting_step_index, + .byte_offset = stream_offset(stream), + })); + } + + // An open bracket is the start of an alternation. + if (stream->next == '[') { + stream_advance(stream); + stream_skip_whitespace(stream); + + // Parse each branch, and add a placeholder step in between the branches. + Array(uint32_t) branch_step_indices = array_new(); + CaptureQuantifiers branch_capture_quantifiers = capture_quantifiers_new(); + for (;;) { + uint32_t start_index = self->steps.size; + TSQueryError e = ts_query__parse_pattern( + self, + stream, + depth, + is_immediate, + &branch_capture_quantifiers + ); + + if (e == PARENT_DONE) { + if (stream->next == ']' && branch_step_indices.size > 0) { + stream_advance(stream); + break; + } + e = TSQueryErrorSyntax; + } + if (e) { + capture_quantifiers_delete(&branch_capture_quantifiers); + array_delete(&branch_step_indices); + return e; + } + + if (start_index == starting_step_index) { + capture_quantifiers_replace(capture_quantifiers, &branch_capture_quantifiers); + } else { + capture_quantifiers_join_all(capture_quantifiers, &branch_capture_quantifiers); + } + + array_push(&branch_step_indices, start_index); + array_push(&self->steps, query_step__new(0, depth, false)); + capture_quantifiers_clear(&branch_capture_quantifiers); + } + (void)array_pop(&self->steps); + + // For all of the branches except for the last one, add the subsequent branch as an + // alternative, and link the end of the branch to the current end of the steps. + for (unsigned i = 0; i < branch_step_indices.size - 1; i++) { + uint32_t step_index = *array_get(&branch_step_indices, i); + uint32_t next_step_index = *array_get(&branch_step_indices, i + 1); + QueryStep *start_step = array_get(&self->steps, step_index); + QueryStep *end_step = array_get(&self->steps, next_step_index - 1); + start_step->alternative_index = next_step_index; + end_step->alternative_index = self->steps.size; + end_step->is_dead_end = true; + } + + capture_quantifiers_delete(&branch_capture_quantifiers); + array_delete(&branch_step_indices); + } + + // An open parenthesis can be the start of three possible constructs: + // * A grouped sequence + // * A predicate + // * A named node + else if (stream->next == '(') { + stream_advance(stream); + stream_skip_whitespace(stream); + + // If this parenthesis is followed by a node, then it represents a grouped sequence. + if (stream->next == '(' || stream->next == '"' || stream->next == '[') { + bool child_is_immediate = is_immediate; + CaptureQuantifiers child_capture_quantifiers = capture_quantifiers_new(); + for (;;) { + if (stream->next == '.') { + child_is_immediate = true; + stream_advance(stream); + stream_skip_whitespace(stream); + } + TSQueryError e = ts_query__parse_pattern( + self, + stream, + depth, + child_is_immediate, + &child_capture_quantifiers + ); + if (e == PARENT_DONE) { + if (stream->next == ')') { + stream_advance(stream); + break; + } + e = TSQueryErrorSyntax; + } + if (e) { + capture_quantifiers_delete(&child_capture_quantifiers); + return e; + } + + capture_quantifiers_add_all(capture_quantifiers, &child_capture_quantifiers); + capture_quantifiers_clear(&child_capture_quantifiers); + child_is_immediate = false; + } + + capture_quantifiers_delete(&child_capture_quantifiers); + } + + // A dot/pound character indicates the start of a predicate. + else if (stream->next == '.' || stream->next == '#') { + stream_advance(stream); + return ts_query__parse_predicate(self, stream); + } + + // Otherwise, this parenthesis is the start of a named node. + else { + TSSymbol symbol; + bool is_missing = false; + const char *node_name = stream->input; + + // Parse a normal node name + if (stream_is_ident_start(stream)) { + stream_scan_identifier(stream); + uint32_t length = (uint32_t)(stream->input - node_name); + + // Parse the wildcard symbol + if (length == 1 && node_name[0] == '_') { + symbol = WILDCARD_SYMBOL; + } else if (!strncmp(node_name, "MISSING", length)) { + is_missing = true; + stream_skip_whitespace(stream); + + if (stream_is_ident_start(stream)) { + const char *missing_node_name = stream->input; + stream_scan_identifier(stream); + uint32_t missing_node_length = (uint32_t)(stream->input - missing_node_name); + symbol = ts_language_symbol_for_name( + self->language, + missing_node_name, + missing_node_length, + true + ); + if (!symbol) { + stream_reset(stream, missing_node_name); + return TSQueryErrorNodeType; + } + } + + else if (stream->next == '"') { + const char *string_start = stream->input; + TSQueryError e = ts_query__parse_string_literal(self, stream); + if (e) return e; + + symbol = ts_language_symbol_for_name( + self->language, + self->string_buffer.contents, + self->string_buffer.size, + false + ); + if (!symbol) { + stream_reset(stream, string_start + 1); + return TSQueryErrorNodeType; + } + } + + else if (stream->next == ')') { + symbol = WILDCARD_SYMBOL; + } + + else { + stream_reset(stream, stream->input); + return TSQueryErrorSyntax; + } + } + + else { + symbol = ts_language_symbol_for_name( + self->language, + node_name, + length, + true + ); + if (!symbol) { + stream_reset(stream, node_name); + return TSQueryErrorNodeType; + } + } + } else { + return TSQueryErrorSyntax; + } + + // Add a step for the node. + array_push(&self->steps, query_step__new(symbol, depth, is_immediate)); + QueryStep *step = array_back(&self->steps); + if (ts_language_symbol_metadata(self->language, symbol).supertype) { + step->supertype_symbol = step->symbol; + step->symbol = WILDCARD_SYMBOL; + } + if (is_missing) { + step->is_missing = true; + } + if (symbol == WILDCARD_SYMBOL) { + step->is_named = true; + } + + stream_skip_whitespace(stream); + + if (stream->next == '/') { + if (!step->supertype_symbol) { + stream_reset(stream, node_name - 1); // reset to the start of the node + return TSQueryErrorStructure; + } + + stream_advance(stream); + if (!stream_is_ident_start(stream)) { + return TSQueryErrorSyntax; + } + + const char *subtype_node_name = stream->input; + stream_scan_identifier(stream); + uint32_t length = (uint32_t)(stream->input - subtype_node_name); + + step->symbol = ts_language_symbol_for_name( + self->language, + subtype_node_name, + length, + true + ); + if (!step->symbol) { + stream_reset(stream, subtype_node_name); + return TSQueryErrorNodeType; + } + + // Get all the possible subtypes for the given supertype, + // and check if the given subtype is valid. + if (self->language->abi_version >= LANGUAGE_VERSION_WITH_RESERVED_WORDS) { + uint32_t subtype_length; + const TSSymbol *subtypes = ts_language_subtypes( + self->language, + step->supertype_symbol, + &subtype_length + ); + + bool subtype_is_valid = false; + for (uint32_t i = 0; i < subtype_length; i++) { + if (subtypes[i] == step->symbol) { + subtype_is_valid = true; + break; + } + } + + // This subtype is not valid for the given supertype. + if (!subtype_is_valid) { + stream_reset(stream, node_name - 1); // reset to the start of the node + return TSQueryErrorStructure; + } + } + + stream_skip_whitespace(stream); + } + + // Parse the child patterns + bool child_is_immediate = false; + uint16_t last_child_step_index = 0; + uint16_t negated_field_count = 0; + TSFieldId negated_field_ids[MAX_NEGATED_FIELD_COUNT]; + CaptureQuantifiers child_capture_quantifiers = capture_quantifiers_new(); + for (;;) { + // Parse a negated field assertion + if (stream->next == '!') { + stream_advance(stream); + stream_skip_whitespace(stream); + if (!stream_is_ident_start(stream)) { + capture_quantifiers_delete(&child_capture_quantifiers); + return TSQueryErrorSyntax; + } + const char *field_name = stream->input; + stream_scan_identifier(stream); + uint32_t length = (uint32_t)(stream->input - field_name); + stream_skip_whitespace(stream); + + TSFieldId field_id = ts_language_field_id_for_name( + self->language, + field_name, + length + ); + if (!field_id) { + stream->input = field_name; + capture_quantifiers_delete(&child_capture_quantifiers); + return TSQueryErrorField; + } + + // Keep the field ids sorted. + if (negated_field_count < MAX_NEGATED_FIELD_COUNT) { + negated_field_ids[negated_field_count] = field_id; + negated_field_count++; + } + + continue; + } + + // Parse a sibling anchor + if (stream->next == '.') { + child_is_immediate = true; + stream_advance(stream); + stream_skip_whitespace(stream); + } + + uint16_t step_index = self->steps.size; + TSQueryError e = ts_query__parse_pattern( + self, + stream, + depth + 1, + child_is_immediate, + &child_capture_quantifiers + ); + // In the event we only parsed a predicate, meaning no new steps were added, + // then subtract one so we're not indexing past the end of the array + if (step_index == self->steps.size) step_index--; + if (e == PARENT_DONE) { + if (stream->next == ')') { + if (child_is_immediate) { + if (last_child_step_index == 0) { + capture_quantifiers_delete(&child_capture_quantifiers); + return TSQueryErrorSyntax; + } + // Mark this step *and* its alternatives as the last child of the parent. + QueryStep *last_child_step = array_get(&self->steps, last_child_step_index); + last_child_step->is_last_child = true; + if ( + last_child_step->alternative_index != NONE && + last_child_step->alternative_index < self->steps.size + ) { + QueryStep *alternative_step = array_get(&self->steps, last_child_step->alternative_index); + alternative_step->is_last_child = true; + while ( + alternative_step->alternative_index != NONE && + alternative_step->alternative_index < self->steps.size + ) { + alternative_step = array_get(&self->steps, alternative_step->alternative_index); + alternative_step->is_last_child = true; + } + } + } + + if (negated_field_count) { + ts_query__add_negated_fields( + self, + starting_step_index, + negated_field_ids, + negated_field_count + ); + } + + stream_advance(stream); + break; + } + e = TSQueryErrorSyntax; + } + if (e) { + capture_quantifiers_delete(&child_capture_quantifiers); + return e; + } + + capture_quantifiers_add_all(capture_quantifiers, &child_capture_quantifiers); + + last_child_step_index = step_index; + child_is_immediate = false; + capture_quantifiers_clear(&child_capture_quantifiers); + } + capture_quantifiers_delete(&child_capture_quantifiers); + } + } + + // Parse a wildcard pattern + else if (stream->next == '_') { + stream_advance(stream); + stream_skip_whitespace(stream); + + // Add a step that matches any kind of node + array_push(&self->steps, query_step__new(WILDCARD_SYMBOL, depth, is_immediate)); + } + + // Parse a double-quoted anonymous leaf node expression + else if (stream->next == '"') { + const char *string_start = stream->input; + TSQueryError e = ts_query__parse_string_literal(self, stream); + if (e) return e; + + // Add a step for the node + TSSymbol symbol = ts_language_symbol_for_name( + self->language, + self->string_buffer.contents, + self->string_buffer.size, + false + ); + if (!symbol) { + stream_reset(stream, string_start + 1); + return TSQueryErrorNodeType; + } + array_push(&self->steps, query_step__new(symbol, depth, is_immediate)); + } + + // Parse a field-prefixed pattern + else if (stream_is_ident_start(stream)) { + // Parse the field name + const char *field_name = stream->input; + stream_scan_identifier(stream); + uint32_t length = (uint32_t)(stream->input - field_name); + stream_skip_whitespace(stream); + + if (stream->next != ':') { + stream_reset(stream, field_name); + return TSQueryErrorSyntax; + } + stream_advance(stream); + stream_skip_whitespace(stream); + + // Parse the pattern + CaptureQuantifiers field_capture_quantifiers = capture_quantifiers_new(); + TSQueryError e = ts_query__parse_pattern( + self, + stream, + depth, + is_immediate, + &field_capture_quantifiers + ); + if (e) { + capture_quantifiers_delete(&field_capture_quantifiers); + if (e == PARENT_DONE) e = TSQueryErrorSyntax; + return e; + } + + // Add the field name to the first step of the pattern + TSFieldId field_id = ts_language_field_id_for_name( + self->language, + field_name, + length + ); + if (!field_id) { + stream->input = field_name; + return TSQueryErrorField; + } + + uint32_t step_index = starting_step_index; + QueryStep *step = array_get(&self->steps, step_index); + for (;;) { + step->field = field_id; + if ( + step->alternative_index != NONE && + step->alternative_index > step_index && + step->alternative_index < self->steps.size + ) { + step_index = step->alternative_index; + step = array_get(&self->steps, step_index); + } else { + break; + } + } + + capture_quantifiers_add_all(capture_quantifiers, &field_capture_quantifiers); + capture_quantifiers_delete(&field_capture_quantifiers); + } + + else { + return TSQueryErrorSyntax; + } + + stream_skip_whitespace(stream); + + // Parse suffixes modifiers for this pattern + TSQuantifier quantifier = TSQuantifierOne; + for (;;) { + // Parse the one-or-more operator. + if (stream->next == '+') { + quantifier = quantifier_join(TSQuantifierOneOrMore, quantifier); + + stream_advance(stream); + stream_skip_whitespace(stream); + + QueryStep repeat_step = query_step__new(WILDCARD_SYMBOL, depth, false); + repeat_step.alternative_index = starting_step_index; + repeat_step.is_pass_through = true; + repeat_step.alternative_is_immediate = true; + array_push(&self->steps, repeat_step); + } + + // Parse the zero-or-more repetition operator. + else if (stream->next == '*') { + quantifier = quantifier_join(TSQuantifierZeroOrMore, quantifier); + + stream_advance(stream); + stream_skip_whitespace(stream); + + QueryStep repeat_step = query_step__new(WILDCARD_SYMBOL, depth, false); + repeat_step.alternative_index = starting_step_index; + repeat_step.is_pass_through = true; + repeat_step.alternative_is_immediate = true; + array_push(&self->steps, repeat_step); + + // Stop when `step->alternative_index` is `NONE` or it points to + // `repeat_step` or beyond. Note that having just been pushed, + // `repeat_step` occupies slot `self->steps.size - 1`. + QueryStep *step = array_get(&self->steps, starting_step_index); + while (step->alternative_index != NONE && step->alternative_index < self->steps.size - 1) { + step = array_get(&self->steps, step->alternative_index); + } + step->alternative_index = self->steps.size; + } + + // Parse the optional operator. + else if (stream->next == '?') { + quantifier = quantifier_join(TSQuantifierZeroOrOne, quantifier); + + stream_advance(stream); + stream_skip_whitespace(stream); + + QueryStep *step = array_get(&self->steps, starting_step_index); + while (step->alternative_index != NONE && step->alternative_index < self->steps.size) { + step = array_get(&self->steps, step->alternative_index); + } + step->alternative_index = self->steps.size; + } + + // Parse an '@'-prefixed capture pattern + else if (stream->next == '@') { + stream_advance(stream); + if (!stream_is_ident_start(stream)) return TSQueryErrorSyntax; + const char *capture_name = stream->input; + stream_scan_identifier(stream); + uint32_t length = (uint32_t)(stream->input - capture_name); + stream_skip_whitespace(stream); + + // Add the capture id to the first step of the pattern + uint16_t capture_id = symbol_table_insert_name( + &self->captures, + capture_name, + length + ); + + // Add the capture quantifier + capture_quantifiers_add_for_id(capture_quantifiers, capture_id, TSQuantifierOne); + + uint32_t step_index = starting_step_index; + for (;;) { + QueryStep *step = array_get(&self->steps, step_index); + query_step__add_capture(step, capture_id); + if ( + step->alternative_index != NONE && + step->alternative_index > step_index && + step->alternative_index < self->steps.size + ) { + step_index = step->alternative_index; + } else { + break; + } + } + } + + // No more suffix modifiers + else { + break; + } + } + + capture_quantifiers_mul(capture_quantifiers, quantifier); + + return 0; +} + +TSQuery *ts_query_new( + const TSLanguage *language, + const char *source, + uint32_t source_len, + uint32_t *error_offset, + TSQueryError *error_type +) { + if ( + !language || + language->abi_version > TREE_SITTER_LANGUAGE_VERSION || + language->abi_version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION + ) { + *error_type = TSQueryErrorLanguage; + return NULL; + } + + TSQuery *self = ts_malloc(sizeof(TSQuery)); + *self = (TSQuery) { + .steps = array_new(), + .pattern_map = array_new(), + .captures = symbol_table_new(), + .capture_quantifiers = array_new(), + .predicate_values = symbol_table_new(), + .predicate_steps = array_new(), + .patterns = array_new(), + .step_offsets = array_new(), + .string_buffer = array_new(), + .negated_fields = array_new(), + .repeat_symbols_with_rootless_patterns = array_new(), + .wildcard_root_pattern_count = 0, + .language = ts_language_copy(language), + }; + + array_push(&self->negated_fields, 0); + + // Parse all of the S-expressions in the given string. + Stream stream = stream_new(source, source_len); + stream_skip_whitespace(&stream); + while (stream.input < stream.end) { + uint32_t pattern_index = self->patterns.size; + uint32_t start_step_index = self->steps.size; + uint32_t start_predicate_step_index = self->predicate_steps.size; + array_push(&self->patterns, ((QueryPattern) { + .steps = (Slice) {.offset = start_step_index}, + .predicate_steps = (Slice) {.offset = start_predicate_step_index}, + .start_byte = stream_offset(&stream), + .is_non_local = false, + })); + CaptureQuantifiers capture_quantifiers = capture_quantifiers_new(); + *error_type = ts_query__parse_pattern(self, &stream, 0, false, &capture_quantifiers); + array_push(&self->steps, query_step__new(0, PATTERN_DONE_MARKER, false)); + + QueryPattern *pattern = array_back(&self->patterns); + pattern->steps.length = self->steps.size - start_step_index; + pattern->predicate_steps.length = self->predicate_steps.size - start_predicate_step_index; + pattern->end_byte = stream_offset(&stream); + + // If any pattern could not be parsed, then report the error information + // and terminate. + if (*error_type) { + if (*error_type == PARENT_DONE) *error_type = TSQueryErrorSyntax; + *error_offset = stream_offset(&stream); + capture_quantifiers_delete(&capture_quantifiers); + ts_query_delete(self); + return NULL; + } + + // Maintain a list of capture quantifiers for each pattern + array_push(&self->capture_quantifiers, capture_quantifiers); + + // Maintain a map that can look up patterns for a given root symbol. + uint16_t wildcard_root_alternative_index = NONE; + for (;;) { + QueryStep *step = array_get(&self->steps, start_step_index); + + // If a pattern has a wildcard at its root, but it has a non-wildcard child, + // then optimize the matching process by skipping matching the wildcard. + // Later, during the matching process, the query cursor will check that + // there is a parent node, and capture it if necessary. + if (step->symbol == WILDCARD_SYMBOL && step->depth == 0 && !step->field) { + QueryStep *second_step = array_get(&self->steps, start_step_index + 1); + if (second_step->symbol != WILDCARD_SYMBOL && second_step->depth == 1 && !second_step->is_immediate) { + wildcard_root_alternative_index = step->alternative_index; + start_step_index += 1; + step = second_step; + } + } + + // Determine whether the pattern has a single root node. This affects + // decisions about whether or not to start matching the pattern when + // a query cursor has a range restriction or when immediately within an + // error node. + uint32_t start_depth = step->depth; + bool is_rooted = start_depth == 0; + for (uint32_t step_index = start_step_index + 1; step_index < self->steps.size; step_index++) { + QueryStep *child_step = array_get(&self->steps, step_index); + if (child_step->is_dead_end) break; + if (child_step->depth == start_depth) { + is_rooted = false; + break; + } + } + + ts_query__pattern_map_insert(self, step->symbol, (PatternEntry) { + .step_index = start_step_index, + .pattern_index = pattern_index, + .is_rooted = is_rooted + }); + if (step->symbol == WILDCARD_SYMBOL) { + self->wildcard_root_pattern_count++; + } + + // If there are alternatives or options at the root of the pattern, + // then add multiple entries to the pattern map. + if (step->alternative_index != NONE) { + start_step_index = step->alternative_index; + } else if (wildcard_root_alternative_index != NONE) { + start_step_index = wildcard_root_alternative_index; + wildcard_root_alternative_index = NONE; + } else { + break; + } + } + } + + if (!ts_query__analyze_patterns(self, error_offset)) { + *error_type = TSQueryErrorStructure; + ts_query_delete(self); + return NULL; + } + + array_delete(&self->string_buffer); + return self; +} + +void ts_query_delete(TSQuery *self) { + if (self) { + array_delete(&self->steps); + array_delete(&self->pattern_map); + array_delete(&self->predicate_steps); + array_delete(&self->patterns); + array_delete(&self->step_offsets); + array_delete(&self->string_buffer); + array_delete(&self->negated_fields); + array_delete(&self->repeat_symbols_with_rootless_patterns); + ts_language_delete(self->language); + symbol_table_delete(&self->captures); + symbol_table_delete(&self->predicate_values); + for (uint32_t index = 0; index < self->capture_quantifiers.size; index++) { + CaptureQuantifiers *capture_quantifiers = array_get(&self->capture_quantifiers, index); + capture_quantifiers_delete(capture_quantifiers); + } + array_delete(&self->capture_quantifiers); + ts_free(self); + } +} + +uint32_t ts_query_pattern_count(const TSQuery *self) { + return self->patterns.size; +} + +uint32_t ts_query_capture_count(const TSQuery *self) { + return self->captures.slices.size; +} + +uint32_t ts_query_string_count(const TSQuery *self) { + return self->predicate_values.slices.size; +} + +const char *ts_query_capture_name_for_id( + const TSQuery *self, + uint32_t index, + uint32_t *length +) { + return symbol_table_name_for_id(&self->captures, index, length); +} + +TSQuantifier ts_query_capture_quantifier_for_id( + const TSQuery *self, + uint32_t pattern_index, + uint32_t capture_index +) { + CaptureQuantifiers *capture_quantifiers = array_get(&self->capture_quantifiers, pattern_index); + return capture_quantifier_for_id(capture_quantifiers, capture_index); +} + +const char *ts_query_string_value_for_id( + const TSQuery *self, + uint32_t index, + uint32_t *length +) { + return symbol_table_name_for_id(&self->predicate_values, index, length); +} + +const TSQueryPredicateStep *ts_query_predicates_for_pattern( + const TSQuery *self, + uint32_t pattern_index, + uint32_t *step_count +) { + Slice slice = array_get(&self->patterns, pattern_index)->predicate_steps; + *step_count = slice.length; + if (slice.length == 0) return NULL; + return array_get(&self->predicate_steps, slice.offset); +} + +uint32_t ts_query_start_byte_for_pattern( + const TSQuery *self, + uint32_t pattern_index +) { + return array_get(&self->patterns, pattern_index)->start_byte; +} + +uint32_t ts_query_end_byte_for_pattern( + const TSQuery *self, + uint32_t pattern_index +) { + return array_get(&self->patterns, pattern_index)->end_byte; +} + +bool ts_query_is_pattern_rooted( + const TSQuery *self, + uint32_t pattern_index +) { + for (unsigned i = 0; i < self->pattern_map.size; i++) { + PatternEntry *entry = array_get(&self->pattern_map, i); + if (entry->pattern_index == pattern_index) { + if (!entry->is_rooted) return false; + } + } + return true; +} + +bool ts_query_is_pattern_non_local( + const TSQuery *self, + uint32_t pattern_index +) { + if (pattern_index < self->patterns.size) { + return array_get(&self->patterns, pattern_index)->is_non_local; + } else { + return false; + } +} + +bool ts_query_is_pattern_guaranteed_at_step( + const TSQuery *self, + uint32_t byte_offset +) { + uint32_t step_index = UINT32_MAX; + for (unsigned i = 0; i < self->step_offsets.size; i++) { + StepOffset *step_offset = array_get(&self->step_offsets, i); + if (step_offset->byte_offset > byte_offset) break; + step_index = step_offset->step_index; + } + if (step_index < self->steps.size) { + return array_get(&self->steps, step_index)->root_pattern_guaranteed; + } else { + return false; + } +} + +bool ts_query__step_is_fallible( + const TSQuery *self, + uint16_t step_index +) { + ts_assert((uint32_t)step_index + 1 < self->steps.size); + QueryStep *step = array_get(&self->steps, step_index); + QueryStep *next_step = array_get(&self->steps, step_index + 1); + return ( + next_step->depth != PATTERN_DONE_MARKER && + next_step->depth > step->depth && + (!next_step->parent_pattern_guaranteed || step->symbol == WILDCARD_SYMBOL) + ); +} + +void ts_query_disable_capture( + TSQuery *self, + const char *name, + uint32_t length +) { + // Remove capture information for any pattern step that previously + // captured with the given name. + int id = symbol_table_id_for_name(&self->captures, name, length); + if (id != -1) { + for (unsigned i = 0; i < self->steps.size; i++) { + QueryStep *step = array_get(&self->steps, i); + query_step__remove_capture(step, id); + } + } +} + +void ts_query_disable_pattern( + TSQuery *self, + uint32_t pattern_index +) { + // Remove the given pattern from the pattern map. Its steps will still + // be in the `steps` array, but they will never be read. + for (unsigned i = 0; i < self->pattern_map.size; i++) { + PatternEntry *pattern = array_get(&self->pattern_map, i); + if (pattern->pattern_index == pattern_index) { + array_erase(&self->pattern_map, i); + i--; + } + } +} + +/*************** + * QueryCursor + ***************/ + +TSQueryCursor *ts_query_cursor_new(void) { + TSQueryCursor *self = ts_malloc(sizeof(TSQueryCursor)); + *self = (TSQueryCursor) { + .did_exceed_match_limit = false, + .ascending = false, + .halted = false, + .states = array_new(), + .finished_states = array_new(), + .capture_list_pool = capture_list_pool_new(), + .start_byte = 0, + .end_byte = UINT32_MAX, + .start_point = {0, 0}, + .end_point = POINT_MAX, + .max_start_depth = UINT32_MAX, + .timeout_duration = 0, + .end_clock = clock_null(), + .operation_count = 0, + }; + array_reserve(&self->states, 8); + array_reserve(&self->finished_states, 8); + return self; +} + +void ts_query_cursor_delete(TSQueryCursor *self) { + array_delete(&self->states); + array_delete(&self->finished_states); + ts_tree_cursor_delete(&self->cursor); + capture_list_pool_delete(&self->capture_list_pool); + ts_free(self); +} + +bool ts_query_cursor_did_exceed_match_limit(const TSQueryCursor *self) { + return self->did_exceed_match_limit; +} + +uint32_t ts_query_cursor_match_limit(const TSQueryCursor *self) { + return self->capture_list_pool.max_capture_list_count; +} + +void ts_query_cursor_set_match_limit(TSQueryCursor *self, uint32_t limit) { + self->capture_list_pool.max_capture_list_count = limit; +} + +uint64_t ts_query_cursor_timeout_micros(const TSQueryCursor *self) { + return duration_to_micros(self->timeout_duration); +} + +void ts_query_cursor_set_timeout_micros(TSQueryCursor *self, uint64_t timeout_micros) { + self->timeout_duration = duration_from_micros(timeout_micros); +} + +#ifdef DEBUG_EXECUTE_QUERY +#define LOG(...) fprintf(stderr, __VA_ARGS__) +#else +#define LOG(...) +#endif + +void ts_query_cursor_exec( + TSQueryCursor *self, + const TSQuery *query, + TSNode node +) { + if (query) { + LOG("query steps:\n"); + for (unsigned i = 0; i < query->steps.size; i++) { + QueryStep *step = array_get(&query->steps, i); + LOG(" %u: {", i); + if (step->depth == PATTERN_DONE_MARKER) { + LOG("DONE"); + } else if (step->is_dead_end) { + LOG("dead_end"); + } else if (step->is_pass_through) { + LOG("pass_through"); + } else if (step->symbol != WILDCARD_SYMBOL) { + LOG("symbol: %s", query->language->symbol_names[step->symbol]); + } else { + LOG("symbol: *"); + } + if (step->field) { + LOG(", field: %s", query->language->field_names[step->field]); + } + if (step->alternative_index != NONE) { + LOG(", alternative: %u", step->alternative_index); + } + LOG("},\n"); + } + } + + array_clear(&self->states); + array_clear(&self->finished_states); + ts_tree_cursor_reset(&self->cursor, node); + capture_list_pool_reset(&self->capture_list_pool); + self->on_visible_node = true; + self->next_state_id = 0; + self->depth = 0; + self->ascending = false; + self->halted = false; + self->query = query; + self->did_exceed_match_limit = false; + self->operation_count = 0; + if (self->timeout_duration) { + self->end_clock = clock_after(clock_now(), self->timeout_duration); + } else { + self->end_clock = clock_null(); + } + self->query_options = NULL; + self->query_state = (TSQueryCursorState) {0}; +} + +void ts_query_cursor_exec_with_options( + TSQueryCursor *self, + const TSQuery *query, + TSNode node, + const TSQueryCursorOptions *query_options +) { + ts_query_cursor_exec(self, query, node); + if (query_options) { + self->query_options = query_options; + self->query_state = (TSQueryCursorState) { + .payload = query_options->payload + }; + } +} + +bool ts_query_cursor_set_byte_range( + TSQueryCursor *self, + uint32_t start_byte, + uint32_t end_byte +) { + if (end_byte == 0) { + end_byte = UINT32_MAX; + } + if (start_byte > end_byte) { + return false; + } + self->start_byte = start_byte; + self->end_byte = end_byte; + return true; +} + +bool ts_query_cursor_set_point_range( + TSQueryCursor *self, + TSPoint start_point, + TSPoint end_point +) { + if (end_point.row == 0 && end_point.column == 0) { + end_point = POINT_MAX; + } + if (point_gt(start_point, end_point)) { + return false; + } + self->start_point = start_point; + self->end_point = end_point; + return true; +} + +// Search through all of the in-progress states, and find the captured +// node that occurs earliest in the document. +static bool ts_query_cursor__first_in_progress_capture( + TSQueryCursor *self, + uint32_t *state_index, + uint32_t *byte_offset, + uint32_t *pattern_index, + bool *is_definite +) { + bool result = false; + *state_index = UINT32_MAX; + *byte_offset = UINT32_MAX; + *pattern_index = UINT32_MAX; + for (unsigned i = 0; i < self->states.size; i++) { + QueryState *state = array_get(&self->states, i); + if (state->dead) continue; + + const CaptureList *captures = capture_list_pool_get( + &self->capture_list_pool, + state->capture_list_id + ); + if (state->consumed_capture_count >= captures->size) { + continue; + } + + TSNode node = array_get(captures, state->consumed_capture_count)->node; + if ( + ts_node_end_byte(node) <= self->start_byte || + point_lte(ts_node_end_point(node), self->start_point) + ) { + state->consumed_capture_count++; + i--; + continue; + } + + uint32_t node_start_byte = ts_node_start_byte(node); + if ( + !result || + node_start_byte < *byte_offset || + (node_start_byte == *byte_offset && state->pattern_index < *pattern_index) + ) { + QueryStep *step = array_get(&self->query->steps, state->step_index); + if (is_definite) { + // We're being a bit conservative here by asserting that the following step + // is not immediate, because this capture might end up being discarded if the + // following symbol in the tree isn't the required symbol for this step. + *is_definite = step->root_pattern_guaranteed && !step->is_immediate; + } else if (step->root_pattern_guaranteed) { + continue; + } + + result = true; + *state_index = i; + *byte_offset = node_start_byte; + *pattern_index = state->pattern_index; + } + } + return result; +} + +// Determine which node is first in a depth-first traversal +int ts_query_cursor__compare_nodes(TSNode left, TSNode right) { + if (left.id != right.id) { + uint32_t left_start = ts_node_start_byte(left); + uint32_t right_start = ts_node_start_byte(right); + if (left_start < right_start) return -1; + if (left_start > right_start) return 1; + uint32_t left_node_count = ts_node_end_byte(left); + uint32_t right_node_count = ts_node_end_byte(right); + if (left_node_count > right_node_count) return -1; + if (left_node_count < right_node_count) return 1; + } + return 0; +} + +// Determine if either state contains a superset of the other state's captures. +void ts_query_cursor__compare_captures( + TSQueryCursor *self, + QueryState *left_state, + QueryState *right_state, + bool *left_contains_right, + bool *right_contains_left +) { + const CaptureList *left_captures = capture_list_pool_get( + &self->capture_list_pool, + left_state->capture_list_id + ); + const CaptureList *right_captures = capture_list_pool_get( + &self->capture_list_pool, + right_state->capture_list_id + ); + *left_contains_right = true; + *right_contains_left = true; + unsigned i = 0, j = 0; + for (;;) { + if (i < left_captures->size) { + if (j < right_captures->size) { + TSQueryCapture *left = array_get(left_captures, i); + TSQueryCapture *right = array_get(right_captures, j); + if (left->node.id == right->node.id && left->index == right->index) { + i++; + j++; + } else { + switch (ts_query_cursor__compare_nodes(left->node, right->node)) { + case -1: + *right_contains_left = false; + i++; + break; + case 1: + *left_contains_right = false; + j++; + break; + default: + *right_contains_left = false; + *left_contains_right = false; + i++; + j++; + break; + } + } + } else { + *right_contains_left = false; + break; + } + } else { + if (j < right_captures->size) { + *left_contains_right = false; + } + break; + } + } +} + +static void ts_query_cursor__add_state( + TSQueryCursor *self, + const PatternEntry *pattern +) { + QueryStep *step = array_get(&self->query->steps, pattern->step_index); + uint32_t start_depth = self->depth - step->depth; + + // Keep the states array in ascending order of start_depth and pattern_index, + // so that it can be processed more efficiently elsewhere. Usually, there is + // no work to do here because of two facts: + // * States with lower start_depth are naturally added first due to the + // order in which nodes are visited. + // * Earlier patterns are naturally added first because of the ordering of the + // pattern_map data structure that's used to initiate matches. + // + // This loop is only needed in cases where two conditions hold: + // * A pattern consists of more than one sibling node, so that its states + // remain in progress after exiting the node that started the match. + // * The first node in the pattern matches against multiple nodes at the + // same depth. + // + // An example of this is the pattern '((comment)* (function))'. If multiple + // `comment` nodes appear in a row, then we may initiate a new state for this + // pattern while another state for the same pattern is already in progress. + // If there are multiple patterns like this in a query, then this loop will + // need to execute in order to keep the states ordered by pattern_index. + uint32_t index = self->states.size; + while (index > 0) { + QueryState *prev_state = array_get(&self->states, index - 1); + if (prev_state->start_depth < start_depth) break; + if (prev_state->start_depth == start_depth) { + // Avoid inserting an unnecessary duplicate state, which would be + // immediately pruned by the longest-match criteria. + if ( + prev_state->pattern_index == pattern->pattern_index && + prev_state->step_index == pattern->step_index + ) return; + if (prev_state->pattern_index <= pattern->pattern_index) break; + } + index--; + } + + LOG( + " start state. pattern:%u, step:%u\n", + pattern->pattern_index, + pattern->step_index + ); + array_insert(&self->states, index, ((QueryState) { + .id = UINT32_MAX, + .capture_list_id = NONE, + .step_index = pattern->step_index, + .pattern_index = pattern->pattern_index, + .start_depth = start_depth, + .consumed_capture_count = 0, + .seeking_immediate_match = true, + .has_in_progress_alternatives = false, + .needs_parent = step->depth == 1, + .dead = false, + })); +} + +// Acquire a capture list for this state. If there are no capture lists left in the +// pool, this will steal the capture list from another existing state, and mark that +// other state as 'dead'. +static CaptureList *ts_query_cursor__prepare_to_capture( + TSQueryCursor *self, + QueryState *state, + unsigned state_index_to_preserve +) { + if (state->capture_list_id == NONE) { + state->capture_list_id = capture_list_pool_acquire(&self->capture_list_pool); + + // If there are no capture lists left in the pool, then terminate whichever + // state has captured the earliest node in the document, and steal its + // capture list. + if (state->capture_list_id == NONE) { + self->did_exceed_match_limit = true; + uint32_t state_index, byte_offset, pattern_index; + if ( + ts_query_cursor__first_in_progress_capture( + self, + &state_index, + &byte_offset, + &pattern_index, + NULL + ) && + state_index != state_index_to_preserve + ) { + LOG( + " abandon state. index:%u, pattern:%u, offset:%u.\n", + state_index, pattern_index, byte_offset + ); + QueryState *other_state = array_get(&self->states, state_index); + state->capture_list_id = other_state->capture_list_id; + other_state->capture_list_id = NONE; + other_state->dead = true; + CaptureList *list = capture_list_pool_get_mut( + &self->capture_list_pool, + state->capture_list_id + ); + array_clear(list); + return list; + } else { + LOG(" ran out of capture lists"); + return NULL; + } + } + } + return capture_list_pool_get_mut(&self->capture_list_pool, state->capture_list_id); +} + +static void ts_query_cursor__capture( + TSQueryCursor *self, + QueryState *state, + QueryStep *step, + TSNode node +) { + if (state->dead) return; + CaptureList *capture_list = ts_query_cursor__prepare_to_capture(self, state, UINT32_MAX); + if (!capture_list) { + state->dead = true; + return; + } + + for (unsigned j = 0; j < MAX_STEP_CAPTURE_COUNT; j++) { + uint16_t capture_id = step->capture_ids[j]; + if (step->capture_ids[j] == NONE) break; + array_push(capture_list, ((TSQueryCapture) { node, capture_id })); + LOG( + " capture node. type:%s, pattern:%u, capture_id:%u, capture_count:%u\n", + ts_node_type(node), + state->pattern_index, + capture_id, + capture_list->size + ); + } +} + +// Duplicate the given state and insert the newly-created state immediately after +// the given state in the `states` array. Ensures that the given state reference is +// still valid, even if the states array is reallocated. +static QueryState *ts_query_cursor__copy_state( + TSQueryCursor *self, + QueryState **state_ref +) { + const QueryState *state = *state_ref; + uint32_t state_index = (uint32_t)(state - self->states.contents); + QueryState copy = *state; + copy.capture_list_id = NONE; + + // If the state has captures, copy its capture list. + if (state->capture_list_id != NONE) { + CaptureList *new_captures = ts_query_cursor__prepare_to_capture(self, ©, state_index); + if (!new_captures) return NULL; + const CaptureList *old_captures = capture_list_pool_get( + &self->capture_list_pool, + state->capture_list_id + ); + array_push_all(new_captures, old_captures); + } + + array_insert(&self->states, state_index + 1, copy); + *state_ref = array_get(&self->states, state_index); + return array_get(&self->states, state_index + 1); +} + +static inline bool ts_query_cursor__should_descend( + TSQueryCursor *self, + bool node_intersects_range +) { + + if (node_intersects_range && self->depth < self->max_start_depth) { + return true; + } + + // If there are in-progress matches whose remaining steps occur + // deeper in the tree, then descend. + for (unsigned i = 0; i < self->states.size; i++) { + QueryState *state = array_get(&self->states, i); + QueryStep *next_step = array_get(&self->query->steps, state->step_index); + if ( + next_step->depth != PATTERN_DONE_MARKER && + state->start_depth + next_step->depth > self->depth + ) { + return true; + } + } + + if (self->depth >= self->max_start_depth) { + return false; + } + + // If the current node is hidden, then a non-rooted pattern might match + // one if its roots inside of this node, and match another of its roots + // as part of a sibling node, so we may need to descend. + if (!self->on_visible_node) { + // Descending into a repetition node outside of the range can be + // expensive, because these nodes can have many visible children. + // Avoid descending into repetition nodes unless we have already + // determined that this query can match rootless patterns inside + // of this type of repetition node. + Subtree subtree = ts_tree_cursor_current_subtree(&self->cursor); + if (ts_subtree_is_repetition(subtree)) { + bool exists; + uint32_t index; + array_search_sorted_by( + &self->query->repeat_symbols_with_rootless_patterns,, + ts_subtree_symbol(subtree), + &index, + &exists + ); + return exists; + } + + return true; + } + + return false; +} + +// Walk the tree, processing patterns until at least one pattern finishes, +// If one or more patterns finish, return `true` and store their states in the +// `finished_states` array. Multiple patterns can finish on the same node. If +// there are no more matches, return `false`. +static inline bool ts_query_cursor__advance( + TSQueryCursor *self, + bool stop_on_definite_step +) { + bool did_match = false; + for (;;) { + if (self->halted) { + while (self->states.size > 0) { + QueryState state = array_pop(&self->states); + capture_list_pool_release( + &self->capture_list_pool, + state.capture_list_id + ); + } + } + + if (++self->operation_count == OP_COUNT_PER_QUERY_TIMEOUT_CHECK) { + self->operation_count = 0; + } + + if (self->query_options && self->query_options->progress_callback) { + self->query_state.current_byte_offset = ts_node_start_byte(ts_tree_cursor_current_node(&self->cursor)); + } + if ( + did_match || + self->halted || + ( + self->operation_count == 0 && + ( + (!clock_is_null(self->end_clock) && clock_is_gt(clock_now(), self->end_clock)) || + (self->query_options && self->query_options->progress_callback && self->query_options->progress_callback(&self->query_state)) + ) + ) + ) { + return did_match; + } + + // Exit the current node. + if (self->ascending) { + if (self->on_visible_node) { + LOG( + "leave node. depth:%u, type:%s\n", + self->depth, + ts_node_type(ts_tree_cursor_current_node(&self->cursor)) + ); + + // After leaving a node, remove any states that cannot make further progress. + uint32_t deleted_count = 0; + for (unsigned i = 0, n = self->states.size; i < n; i++) { + QueryState *state = array_get(&self->states, i); + QueryStep *step = array_get(&self->query->steps, state->step_index); + + // If a state completed its pattern inside of this node, but was deferred from finishing + // in order to search for longer matches, mark it as finished. + if ( + step->depth == PATTERN_DONE_MARKER && + (state->start_depth > self->depth || self->depth == 0) + ) { + LOG(" finish pattern %u\n", state->pattern_index); + array_push(&self->finished_states, *state); + did_match = true; + deleted_count++; + } + + // If a state needed to match something within this node, then remove that state + // as it has failed to match. + else if ( + step->depth != PATTERN_DONE_MARKER && + (uint32_t)state->start_depth + (uint32_t)step->depth > self->depth + ) { + LOG( + " failed to match. pattern:%u, step:%u\n", + state->pattern_index, + state->step_index + ); + capture_list_pool_release( + &self->capture_list_pool, + state->capture_list_id + ); + deleted_count++; + } + + else if (deleted_count > 0) { + *array_get(&self->states, i - deleted_count) = *state; + } + } + self->states.size -= deleted_count; + } + + // Leave this node by stepping to its next sibling or to its parent. + switch (ts_tree_cursor_goto_next_sibling_internal(&self->cursor)) { + case TreeCursorStepVisible: + if (!self->on_visible_node) { + self->depth++; + self->on_visible_node = true; + } + self->ascending = false; + break; + case TreeCursorStepHidden: + if (self->on_visible_node) { + self->depth--; + self->on_visible_node = false; + } + self->ascending = false; + break; + default: + if (ts_tree_cursor_goto_parent(&self->cursor)) { + self->depth--; + } else { + LOG("halt at root\n"); + self->halted = true; + } + } + } + + // Enter a new node. + else { + // Get the properties of the current node. + TSNode node = ts_tree_cursor_current_node(&self->cursor); + TSNode parent_node = ts_tree_cursor_parent_node(&self->cursor); + + uint32_t start_byte = ts_node_start_byte(node); + uint32_t end_byte = ts_node_end_byte(node); + TSPoint start_point = ts_node_start_point(node); + TSPoint end_point = ts_node_end_point(node); + bool is_empty = start_byte == end_byte; + + bool parent_precedes_range = !ts_node_is_null(parent_node) && ( + ts_node_end_byte(parent_node) <= self->start_byte || + point_lte(ts_node_end_point(parent_node), self->start_point) + ); + bool parent_follows_range = !ts_node_is_null(parent_node) && ( + ts_node_start_byte(parent_node) >= self->end_byte || + point_gte(ts_node_start_point(parent_node), self->end_point) + ); + bool node_precedes_range = + parent_precedes_range || + end_byte < self->start_byte || + point_lt(end_point, self->start_point) || + (!is_empty && end_byte == self->start_byte) || + (!is_empty && point_eq(end_point, self->start_point)); + + bool node_follows_range = parent_follows_range || ( + start_byte >= self->end_byte || + point_gte(start_point, self->end_point) + ); + bool parent_intersects_range = !parent_precedes_range && !parent_follows_range; + bool node_intersects_range = !node_precedes_range && !node_follows_range; + + if (self->on_visible_node) { + TSSymbol symbol = ts_node_symbol(node); + bool is_named = ts_node_is_named(node); + bool is_missing = ts_node_is_missing(node); + bool has_later_siblings; + bool has_later_named_siblings; + bool can_have_later_siblings_with_this_field; + TSFieldId field_id = 0; + TSSymbol supertypes[8] = {0}; + unsigned supertype_count = 8; + ts_tree_cursor_current_status( + &self->cursor, + &field_id, + &has_later_siblings, + &has_later_named_siblings, + &can_have_later_siblings_with_this_field, + supertypes, + &supertype_count + ); + LOG( + "enter node. depth:%u, type:%s, field:%s, row:%u state_count:%u, finished_state_count:%u\n", + self->depth, + ts_node_type(node), + ts_language_field_name_for_id(self->query->language, field_id), + ts_node_start_point(node).row, + self->states.size, + self->finished_states.size + ); + + bool node_is_error = symbol == ts_builtin_sym_error; + bool parent_is_error = + !ts_node_is_null(parent_node) && + ts_node_symbol(parent_node) == ts_builtin_sym_error; + + // Add new states for any patterns whose root node is a wildcard. + if (!node_is_error) { + for (unsigned i = 0; i < self->query->wildcard_root_pattern_count; i++) { + PatternEntry *pattern = array_get(&self->query->pattern_map, i); + + // If this node matches the first step of the pattern, then add a new + // state at the start of this pattern. + QueryStep *step = array_get(&self->query->steps, pattern->step_index); + uint32_t start_depth = self->depth - step->depth; + if ( + (pattern->is_rooted ? + node_intersects_range : + (parent_intersects_range && !parent_is_error)) && + (!step->field || field_id == step->field) && + (!step->supertype_symbol || supertype_count > 0) && + (start_depth <= self->max_start_depth) + ) { + ts_query_cursor__add_state(self, pattern); + } + } + } + + // Add new states for any patterns whose root node matches this node. + unsigned i; + if (ts_query__pattern_map_search(self->query, symbol, &i)) { + PatternEntry *pattern = array_get(&self->query->pattern_map, i); + + QueryStep *step = array_get(&self->query->steps, pattern->step_index); + uint32_t start_depth = self->depth - step->depth; + do { + // If this node matches the first step of the pattern, then add a new + // state at the start of this pattern. + if ( + (pattern->is_rooted ? + node_intersects_range : + (parent_intersects_range && !parent_is_error)) && + (!step->field || field_id == step->field) && + (start_depth <= self->max_start_depth) + ) { + ts_query_cursor__add_state(self, pattern); + } + + // Advance to the next pattern whose root node matches this node. + i++; + if (i == self->query->pattern_map.size) break; + pattern = array_get(&self->query->pattern_map, i); + step = array_get(&self->query->steps, pattern->step_index); + } while (step->symbol == symbol); + } + + // Update all of the in-progress states with current node. + for (unsigned j = 0, copy_count = 0; j < self->states.size; j += 1 + copy_count) { + QueryState *state = array_get(&self->states, j); + QueryStep *step = array_get(&self->query->steps, state->step_index); + state->has_in_progress_alternatives = false; + copy_count = 0; + + // Check that the node matches all of the criteria for the next + // step of the pattern. + if ((uint32_t)state->start_depth + (uint32_t)step->depth != self->depth) continue; + + // Determine if this node matches this step of the pattern, and also + // if this node can have later siblings that match this step of the + // pattern. + bool node_does_match = false; + if (step->symbol == WILDCARD_SYMBOL) { + if (step->is_missing) { + node_does_match = is_missing; + } else { + node_does_match = !node_is_error && (is_named || !step->is_named); + } + } else { + node_does_match = symbol == step->symbol && (!step->is_missing || is_missing); + } + bool later_sibling_can_match = has_later_siblings; + if ((step->is_immediate && is_named) || state->seeking_immediate_match) { + later_sibling_can_match = false; + } + if (step->is_last_child && has_later_named_siblings) { + node_does_match = false; + } + if (step->supertype_symbol) { + bool has_supertype = false; + for (unsigned k = 0; k < supertype_count; k++) { + if (supertypes[k] == step->supertype_symbol) { + has_supertype = true; + break; + } + } + if (!has_supertype) node_does_match = false; + } + if (step->field) { + if (step->field == field_id) { + if (!can_have_later_siblings_with_this_field) { + later_sibling_can_match = false; + } + } else { + node_does_match = false; + } + } + + if (step->negated_field_list_id) { + TSFieldId *negated_field_ids = array_get(&self->query->negated_fields, step->negated_field_list_id); + for (;;) { + TSFieldId negated_field_id = *negated_field_ids; + if (negated_field_id) { + negated_field_ids++; + if (ts_node_child_by_field_id(node, negated_field_id).id) { + node_does_match = false; + break; + } + } else { + break; + } + } + } + + // Remove states immediately if it is ever clear that they cannot match. + if (!node_does_match) { + if (!later_sibling_can_match) { + LOG( + " discard state. pattern:%u, step:%u\n", + state->pattern_index, + state->step_index + ); + capture_list_pool_release( + &self->capture_list_pool, + state->capture_list_id + ); + array_erase(&self->states, j); + j--; + } + continue; + } + + // Some patterns can match their root node in multiple ways, capturing different + // children. If this pattern step could match later children within the same + // parent, then this query state cannot simply be updated in place. It must be + // split into two states: one that matches this node, and one which skips over + // this node, to preserve the possibility of matching later siblings. + if (later_sibling_can_match && ( + step->contains_captures || + ts_query__step_is_fallible(self->query, state->step_index) + )) { + if (ts_query_cursor__copy_state(self, &state)) { + LOG( + " split state for capture. pattern:%u, step:%u\n", + state->pattern_index, + state->step_index + ); + copy_count++; + } + } + + // If this pattern started with a wildcard, such that the pattern map + // actually points to the *second* step of the pattern, then check + // that the node has a parent, and capture the parent node if necessary. + if (state->needs_parent) { + TSNode parent = ts_tree_cursor_parent_node(&self->cursor); + if (ts_node_is_null(parent)) { + LOG(" missing parent node\n"); + state->dead = true; + } else { + state->needs_parent = false; + QueryStep *skipped_wildcard_step = step; + do { + skipped_wildcard_step--; + } while ( + skipped_wildcard_step->is_dead_end || + skipped_wildcard_step->is_pass_through || + skipped_wildcard_step->depth > 0 + ); + if (skipped_wildcard_step->capture_ids[0] != NONE) { + LOG(" capture wildcard parent\n"); + ts_query_cursor__capture( + self, + state, + skipped_wildcard_step, + parent + ); + } + } + } + + // If the current node is captured in this pattern, add it to the capture list. + if (step->capture_ids[0] != NONE) { + ts_query_cursor__capture(self, state, step, node); + } + + if (state->dead) { + array_erase(&self->states, j); + j--; + continue; + } + + // Advance this state to the next step of its pattern. + state->step_index++; + LOG( + " advance state. pattern:%u, step:%u\n", + state->pattern_index, + state->step_index + ); + + QueryStep *next_step = array_get(&self->query->steps, state->step_index); + + // For a given step, if the current symbol is the wildcard symbol, `_`, and it is **not** + // named, meaning it should capture anonymous nodes, **and** the next step is immediate, + // we reuse the `seeking_immediate_match` flag to indicate that we are looking for an + // immediate match due to an unnamed wildcard symbol. + // + // The reason for this is that typically, anchors will not consider anonymous nodes, + // but we're special casing the wildcard symbol to allow for any immediate matches, + // regardless of whether they are named or not. + if (step->symbol == WILDCARD_SYMBOL && !step->is_named && next_step->is_immediate) { + state->seeking_immediate_match = true; + } else { + state->seeking_immediate_match = false; + } + + if (stop_on_definite_step && next_step->root_pattern_guaranteed) did_match = true; + + // If this state's next step has an alternative step, then copy the state in order + // to pursue both alternatives. The alternative step itself may have an alternative, + // so this is an interactive process. + unsigned end_index = j + 1; + for (unsigned k = j; k < end_index; k++) { + QueryState *child_state = array_get(&self->states, k); + QueryStep *child_step = array_get(&self->query->steps, child_state->step_index); + if (child_step->alternative_index != NONE) { + // A "dead-end" step exists only to add a non-sequential jump into the step sequence, + // via its alternative index. When a state reaches a dead-end step, it jumps straight + // to the step's alternative. + if (child_step->is_dead_end) { + child_state->step_index = child_step->alternative_index; + k--; + continue; + } + + // A "pass-through" step exists only to add a branch into the step sequence, + // via its alternative_index. When a state reaches a pass-through step, it splits + // in order to process the alternative step, and then it advances to the next step. + if (child_step->is_pass_through) { + child_state->step_index++; + k--; + } + + QueryState *copy = ts_query_cursor__copy_state(self, &child_state); + if (copy) { + LOG( + " split state for branch. pattern:%u, from_step:%u, to_step:%u, immediate:%d, capture_count: %u\n", + copy->pattern_index, + copy->step_index, + next_step->alternative_index, + next_step->alternative_is_immediate, + capture_list_pool_get(&self->capture_list_pool, copy->capture_list_id)->size + ); + end_index++; + copy_count++; + copy->step_index = child_step->alternative_index; + if (child_step->alternative_is_immediate) { + copy->seeking_immediate_match = true; + } + } + } + } + } + + for (unsigned j = 0; j < self->states.size; j++) { + QueryState *state = array_get(&self->states, j); + if (state->dead) { + array_erase(&self->states, j); + j--; + continue; + } + + // Enforce the longest-match criteria. When a query pattern contains optional or + // repeated nodes, this is necessary to avoid multiple redundant states, where + // one state has a strict subset of another state's captures. + bool did_remove = false; + for (unsigned k = j + 1; k < self->states.size; k++) { + QueryState *other_state = array_get(&self->states, k); + + // Query states are kept in ascending order of start_depth and pattern_index. + // Since the longest-match criteria is only used for deduping matches of the same + // pattern and root node, we only need to perform pairwise comparisons within a + // small slice of the states array. + if ( + other_state->start_depth != state->start_depth || + other_state->pattern_index != state->pattern_index + ) break; + + bool left_contains_right, right_contains_left; + ts_query_cursor__compare_captures( + self, + state, + other_state, + &left_contains_right, + &right_contains_left + ); + if (left_contains_right) { + if (state->step_index == other_state->step_index) { + LOG( + " drop shorter state. pattern: %u, step_index: %u\n", + state->pattern_index, + state->step_index + ); + capture_list_pool_release(&self->capture_list_pool, other_state->capture_list_id); + array_erase(&self->states, k); + k--; + continue; + } + other_state->has_in_progress_alternatives = true; + } + if (right_contains_left) { + if (state->step_index == other_state->step_index) { + LOG( + " drop shorter state. pattern: %u, step_index: %u\n", + state->pattern_index, + state->step_index + ); + capture_list_pool_release(&self->capture_list_pool, state->capture_list_id); + array_erase(&self->states, j); + j--; + did_remove = true; + break; + } + state->has_in_progress_alternatives = true; + } + } + + // If the state is at the end of its pattern, remove it from the list + // of in-progress states and add it to the list of finished states. + if (!did_remove) { + LOG( + " keep state. pattern: %u, start_depth: %u, step_index: %u, capture_count: %u\n", + state->pattern_index, + state->start_depth, + state->step_index, + capture_list_pool_get(&self->capture_list_pool, state->capture_list_id)->size + ); + QueryStep *next_step = array_get(&self->query->steps, state->step_index); + if (next_step->depth == PATTERN_DONE_MARKER) { + if (state->has_in_progress_alternatives) { + LOG(" defer finishing pattern %u\n", state->pattern_index); + } else { + LOG(" finish pattern %u\n", state->pattern_index); + array_push(&self->finished_states, *state); + array_erase(&self->states, (uint32_t)(state - self->states.contents)); + did_match = true; + j--; + } + } + } + } + } + + if (ts_query_cursor__should_descend(self, node_intersects_range)) { + switch (ts_tree_cursor_goto_first_child_internal(&self->cursor)) { + case TreeCursorStepVisible: + self->depth++; + self->on_visible_node = true; + continue; + case TreeCursorStepHidden: + self->on_visible_node = false; + continue; + default: + break; + } + } + + self->ascending = true; + } + } +} + +bool ts_query_cursor_next_match( + TSQueryCursor *self, + TSQueryMatch *match +) { + if (self->finished_states.size == 0) { + if (!ts_query_cursor__advance(self, false)) { + return false; + } + } + + QueryState *state = array_get(&self->finished_states, 0); + if (state->id == UINT32_MAX) state->id = self->next_state_id++; + match->id = state->id; + match->pattern_index = state->pattern_index; + const CaptureList *captures = capture_list_pool_get( + &self->capture_list_pool, + state->capture_list_id + ); + match->captures = captures->contents; + match->capture_count = captures->size; + capture_list_pool_release(&self->capture_list_pool, state->capture_list_id); + array_erase(&self->finished_states, 0); + return true; +} + +void ts_query_cursor_remove_match( + TSQueryCursor *self, + uint32_t match_id +) { + for (unsigned i = 0; i < self->finished_states.size; i++) { + const QueryState *state = array_get(&self->finished_states, i); + if (state->id == match_id) { + capture_list_pool_release( + &self->capture_list_pool, + state->capture_list_id + ); + array_erase(&self->finished_states, i); + return; + } + } + + // Remove unfinished query states as well to prevent future + // captures for a match being removed. + for (unsigned i = 0; i < self->states.size; i++) { + const QueryState *state = array_get(&self->states, i); + if (state->id == match_id) { + capture_list_pool_release( + &self->capture_list_pool, + state->capture_list_id + ); + array_erase(&self->states, i); + return; + } + } +} + +bool ts_query_cursor_next_capture( + TSQueryCursor *self, + TSQueryMatch *match, + uint32_t *capture_index +) { + // The goal here is to return captures in order, even though they may not + // be discovered in order, because patterns can overlap. Search for matches + // until there is a finished capture that is before any unfinished capture. + for (;;) { + // First, find the earliest capture in an unfinished match. + uint32_t first_unfinished_capture_byte; + uint32_t first_unfinished_pattern_index; + uint32_t first_unfinished_state_index; + bool first_unfinished_state_is_definite = false; + bool found_unfinished_state = ts_query_cursor__first_in_progress_capture( + self, + &first_unfinished_state_index, + &first_unfinished_capture_byte, + &first_unfinished_pattern_index, + &first_unfinished_state_is_definite + ); + + // Then find the earliest capture in a finished match. It must occur + // before the first capture in an *unfinished* match. + QueryState *first_finished_state = NULL; + uint32_t first_finished_capture_byte = first_unfinished_capture_byte; + uint32_t first_finished_pattern_index = first_unfinished_pattern_index; + for (unsigned i = 0; i < self->finished_states.size;) { + QueryState *state = array_get(&self->finished_states, i); + const CaptureList *captures = capture_list_pool_get( + &self->capture_list_pool, + state->capture_list_id + ); + + // Remove states whose captures are all consumed. + if (state->consumed_capture_count >= captures->size) { + capture_list_pool_release( + &self->capture_list_pool, + state->capture_list_id + ); + array_erase(&self->finished_states, i); + continue; + } + + TSNode node = array_get(captures, state->consumed_capture_count)->node; + + bool node_precedes_range = ( + ts_node_end_byte(node) <= self->start_byte || + point_lte(ts_node_end_point(node), self->start_point) + ); + bool node_follows_range = ( + ts_node_start_byte(node) >= self->end_byte || + point_gte(ts_node_start_point(node), self->end_point) + ); + bool node_outside_of_range = node_precedes_range || node_follows_range; + + // Skip captures that are outside of the cursor's range. + if (node_outside_of_range) { + state->consumed_capture_count++; + continue; + } + + uint32_t node_start_byte = ts_node_start_byte(node); + if ( + node_start_byte < first_finished_capture_byte || + ( + node_start_byte == first_finished_capture_byte && + state->pattern_index < first_finished_pattern_index + ) + ) { + first_finished_state = state; + first_finished_capture_byte = node_start_byte; + first_finished_pattern_index = state->pattern_index; + } + i++; + } + + // If there is finished capture that is clearly before any unfinished + // capture, then return its match, and its capture index. Internally + // record the fact that the capture has been 'consumed'. + QueryState *state; + if (first_finished_state) { + state = first_finished_state; + } else if (first_unfinished_state_is_definite) { + state = array_get(&self->states, first_unfinished_state_index); + } else { + state = NULL; + } + + if (state) { + if (state->id == UINT32_MAX) state->id = self->next_state_id++; + match->id = state->id; + match->pattern_index = state->pattern_index; + const CaptureList *captures = capture_list_pool_get( + &self->capture_list_pool, + state->capture_list_id + ); + match->captures = captures->contents; + match->capture_count = captures->size; + *capture_index = state->consumed_capture_count; + state->consumed_capture_count++; + return true; + } + + if (capture_list_pool_is_empty(&self->capture_list_pool) && found_unfinished_state) { + LOG( + " abandon state. index:%u, pattern:%u, offset:%u.\n", + first_unfinished_state_index, + first_unfinished_pattern_index, + first_unfinished_capture_byte + ); + capture_list_pool_release( + &self->capture_list_pool, + array_get(&self->states, first_unfinished_state_index)->capture_list_id + ); + array_erase(&self->states, first_unfinished_state_index); + } + + // If there are no finished matches that are ready to be returned, then + // continue finding more matches. + if ( + !ts_query_cursor__advance(self, true) && + self->finished_states.size == 0 + ) return false; + } +} + +void ts_query_cursor_set_max_start_depth( + TSQueryCursor *self, + uint32_t max_start_depth +) { + self->max_start_depth = max_start_depth; +} + +#undef LOG diff --git a/vendor/tree-sitter/src/reduce_action.h b/vendor/tree-sitter/src/reduce_action.h new file mode 100644 index 0000000..72aff08 --- /dev/null +++ b/vendor/tree-sitter/src/reduce_action.h @@ -0,0 +1,34 @@ +#ifndef TREE_SITTER_REDUCE_ACTION_H_ +#define TREE_SITTER_REDUCE_ACTION_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./array.h" +#include "tree_sitter/api.h" + +typedef struct { + uint32_t count; + TSSymbol symbol; + int dynamic_precedence; + unsigned short production_id; +} ReduceAction; + +typedef Array(ReduceAction) ReduceActionSet; + +static inline void ts_reduce_action_set_add(ReduceActionSet *self, + ReduceAction new_action) { + for (uint32_t i = 0; i < self->size; i++) { + ReduceAction action = self->contents[i]; + if (action.symbol == new_action.symbol && action.count == new_action.count) + return; + } + array_push(self, new_action); +} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_REDUCE_ACTION_H_ diff --git a/vendor/tree-sitter/src/reusable_node.h b/vendor/tree-sitter/src/reusable_node.h new file mode 100644 index 0000000..63fe3c1 --- /dev/null +++ b/vendor/tree-sitter/src/reusable_node.h @@ -0,0 +1,95 @@ +#include "./subtree.h" + +typedef struct { + Subtree tree; + uint32_t child_index; + uint32_t byte_offset; +} StackEntry; + +typedef struct { + Array(StackEntry) stack; + Subtree last_external_token; +} ReusableNode; + +static inline ReusableNode reusable_node_new(void) { + return (ReusableNode) {array_new(), NULL_SUBTREE}; +} + +static inline void reusable_node_clear(ReusableNode *self) { + array_clear(&self->stack); + self->last_external_token = NULL_SUBTREE; +} + +static inline Subtree reusable_node_tree(ReusableNode *self) { + return self->stack.size > 0 + ? self->stack.contents[self->stack.size - 1].tree + : NULL_SUBTREE; +} + +static inline uint32_t reusable_node_byte_offset(ReusableNode *self) { + return self->stack.size > 0 + ? self->stack.contents[self->stack.size - 1].byte_offset + : UINT32_MAX; +} + +static inline void reusable_node_delete(ReusableNode *self) { + array_delete(&self->stack); +} + +static inline void reusable_node_advance(ReusableNode *self) { + StackEntry last_entry = *array_back(&self->stack); + uint32_t byte_offset = last_entry.byte_offset + ts_subtree_total_bytes(last_entry.tree); + if (ts_subtree_has_external_tokens(last_entry.tree)) { + self->last_external_token = ts_subtree_last_external_token(last_entry.tree); + } + + Subtree tree; + uint32_t next_index; + do { + StackEntry popped_entry = array_pop(&self->stack); + next_index = popped_entry.child_index + 1; + if (self->stack.size == 0) return; + tree = array_back(&self->stack)->tree; + } while (ts_subtree_child_count(tree) <= next_index); + + array_push(&self->stack, ((StackEntry) { + .tree = ts_subtree_children(tree)[next_index], + .child_index = next_index, + .byte_offset = byte_offset, + })); +} + +static inline bool reusable_node_descend(ReusableNode *self) { + StackEntry last_entry = *array_back(&self->stack); + if (ts_subtree_child_count(last_entry.tree) > 0) { + array_push(&self->stack, ((StackEntry) { + .tree = ts_subtree_children(last_entry.tree)[0], + .child_index = 0, + .byte_offset = last_entry.byte_offset, + })); + return true; + } else { + return false; + } +} + +static inline void reusable_node_advance_past_leaf(ReusableNode *self) { + while (reusable_node_descend(self)) {} + reusable_node_advance(self); +} + +static inline void reusable_node_reset(ReusableNode *self, Subtree tree) { + reusable_node_clear(self); + array_push(&self->stack, ((StackEntry) { + .tree = tree, + .child_index = 0, + .byte_offset = 0, + })); + + // Never reuse the root node, because it has a non-standard internal structure + // due to transformations that are applied when it is accepted: adding the EOF + // child and any extra children. + if (!reusable_node_descend(self)) { + reusable_node_clear(self); + } +} diff --git a/vendor/tree-sitter/src/stack.c b/vendor/tree-sitter/src/stack.c new file mode 100644 index 0000000..9142007 --- /dev/null +++ b/vendor/tree-sitter/src/stack.c @@ -0,0 +1,912 @@ +#include "./alloc.h" +#include "./language.h" +#include "./subtree.h" +#include "./array.h" +#include "./stack.h" +#include "./length.h" +#include +#include +#include + +#define MAX_LINK_COUNT 8 +#define MAX_NODE_POOL_SIZE 50 +#define MAX_ITERATOR_COUNT 64 + +#if defined _WIN32 && !defined __GNUC__ +#define forceinline __forceinline +#else +#define forceinline static inline __attribute__((always_inline)) +#endif + +typedef struct StackNode StackNode; + +typedef struct { + StackNode *node; + Subtree subtree; + bool is_pending; +} StackLink; + +struct StackNode { + TSStateId state; + Length position; + StackLink links[MAX_LINK_COUNT]; + short unsigned int link_count; + uint32_t ref_count; + unsigned error_cost; + unsigned node_count; + int dynamic_precedence; +}; + +typedef struct { + StackNode *node; + SubtreeArray subtrees; + uint32_t subtree_count; + bool is_pending; +} StackIterator; + +typedef Array(StackNode *) StackNodeArray; + +typedef enum { + StackStatusActive, + StackStatusPaused, + StackStatusHalted, +} StackStatus; + +typedef struct { + StackNode *node; + StackSummary *summary; + unsigned node_count_at_last_error; + Subtree last_external_token; + Subtree lookahead_when_paused; + StackStatus status; +} StackHead; + +struct Stack { + Array(StackHead) heads; + StackSliceArray slices; + Array(StackIterator) iterators; + StackNodeArray node_pool; + StackNode *base_node; + SubtreePool *subtree_pool; +}; + +typedef unsigned StackAction; +enum { + StackActionNone, + StackActionStop = 1, + StackActionPop = 2, +}; + +typedef StackAction (*StackCallback)(void *, const StackIterator *); + +static void stack_node_retain(StackNode *self) { + if (!self) + return; + ts_assert(self->ref_count > 0); + self->ref_count++; + ts_assert(self->ref_count != 0); +} + +static void stack_node_release( + StackNode *self, + StackNodeArray *pool, + SubtreePool *subtree_pool +) { +recur: + ts_assert(self->ref_count != 0); + self->ref_count--; + if (self->ref_count > 0) return; + + StackNode *first_predecessor = NULL; + if (self->link_count > 0) { + for (unsigned i = self->link_count - 1; i > 0; i--) { + StackLink link = self->links[i]; + if (link.subtree.ptr) ts_subtree_release(subtree_pool, link.subtree); + stack_node_release(link.node, pool, subtree_pool); + } + StackLink link = self->links[0]; + if (link.subtree.ptr) ts_subtree_release(subtree_pool, link.subtree); + first_predecessor = self->links[0].node; + } + + if (pool->size < MAX_NODE_POOL_SIZE) { + array_push(pool, self); + } else { + ts_free(self); + } + + if (first_predecessor) { + self = first_predecessor; + goto recur; + } +} + +/// Get the number of nodes in the subtree, for the purpose of measuring +/// how much progress has been made by a given version of the stack. +static uint32_t stack__subtree_node_count(Subtree subtree) { + uint32_t count = ts_subtree_visible_descendant_count(subtree); + if (ts_subtree_visible(subtree)) count++; + + // Count intermediate error nodes even though they are not visible, + // because a stack version's node count is used to check whether it + // has made any progress since the last time it encountered an error. + if (ts_subtree_symbol(subtree) == ts_builtin_sym_error_repeat) count++; + + return count; +} + +static StackNode *stack_node_new( + StackNode *previous_node, + Subtree subtree, + bool is_pending, + TSStateId state, + StackNodeArray *pool +) { + StackNode *node = pool->size > 0 + ? array_pop(pool) + : ts_malloc(sizeof(StackNode)); + *node = (StackNode) { + .ref_count = 1, + .link_count = 0, + .state = state + }; + + if (previous_node) { + node->link_count = 1; + node->links[0] = (StackLink) { + .node = previous_node, + .subtree = subtree, + .is_pending = is_pending, + }; + + node->position = previous_node->position; + node->error_cost = previous_node->error_cost; + node->dynamic_precedence = previous_node->dynamic_precedence; + node->node_count = previous_node->node_count; + + if (subtree.ptr) { + node->error_cost += ts_subtree_error_cost(subtree); + node->position = length_add(node->position, ts_subtree_total_size(subtree)); + node->node_count += stack__subtree_node_count(subtree); + node->dynamic_precedence += ts_subtree_dynamic_precedence(subtree); + } + } else { + node->position = length_zero(); + node->error_cost = 0; + } + + return node; +} + +static bool stack__subtree_is_equivalent(Subtree left, Subtree right) { + if (left.ptr == right.ptr) return true; + if (!left.ptr || !right.ptr) return false; + + // Symbols must match + if (ts_subtree_symbol(left) != ts_subtree_symbol(right)) return false; + + // If both have errors, don't bother keeping both. + if (ts_subtree_error_cost(left) > 0 && ts_subtree_error_cost(right) > 0) return true; + + return ( + ts_subtree_padding(left).bytes == ts_subtree_padding(right).bytes && + ts_subtree_size(left).bytes == ts_subtree_size(right).bytes && + ts_subtree_child_count(left) == ts_subtree_child_count(right) && + ts_subtree_extra(left) == ts_subtree_extra(right) && + ts_subtree_external_scanner_state_eq(left, right) + ); +} + +static void stack_node_add_link( + StackNode *self, + StackLink link, + SubtreePool *subtree_pool +) { + if (link.node == self) return; + + for (int i = 0; i < self->link_count; i++) { + StackLink *existing_link = &self->links[i]; + if (stack__subtree_is_equivalent(existing_link->subtree, link.subtree)) { + // In general, we preserve ambiguities until they are removed from the stack + // during a pop operation where multiple paths lead to the same node. But in + // the special case where two links directly connect the same pair of nodes, + // we can safely remove the ambiguity ahead of time without changing behavior. + if (existing_link->node == link.node) { + if ( + ts_subtree_dynamic_precedence(link.subtree) > + ts_subtree_dynamic_precedence(existing_link->subtree) + ) { + ts_subtree_retain(link.subtree); + ts_subtree_release(subtree_pool, existing_link->subtree); + existing_link->subtree = link.subtree; + self->dynamic_precedence = + link.node->dynamic_precedence + ts_subtree_dynamic_precedence(link.subtree); + } + return; + } + + // If the previous nodes are mergeable, merge them recursively. + if ( + existing_link->node->state == link.node->state && + existing_link->node->position.bytes == link.node->position.bytes && + existing_link->node->error_cost == link.node->error_cost + ) { + for (int j = 0; j < link.node->link_count; j++) { + stack_node_add_link(existing_link->node, link.node->links[j], subtree_pool); + } + int32_t dynamic_precedence = link.node->dynamic_precedence; + if (link.subtree.ptr) { + dynamic_precedence += ts_subtree_dynamic_precedence(link.subtree); + } + if (dynamic_precedence > self->dynamic_precedence) { + self->dynamic_precedence = dynamic_precedence; + } + return; + } + } + } + + if (self->link_count == MAX_LINK_COUNT) return; + + stack_node_retain(link.node); + unsigned node_count = link.node->node_count; + int dynamic_precedence = link.node->dynamic_precedence; + self->links[self->link_count++] = link; + + if (link.subtree.ptr) { + ts_subtree_retain(link.subtree); + node_count += stack__subtree_node_count(link.subtree); + dynamic_precedence += ts_subtree_dynamic_precedence(link.subtree); + } + + if (node_count > self->node_count) self->node_count = node_count; + if (dynamic_precedence > self->dynamic_precedence) self->dynamic_precedence = dynamic_precedence; +} + +static void stack_head_delete( + StackHead *self, + StackNodeArray *pool, + SubtreePool *subtree_pool +) { + if (self->node) { + if (self->last_external_token.ptr) { + ts_subtree_release(subtree_pool, self->last_external_token); + } + if (self->lookahead_when_paused.ptr) { + ts_subtree_release(subtree_pool, self->lookahead_when_paused); + } + if (self->summary) { + array_delete(self->summary); + ts_free(self->summary); + } + stack_node_release(self->node, pool, subtree_pool); + } +} + +static StackVersion ts_stack__add_version( + Stack *self, + StackVersion original_version, + StackNode *node +) { + StackHead head = { + .node = node, + .node_count_at_last_error = array_get(&self->heads, original_version)->node_count_at_last_error, + .last_external_token = array_get(&self->heads, original_version)->last_external_token, + .status = StackStatusActive, + .lookahead_when_paused = NULL_SUBTREE, + }; + array_push(&self->heads, head); + stack_node_retain(node); + if (head.last_external_token.ptr) ts_subtree_retain(head.last_external_token); + return (StackVersion)(self->heads.size - 1); +} + +static void ts_stack__add_slice( + Stack *self, + StackVersion original_version, + StackNode *node, + SubtreeArray *subtrees +) { + for (uint32_t i = self->slices.size - 1; i + 1 > 0; i--) { + StackVersion version = array_get(&self->slices, i)->version; + if (array_get(&self->heads, version)->node == node) { + StackSlice slice = {*subtrees, version}; + array_insert(&self->slices, i + 1, slice); + return; + } + } + + StackVersion version = ts_stack__add_version(self, original_version, node); + StackSlice slice = { *subtrees, version }; + array_push(&self->slices, slice); +} + +static StackSliceArray stack__iter( + Stack *self, + StackVersion version, + StackCallback callback, + void *payload, + int goal_subtree_count +) { + array_clear(&self->slices); + array_clear(&self->iterators); + + StackHead *head = array_get(&self->heads, version); + StackIterator new_iterator = { + .node = head->node, + .subtrees = array_new(), + .subtree_count = 0, + .is_pending = true, + }; + + bool include_subtrees = false; + if (goal_subtree_count >= 0) { + include_subtrees = true; + array_reserve(&new_iterator.subtrees, (uint32_t)ts_subtree_alloc_size(goal_subtree_count) / sizeof(Subtree)); + } + + array_push(&self->iterators, new_iterator); + + while (self->iterators.size > 0) { + for (uint32_t i = 0, size = self->iterators.size; i < size; i++) { + StackIterator *iterator = array_get(&self->iterators, i); + StackNode *node = iterator->node; + + StackAction action = callback(payload, iterator); + bool should_pop = action & StackActionPop; + bool should_stop = action & StackActionStop || node->link_count == 0; + + if (should_pop) { + SubtreeArray subtrees = iterator->subtrees; + if (!should_stop) { + ts_subtree_array_copy(subtrees, &subtrees); + } + ts_subtree_array_reverse(&subtrees); + ts_stack__add_slice( + self, + version, + node, + &subtrees + ); + } + + if (should_stop) { + if (!should_pop) { + ts_subtree_array_delete(self->subtree_pool, &iterator->subtrees); + } + array_erase(&self->iterators, i); + i--, size--; + continue; + } + + for (uint32_t j = 1; j <= node->link_count; j++) { + StackIterator *next_iterator; + StackLink link; + if (j == node->link_count) { + link = node->links[0]; + next_iterator = array_get(&self->iterators, i); + } else { + if (self->iterators.size >= MAX_ITERATOR_COUNT) continue; + link = node->links[j]; + StackIterator current_iterator = *array_get(&self->iterators, i); + array_push(&self->iterators, current_iterator); + next_iterator = array_back(&self->iterators); + ts_subtree_array_copy(next_iterator->subtrees, &next_iterator->subtrees); + } + + next_iterator->node = link.node; + if (link.subtree.ptr) { + if (include_subtrees) { + array_push(&next_iterator->subtrees, link.subtree); + ts_subtree_retain(link.subtree); + } + + if (!ts_subtree_extra(link.subtree)) { + next_iterator->subtree_count++; + if (!link.is_pending) { + next_iterator->is_pending = false; + } + } + } else { + next_iterator->subtree_count++; + next_iterator->is_pending = false; + } + } + } + } + + return self->slices; +} + +Stack *ts_stack_new(SubtreePool *subtree_pool) { + Stack *self = ts_calloc(1, sizeof(Stack)); + + array_init(&self->heads); + array_init(&self->slices); + array_init(&self->iterators); + array_init(&self->node_pool); + array_reserve(&self->heads, 4); + array_reserve(&self->slices, 4); + array_reserve(&self->iterators, 4); + array_reserve(&self->node_pool, MAX_NODE_POOL_SIZE); + + self->subtree_pool = subtree_pool; + self->base_node = stack_node_new(NULL, NULL_SUBTREE, false, 1, &self->node_pool); + ts_stack_clear(self); + + return self; +} + +void ts_stack_delete(Stack *self) { + if (self->slices.contents) + array_delete(&self->slices); + if (self->iterators.contents) + array_delete(&self->iterators); + stack_node_release(self->base_node, &self->node_pool, self->subtree_pool); + for (uint32_t i = 0; i < self->heads.size; i++) { + stack_head_delete(array_get(&self->heads, i), &self->node_pool, self->subtree_pool); + } + array_clear(&self->heads); + if (self->node_pool.contents) { + for (uint32_t i = 0; i < self->node_pool.size; i++) + ts_free(*array_get(&self->node_pool, i)); + array_delete(&self->node_pool); + } + array_delete(&self->heads); + ts_free(self); +} + +uint32_t ts_stack_version_count(const Stack *self) { + return self->heads.size; +} + +uint32_t ts_stack_halted_version_count(Stack *self) { + uint32_t count = 0; + for (uint32_t i = 0; i < self->heads.size; i++) { + StackHead *head = array_get(&self->heads, i); + if (head->status == StackStatusHalted) { + count++; + } + } + return count; +} + +TSStateId ts_stack_state(const Stack *self, StackVersion version) { + return array_get(&self->heads, version)->node->state; +} + +Length ts_stack_position(const Stack *self, StackVersion version) { + return array_get(&self->heads, version)->node->position; +} + +Subtree ts_stack_last_external_token(const Stack *self, StackVersion version) { + return array_get(&self->heads, version)->last_external_token; +} + +void ts_stack_set_last_external_token(Stack *self, StackVersion version, Subtree token) { + StackHead *head = array_get(&self->heads, version); + if (token.ptr) ts_subtree_retain(token); + if (head->last_external_token.ptr) ts_subtree_release(self->subtree_pool, head->last_external_token); + head->last_external_token = token; +} + +unsigned ts_stack_error_cost(const Stack *self, StackVersion version) { + StackHead *head = array_get(&self->heads, version); + unsigned result = head->node->error_cost; + if ( + head->status == StackStatusPaused || + (head->node->state == ERROR_STATE && !head->node->links[0].subtree.ptr)) { + result += ERROR_COST_PER_RECOVERY; + } + return result; +} + +unsigned ts_stack_node_count_since_error(const Stack *self, StackVersion version) { + StackHead *head = array_get(&self->heads, version); + if (head->node->node_count < head->node_count_at_last_error) { + head->node_count_at_last_error = head->node->node_count; + } + return head->node->node_count - head->node_count_at_last_error; +} + +void ts_stack_push( + Stack *self, + StackVersion version, + Subtree subtree, + bool pending, + TSStateId state +) { + StackHead *head = array_get(&self->heads, version); + StackNode *new_node = stack_node_new(head->node, subtree, pending, state, &self->node_pool); + if (!subtree.ptr) head->node_count_at_last_error = new_node->node_count; + head->node = new_node; +} + +forceinline StackAction pop_count_callback(void *payload, const StackIterator *iterator) { + unsigned *goal_subtree_count = payload; + if (iterator->subtree_count == *goal_subtree_count) { + return StackActionPop | StackActionStop; + } else { + return StackActionNone; + } +} + +StackSliceArray ts_stack_pop_count(Stack *self, StackVersion version, uint32_t count) { + return stack__iter(self, version, pop_count_callback, &count, (int)count); +} + + +forceinline StackAction pop_pending_callback(void *payload, const StackIterator *iterator) { + (void)payload; + if (iterator->subtree_count >= 1) { + if (iterator->is_pending) { + return StackActionPop | StackActionStop; + } else { + return StackActionStop; + } + } else { + return StackActionNone; + } +} + +StackSliceArray ts_stack_pop_pending(Stack *self, StackVersion version) { + StackSliceArray pop = stack__iter(self, version, pop_pending_callback, NULL, 0); + if (pop.size > 0) { + ts_stack_renumber_version(self, array_get(&pop, 0)->version, version); + array_get(&pop, 0)->version = version; + } + return pop; +} + +forceinline StackAction pop_error_callback(void *payload, const StackIterator *iterator) { + if (iterator->subtrees.size > 0) { + bool *found_error = payload; + if (!*found_error && ts_subtree_is_error(*array_get(&iterator->subtrees, 0))) { + *found_error = true; + return StackActionPop | StackActionStop; + } else { + return StackActionStop; + } + } else { + return StackActionNone; + } +} + +SubtreeArray ts_stack_pop_error(Stack *self, StackVersion version) { + StackNode *node = array_get(&self->heads, version)->node; + for (unsigned i = 0; i < node->link_count; i++) { + if (node->links[i].subtree.ptr && ts_subtree_is_error(node->links[i].subtree)) { + bool found_error = false; + StackSliceArray pop = stack__iter(self, version, pop_error_callback, &found_error, 1); + if (pop.size > 0) { + ts_assert(pop.size == 1); + ts_stack_renumber_version(self, array_get(&pop, 0)->version, version); + return array_get(&pop, 0)->subtrees; + } + break; + } + } + return (SubtreeArray) {.size = 0}; +} + +forceinline StackAction pop_all_callback(void *payload, const StackIterator *iterator) { + (void)payload; + return iterator->node->link_count == 0 ? StackActionPop : StackActionNone; +} + +StackSliceArray ts_stack_pop_all(Stack *self, StackVersion version) { + return stack__iter(self, version, pop_all_callback, NULL, 0); +} + +typedef struct { + StackSummary *summary; + unsigned max_depth; +} SummarizeStackSession; + +forceinline StackAction summarize_stack_callback(void *payload, const StackIterator *iterator) { + SummarizeStackSession *session = payload; + TSStateId state = iterator->node->state; + unsigned depth = iterator->subtree_count; + if (depth > session->max_depth) return StackActionStop; + for (unsigned i = session->summary->size - 1; i + 1 > 0; i--) { + StackSummaryEntry entry = *array_get(session->summary, i); + if (entry.depth < depth) break; + if (entry.depth == depth && entry.state == state) return StackActionNone; + } + array_push(session->summary, ((StackSummaryEntry) { + .position = iterator->node->position, + .depth = depth, + .state = state, + })); + return StackActionNone; +} + +void ts_stack_record_summary(Stack *self, StackVersion version, unsigned max_depth) { + SummarizeStackSession session = { + .summary = ts_malloc(sizeof(StackSummary)), + .max_depth = max_depth + }; + array_init(session.summary); + stack__iter(self, version, summarize_stack_callback, &session, -1); + StackHead *head = array_get(&self->heads, version); + if (head->summary) { + array_delete(head->summary); + ts_free(head->summary); + } + head->summary = session.summary; +} + +StackSummary *ts_stack_get_summary(Stack *self, StackVersion version) { + return array_get(&self->heads, version)->summary; +} + +int ts_stack_dynamic_precedence(Stack *self, StackVersion version) { + return array_get(&self->heads, version)->node->dynamic_precedence; +} + +bool ts_stack_has_advanced_since_error(const Stack *self, StackVersion version) { + const StackHead *head = array_get(&self->heads, version); + const StackNode *node = head->node; + if (node->error_cost == 0) return true; + while (node) { + if (node->link_count > 0) { + Subtree subtree = node->links[0].subtree; + if (subtree.ptr) { + if (ts_subtree_total_bytes(subtree) > 0) { + return true; + } else if ( + node->node_count > head->node_count_at_last_error && + ts_subtree_error_cost(subtree) == 0 + ) { + node = node->links[0].node; + continue; + } + } + } + break; + } + return false; +} + +void ts_stack_remove_version(Stack *self, StackVersion version) { + stack_head_delete(array_get(&self->heads, version), &self->node_pool, self->subtree_pool); + array_erase(&self->heads, version); +} + +void ts_stack_renumber_version(Stack *self, StackVersion v1, StackVersion v2) { + if (v1 == v2) return; + ts_assert(v2 < v1); + ts_assert((uint32_t)v1 < self->heads.size); + StackHead *source_head = array_get(&self->heads, v1); + StackHead *target_head = array_get(&self->heads, v2); + if (target_head->summary && !source_head->summary) { + source_head->summary = target_head->summary; + target_head->summary = NULL; + } + stack_head_delete(target_head, &self->node_pool, self->subtree_pool); + *target_head = *source_head; + array_erase(&self->heads, v1); +} + +void ts_stack_swap_versions(Stack *self, StackVersion v1, StackVersion v2) { + StackHead temporary_head = *array_get(&self->heads, v1); + *array_get(&self->heads, v1) = *array_get(&self->heads, v2); + *array_get(&self->heads, v2) = temporary_head; +} + +StackVersion ts_stack_copy_version(Stack *self, StackVersion version) { + ts_assert(version < self->heads.size); + StackHead version_head = *array_get(&self->heads, version); + array_push(&self->heads, version_head); + StackHead *head = array_back(&self->heads); + stack_node_retain(head->node); + if (head->last_external_token.ptr) ts_subtree_retain(head->last_external_token); + head->summary = NULL; + return self->heads.size - 1; +} + +bool ts_stack_merge(Stack *self, StackVersion version1, StackVersion version2) { + if (!ts_stack_can_merge(self, version1, version2)) return false; + StackHead *head1 = array_get(&self->heads, version1); + StackHead *head2 = array_get(&self->heads, version2); + for (uint32_t i = 0; i < head2->node->link_count; i++) { + stack_node_add_link(head1->node, head2->node->links[i], self->subtree_pool); + } + if (head1->node->state == ERROR_STATE) { + head1->node_count_at_last_error = head1->node->node_count; + } + ts_stack_remove_version(self, version2); + return true; +} + +bool ts_stack_can_merge(Stack *self, StackVersion version1, StackVersion version2) { + StackHead *head1 = array_get(&self->heads, version1); + StackHead *head2 = array_get(&self->heads, version2); + return + head1->status == StackStatusActive && + head2->status == StackStatusActive && + head1->node->state == head2->node->state && + head1->node->position.bytes == head2->node->position.bytes && + head1->node->error_cost == head2->node->error_cost && + ts_subtree_external_scanner_state_eq(head1->last_external_token, head2->last_external_token); +} + +void ts_stack_halt(Stack *self, StackVersion version) { + array_get(&self->heads, version)->status = StackStatusHalted; +} + +void ts_stack_pause(Stack *self, StackVersion version, Subtree lookahead) { + StackHead *head = array_get(&self->heads, version); + head->status = StackStatusPaused; + head->lookahead_when_paused = lookahead; + head->node_count_at_last_error = head->node->node_count; +} + +bool ts_stack_is_active(const Stack *self, StackVersion version) { + return array_get(&self->heads, version)->status == StackStatusActive; +} + +bool ts_stack_is_halted(const Stack *self, StackVersion version) { + return array_get(&self->heads, version)->status == StackStatusHalted; +} + +bool ts_stack_is_paused(const Stack *self, StackVersion version) { + return array_get(&self->heads, version)->status == StackStatusPaused; +} + +Subtree ts_stack_resume(Stack *self, StackVersion version) { + StackHead *head = array_get(&self->heads, version); + ts_assert(head->status == StackStatusPaused); + Subtree result = head->lookahead_when_paused; + head->status = StackStatusActive; + head->lookahead_when_paused = NULL_SUBTREE; + return result; +} + +void ts_stack_clear(Stack *self) { + stack_node_retain(self->base_node); + for (uint32_t i = 0; i < self->heads.size; i++) { + stack_head_delete(array_get(&self->heads, i), &self->node_pool, self->subtree_pool); + } + array_clear(&self->heads); + array_push(&self->heads, ((StackHead) { + .node = self->base_node, + .status = StackStatusActive, + .last_external_token = NULL_SUBTREE, + .lookahead_when_paused = NULL_SUBTREE, + })); +} + +bool ts_stack_print_dot_graph(Stack *self, const TSLanguage *language, FILE *f) { + array_reserve(&self->iterators, 32); + if (!f) f = stderr; + + fprintf(f, "digraph stack {\n"); + fprintf(f, "rankdir=\"RL\";\n"); + fprintf(f, "edge [arrowhead=none]\n"); + + Array(StackNode *) visited_nodes = array_new(); + + array_clear(&self->iterators); + for (uint32_t i = 0; i < self->heads.size; i++) { + StackHead *head = array_get(&self->heads, i); + if (head->status == StackStatusHalted) continue; + + fprintf(f, "node_head_%u [shape=none, label=\"\"]\n", i); + fprintf(f, "node_head_%u -> node_%p [", i, (void *)head->node); + + if (head->status == StackStatusPaused) { + fprintf(f, "color=red "); + } + fprintf(f, + "label=%u, fontcolor=blue, weight=10000, labeltooltip=\"node_count: %u\nerror_cost: %u", + i, + ts_stack_node_count_since_error(self, i), + ts_stack_error_cost(self, i) + ); + + if (head->summary) { + fprintf(f, "\nsummary:"); + for (uint32_t j = 0; j < head->summary->size; j++) fprintf(f, " %u", array_get(head->summary, j)->state); + } + + if (head->last_external_token.ptr) { + const ExternalScannerState *state = &head->last_external_token.ptr->external_scanner_state; + const char *data = ts_external_scanner_state_data(state); + fprintf(f, "\nexternal_scanner_state:"); + for (uint32_t j = 0; j < state->length; j++) fprintf(f, " %2X", data[j]); + } + + fprintf(f, "\"]\n"); + array_push(&self->iterators, ((StackIterator) { + .node = head->node + })); + } + + bool all_iterators_done = false; + while (!all_iterators_done) { + all_iterators_done = true; + + for (uint32_t i = 0; i < self->iterators.size; i++) { + StackIterator iterator = *array_get(&self->iterators, i); + StackNode *node = iterator.node; + + for (uint32_t j = 0; j < visited_nodes.size; j++) { + if (*array_get(&visited_nodes, j) == node) { + node = NULL; + break; + } + } + + if (!node) continue; + all_iterators_done = false; + + fprintf(f, "node_%p [", (void *)node); + if (node->state == ERROR_STATE) { + fprintf(f, "label=\"?\""); + } else if ( + node->link_count == 1 && + node->links[0].subtree.ptr && + ts_subtree_extra(node->links[0].subtree) + ) { + fprintf(f, "shape=point margin=0 label=\"\""); + } else { + fprintf(f, "label=\"%d\"", node->state); + } + + fprintf( + f, + " tooltip=\"position: %u,%u\nnode_count:%u\nerror_cost: %u\ndynamic_precedence: %d\"];\n", + node->position.extent.row + 1, + node->position.extent.column, + node->node_count, + node->error_cost, + node->dynamic_precedence + ); + + for (int j = 0; j < node->link_count; j++) { + StackLink link = node->links[j]; + fprintf(f, "node_%p -> node_%p [", (void *)node, (void *)link.node); + if (link.is_pending) fprintf(f, "style=dashed "); + if (link.subtree.ptr && ts_subtree_extra(link.subtree)) fprintf(f, "fontcolor=gray "); + + if (!link.subtree.ptr) { + fprintf(f, "color=red"); + } else { + fprintf(f, "label=\""); + bool quoted = ts_subtree_visible(link.subtree) && !ts_subtree_named(link.subtree); + if (quoted) fprintf(f, "'"); + ts_language_write_symbol_as_dot_string(language, f, ts_subtree_symbol(link.subtree)); + if (quoted) fprintf(f, "'"); + fprintf(f, "\""); + fprintf( + f, + "labeltooltip=\"error_cost: %u\ndynamic_precedence: %" PRId32 "\"", + ts_subtree_error_cost(link.subtree), + ts_subtree_dynamic_precedence(link.subtree) + ); + } + + fprintf(f, "];\n"); + + StackIterator *next_iterator; + if (j == 0) { + next_iterator = array_get(&self->iterators, i); + } else { + array_push(&self->iterators, iterator); + next_iterator = array_back(&self->iterators); + } + next_iterator->node = link.node; + } + + array_push(&visited_nodes, node); + } + } + + fprintf(f, "}\n"); + + array_delete(&visited_nodes); + return true; +} + +#undef forceinline diff --git a/vendor/tree-sitter/src/stack.h b/vendor/tree-sitter/src/stack.h new file mode 100644 index 0000000..2619f1e --- /dev/null +++ b/vendor/tree-sitter/src/stack.h @@ -0,0 +1,133 @@ +#ifndef TREE_SITTER_PARSE_STACK_H_ +#define TREE_SITTER_PARSE_STACK_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./array.h" +#include "./subtree.h" +#include + +typedef struct Stack Stack; + +typedef unsigned StackVersion; +#define STACK_VERSION_NONE ((StackVersion)-1) + +typedef struct { + SubtreeArray subtrees; + StackVersion version; +} StackSlice; +typedef Array(StackSlice) StackSliceArray; + +typedef struct { + Length position; + unsigned depth; + TSStateId state; +} StackSummaryEntry; +typedef Array(StackSummaryEntry) StackSummary; + +// Create a stack. +Stack *ts_stack_new(SubtreePool *subtree_pool); + +// Release the memory reserved for a given stack. +void ts_stack_delete(Stack *self); + +// Get the stack's current number of versions. +uint32_t ts_stack_version_count(const Stack *self); + +// Get the stack's current number of halted versions. +uint32_t ts_stack_halted_version_count(Stack *self); + +// Get the state at the top of the given version of the stack. If the stack is +// empty, this returns the initial state, 0. +TSStateId ts_stack_state(const Stack *self, StackVersion version); + +// Get the last external token associated with a given version of the stack. +Subtree ts_stack_last_external_token(const Stack *self, StackVersion version); + +// Set the last external token associated with a given version of the stack. +void ts_stack_set_last_external_token(Stack *self, StackVersion version, Subtree token); + +// Get the position of the given version of the stack within the document. +Length ts_stack_position(const Stack *, StackVersion); + +// Push a tree and state onto the given version of the stack. +// +// This transfers ownership of the tree to the Stack. Callers that +// need to retain ownership of the tree for their own purposes should +// first retain the tree. +void ts_stack_push(Stack *self, StackVersion version, Subtree subtree, bool pending, TSStateId state); + +// Pop the given number of entries from the given version of the stack. This +// operation can increase the number of stack versions by revealing multiple +// versions which had previously been merged. It returns an array that +// specifies the index of each revealed version and the trees that were +// removed from that version. +StackSliceArray ts_stack_pop_count(Stack *self, StackVersion version, uint32_t count); + +// Remove an error at the top of the given version of the stack. +SubtreeArray ts_stack_pop_error(Stack *self, StackVersion version); + +// Remove any pending trees from the top of the given version of the stack. +StackSliceArray ts_stack_pop_pending(Stack *self, StackVersion version); + +// Remove all trees from the given version of the stack. +StackSliceArray ts_stack_pop_all(Stack *self, StackVersion version); + +// Get the maximum number of tree nodes reachable from this version of the stack +// since the last error was detected. +unsigned ts_stack_node_count_since_error(const Stack *self, StackVersion version); + +int ts_stack_dynamic_precedence(Stack *self, StackVersion version); + +bool ts_stack_has_advanced_since_error(const Stack *self, StackVersion version); + +// Compute a summary of all the parse states near the top of the given +// version of the stack and store the summary for later retrieval. +void ts_stack_record_summary(Stack *self, StackVersion version, unsigned max_depth); + +// Retrieve a summary of all the parse states near the top of the +// given version of the stack. +StackSummary *ts_stack_get_summary(Stack *self, StackVersion version); + +// Get the total cost of all errors on the given version of the stack. +unsigned ts_stack_error_cost(const Stack *self, StackVersion version); + +// Merge the given two stack versions if possible, returning true +// if they were successfully merged and false otherwise. +bool ts_stack_merge(Stack *self, StackVersion version1, StackVersion version2); + +// Determine whether the given two stack versions can be merged. +bool ts_stack_can_merge(Stack *self, StackVersion version1, StackVersion version2); + +Subtree ts_stack_resume(Stack *self, StackVersion version); + +void ts_stack_pause(Stack *self, StackVersion version, Subtree lookahead); + +void ts_stack_halt(Stack *self, StackVersion version); + +bool ts_stack_is_active(const Stack *self, StackVersion version); + +bool ts_stack_is_paused(const Stack *self, StackVersion version); + +bool ts_stack_is_halted(const Stack *self, StackVersion version); + +void ts_stack_renumber_version(Stack *self, StackVersion v1, StackVersion v2); + +void ts_stack_swap_versions(Stack *, StackVersion v1, StackVersion v2); + +StackVersion ts_stack_copy_version(Stack *self, StackVersion version); + +// Remove the given version from the stack. +void ts_stack_remove_version(Stack *self, StackVersion version); + +void ts_stack_clear(Stack *self); + +bool ts_stack_print_dot_graph(Stack *self, const TSLanguage *language, FILE *f); + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_PARSE_STACK_H_ diff --git a/vendor/tree-sitter/src/subtree.c b/vendor/tree-sitter/src/subtree.c new file mode 100644 index 0000000..97d55c8 --- /dev/null +++ b/vendor/tree-sitter/src/subtree.c @@ -0,0 +1,1034 @@ +#include +#include +#include +#include +#include +#include "./alloc.h" +#include "./array.h" +#include "./atomic.h" +#include "./subtree.h" +#include "./length.h" +#include "./language.h" +#include "./error_costs.h" +#include "./ts_assert.h" +#include + +typedef struct { + Length start; + Length old_end; + Length new_end; +} Edit; + +#define TS_MAX_INLINE_TREE_LENGTH UINT8_MAX +#define TS_MAX_TREE_POOL_SIZE 32 + +// ExternalScannerState + +void ts_external_scanner_state_init(ExternalScannerState *self, const char *data, unsigned length) { + self->length = length; + if (length > sizeof(self->short_data)) { + self->long_data = ts_malloc(length); + memcpy(self->long_data, data, length); + } else { + memcpy(self->short_data, data, length); + } +} + +ExternalScannerState ts_external_scanner_state_copy(const ExternalScannerState *self) { + ExternalScannerState result = *self; + if (self->length > sizeof(self->short_data)) { + result.long_data = ts_malloc(self->length); + memcpy(result.long_data, self->long_data, self->length); + } + return result; +} + +void ts_external_scanner_state_delete(ExternalScannerState *self) { + if (self->length > sizeof(self->short_data)) { + ts_free(self->long_data); + } +} + +const char *ts_external_scanner_state_data(const ExternalScannerState *self) { + if (self->length > sizeof(self->short_data)) { + return self->long_data; + } else { + return self->short_data; + } +} + +bool ts_external_scanner_state_eq(const ExternalScannerState *self, const char *buffer, unsigned length) { + return + self->length == length && + memcmp(ts_external_scanner_state_data(self), buffer, length) == 0; +} + +// SubtreeArray + +void ts_subtree_array_copy(SubtreeArray self, SubtreeArray *dest) { + dest->size = self.size; + dest->capacity = self.capacity; + dest->contents = self.contents; + if (self.capacity > 0) { + dest->contents = ts_calloc(self.capacity, sizeof(Subtree)); + memcpy(dest->contents, self.contents, self.size * sizeof(Subtree)); + for (uint32_t i = 0; i < self.size; i++) { + ts_subtree_retain(*array_get(dest, i)); + } + } +} + +void ts_subtree_array_clear(SubtreePool *pool, SubtreeArray *self) { + for (uint32_t i = 0; i < self->size; i++) { + ts_subtree_release(pool, *array_get(self, i)); + } + array_clear(self); +} + +void ts_subtree_array_delete(SubtreePool *pool, SubtreeArray *self) { + ts_subtree_array_clear(pool, self); + array_delete(self); +} + +void ts_subtree_array_remove_trailing_extras( + SubtreeArray *self, + SubtreeArray *destination +) { + array_clear(destination); + while (self->size > 0) { + Subtree last = *array_get(self, self->size - 1); + if (ts_subtree_extra(last)) { + self->size--; + array_push(destination, last); + } else { + break; + } + } + ts_subtree_array_reverse(destination); +} + +void ts_subtree_array_reverse(SubtreeArray *self) { + for (uint32_t i = 0, limit = self->size / 2; i < limit; i++) { + size_t reverse_index = self->size - 1 - i; + Subtree swap = *array_get(self, i); + *array_get(self, i) = *array_get(self, reverse_index); + *array_get(self, reverse_index) = swap; + } +} + +// SubtreePool + +SubtreePool ts_subtree_pool_new(uint32_t capacity) { + SubtreePool self = {array_new(), array_new()}; + array_reserve(&self.free_trees, capacity); + return self; +} + +void ts_subtree_pool_delete(SubtreePool *self) { + if (self->free_trees.contents) { + for (unsigned i = 0; i < self->free_trees.size; i++) { + ts_free(array_get(&self->free_trees, i)->ptr); + } + array_delete(&self->free_trees); + } + if (self->tree_stack.contents) array_delete(&self->tree_stack); +} + +static SubtreeHeapData *ts_subtree_pool_allocate(SubtreePool *self) { + if (self->free_trees.size > 0) { + return array_pop(&self->free_trees).ptr; + } else { + return ts_malloc(sizeof(SubtreeHeapData)); + } +} + +static void ts_subtree_pool_free(SubtreePool *self, SubtreeHeapData *tree) { + if (self->free_trees.capacity > 0 && self->free_trees.size + 1 <= TS_MAX_TREE_POOL_SIZE) { + array_push(&self->free_trees, (MutableSubtree) {.ptr = tree}); + } else { + ts_free(tree); + } +} + +// Subtree + +static inline bool ts_subtree_can_inline(Length padding, Length size, uint32_t lookahead_bytes) { + return + padding.bytes < TS_MAX_INLINE_TREE_LENGTH && + padding.extent.row < 16 && + padding.extent.column < TS_MAX_INLINE_TREE_LENGTH && + size.bytes < TS_MAX_INLINE_TREE_LENGTH && + size.extent.row == 0 && + size.extent.column < TS_MAX_INLINE_TREE_LENGTH && + lookahead_bytes < 16; +} + +Subtree ts_subtree_new_leaf( + SubtreePool *pool, TSSymbol symbol, Length padding, Length size, + uint32_t lookahead_bytes, TSStateId parse_state, + bool has_external_tokens, bool depends_on_column, + bool is_keyword, const TSLanguage *language +) { + TSSymbolMetadata metadata = ts_language_symbol_metadata(language, symbol); + bool extra = symbol == ts_builtin_sym_end; + + bool is_inline = ( + symbol <= UINT8_MAX && + !has_external_tokens && + ts_subtree_can_inline(padding, size, lookahead_bytes) + ); + + if (is_inline) { + return (Subtree) {{ + .parse_state = parse_state, + .symbol = symbol, + .padding_bytes = padding.bytes, + .padding_rows = padding.extent.row, + .padding_columns = padding.extent.column, + .size_bytes = size.bytes, + .lookahead_bytes = lookahead_bytes, + .visible = metadata.visible, + .named = metadata.named, + .extra = extra, + .has_changes = false, + .is_missing = false, + .is_keyword = is_keyword, + .is_inline = true, + }}; + } else { + SubtreeHeapData *data = ts_subtree_pool_allocate(pool); + *data = (SubtreeHeapData) { + .ref_count = 1, + .padding = padding, + .size = size, + .lookahead_bytes = lookahead_bytes, + .error_cost = 0, + .child_count = 0, + .symbol = symbol, + .parse_state = parse_state, + .visible = metadata.visible, + .named = metadata.named, + .extra = extra, + .fragile_left = false, + .fragile_right = false, + .has_changes = false, + .has_external_tokens = has_external_tokens, + .has_external_scanner_state_change = false, + .depends_on_column = depends_on_column, + .is_missing = false, + .is_keyword = is_keyword, + {{.first_leaf = {.symbol = 0, .parse_state = 0}}} + }; + return (Subtree) {.ptr = data}; + } +} + +void ts_subtree_set_symbol( + MutableSubtree *self, + TSSymbol symbol, + const TSLanguage *language +) { + TSSymbolMetadata metadata = ts_language_symbol_metadata(language, symbol); + if (self->data.is_inline) { + ts_assert(symbol < UINT8_MAX); + self->data.symbol = symbol; + self->data.named = metadata.named; + self->data.visible = metadata.visible; + } else { + self->ptr->symbol = symbol; + self->ptr->named = metadata.named; + self->ptr->visible = metadata.visible; + } +} + +Subtree ts_subtree_new_error( + SubtreePool *pool, int32_t lookahead_char, Length padding, Length size, + uint32_t bytes_scanned, TSStateId parse_state, const TSLanguage *language +) { + Subtree result = ts_subtree_new_leaf( + pool, ts_builtin_sym_error, padding, size, bytes_scanned, + parse_state, false, false, false, language + ); + SubtreeHeapData *data = (SubtreeHeapData *)result.ptr; + data->fragile_left = true; + data->fragile_right = true; + data->lookahead_char = lookahead_char; + return result; +} + +// Clone a subtree. +MutableSubtree ts_subtree_clone(Subtree self) { + size_t alloc_size = ts_subtree_alloc_size(self.ptr->child_count); + Subtree *new_children = ts_malloc(alloc_size); + Subtree *old_children = ts_subtree_children(self); + memcpy(new_children, old_children, alloc_size); + SubtreeHeapData *result = (SubtreeHeapData *)&new_children[self.ptr->child_count]; + if (self.ptr->child_count > 0) { + for (uint32_t i = 0; i < self.ptr->child_count; i++) { + ts_subtree_retain(new_children[i]); + } + } else if (self.ptr->has_external_tokens) { + result->external_scanner_state = ts_external_scanner_state_copy( + &self.ptr->external_scanner_state + ); + } + result->ref_count = 1; + return (MutableSubtree) {.ptr = result}; +} + +// Get mutable version of a subtree. +// +// This takes ownership of the subtree. If the subtree has only one owner, +// this will directly convert it into a mutable version. Otherwise, it will +// perform a copy. +MutableSubtree ts_subtree_make_mut(SubtreePool *pool, Subtree self) { + if (self.data.is_inline) return (MutableSubtree) {self.data}; + if (self.ptr->ref_count == 1) return ts_subtree_to_mut_unsafe(self); + MutableSubtree result = ts_subtree_clone(self); + ts_subtree_release(pool, self); + return result; +} + +void ts_subtree_compress( + MutableSubtree self, + unsigned count, + const TSLanguage *language, + MutableSubtreeArray *stack +) { + unsigned initial_stack_size = stack->size; + + MutableSubtree tree = self; + TSSymbol symbol = tree.ptr->symbol; + for (unsigned i = 0; i < count; i++) { + if (tree.ptr->ref_count > 1 || tree.ptr->child_count < 2) break; + + MutableSubtree child = ts_subtree_to_mut_unsafe(ts_subtree_children(tree)[0]); + if ( + child.data.is_inline || + child.ptr->child_count < 2 || + child.ptr->ref_count > 1 || + child.ptr->symbol != symbol + ) break; + + MutableSubtree grandchild = ts_subtree_to_mut_unsafe(ts_subtree_children(child)[0]); + if ( + grandchild.data.is_inline || + grandchild.ptr->child_count < 2 || + grandchild.ptr->ref_count > 1 || + grandchild.ptr->symbol != symbol + ) break; + + ts_subtree_children(tree)[0] = ts_subtree_from_mut(grandchild); + ts_subtree_children(child)[0] = ts_subtree_children(grandchild)[grandchild.ptr->child_count - 1]; + ts_subtree_children(grandchild)[grandchild.ptr->child_count - 1] = ts_subtree_from_mut(child); + array_push(stack, tree); + tree = grandchild; + } + + while (stack->size > initial_stack_size) { + tree = array_pop(stack); + MutableSubtree child = ts_subtree_to_mut_unsafe(ts_subtree_children(tree)[0]); + MutableSubtree grandchild = ts_subtree_to_mut_unsafe(ts_subtree_children(child)[child.ptr->child_count - 1]); + ts_subtree_summarize_children(grandchild, language); + ts_subtree_summarize_children(child, language); + ts_subtree_summarize_children(tree, language); + } +} + +// Assign all of the node's properties that depend on its children. +void ts_subtree_summarize_children( + MutableSubtree self, + const TSLanguage *language +) { + ts_assert(!self.data.is_inline); + + self.ptr->named_child_count = 0; + self.ptr->visible_child_count = 0; + self.ptr->error_cost = 0; + self.ptr->repeat_depth = 0; + self.ptr->visible_descendant_count = 0; + self.ptr->has_external_tokens = false; + self.ptr->depends_on_column = false; + self.ptr->has_external_scanner_state_change = false; + self.ptr->dynamic_precedence = 0; + + uint32_t structural_index = 0; + const TSSymbol *alias_sequence = ts_language_alias_sequence(language, self.ptr->production_id); + uint32_t lookahead_end_byte = 0; + + const Subtree *children = ts_subtree_children(self); + for (uint32_t i = 0; i < self.ptr->child_count; i++) { + Subtree child = children[i]; + + if ( + self.ptr->size.extent.row == 0 && + ts_subtree_depends_on_column(child) + ) { + self.ptr->depends_on_column = true; + } + + if (ts_subtree_has_external_scanner_state_change(child)) { + self.ptr->has_external_scanner_state_change = true; + } + + if (i == 0) { + self.ptr->padding = ts_subtree_padding(child); + self.ptr->size = ts_subtree_size(child); + } else { + self.ptr->size = length_add(self.ptr->size, ts_subtree_total_size(child)); + } + + uint32_t child_lookahead_end_byte = + self.ptr->padding.bytes + + self.ptr->size.bytes + + ts_subtree_lookahead_bytes(child); + if (child_lookahead_end_byte > lookahead_end_byte) { + lookahead_end_byte = child_lookahead_end_byte; + } + + if (ts_subtree_symbol(child) != ts_builtin_sym_error_repeat) { + self.ptr->error_cost += ts_subtree_error_cost(child); + } + + uint32_t grandchild_count = ts_subtree_child_count(child); + if ( + self.ptr->symbol == ts_builtin_sym_error || + self.ptr->symbol == ts_builtin_sym_error_repeat + ) { + if (!ts_subtree_extra(child) && !(ts_subtree_is_error(child) && grandchild_count == 0)) { + if (ts_subtree_visible(child)) { + self.ptr->error_cost += ERROR_COST_PER_SKIPPED_TREE; + } else if (grandchild_count > 0) { + self.ptr->error_cost += ERROR_COST_PER_SKIPPED_TREE * child.ptr->visible_child_count; + } + } + } + + self.ptr->dynamic_precedence += ts_subtree_dynamic_precedence(child); + self.ptr->visible_descendant_count += ts_subtree_visible_descendant_count(child); + + if ( + !ts_subtree_extra(child) && + ts_subtree_symbol(child) != 0 && + alias_sequence && + alias_sequence[structural_index] != 0 + ) { + self.ptr->visible_descendant_count++; + self.ptr->visible_child_count++; + if (ts_language_symbol_metadata(language, alias_sequence[structural_index]).named) { + self.ptr->named_child_count++; + } + } else if (ts_subtree_visible(child)) { + self.ptr->visible_descendant_count++; + self.ptr->visible_child_count++; + if (ts_subtree_named(child)) self.ptr->named_child_count++; + } else if (grandchild_count > 0) { + self.ptr->visible_child_count += child.ptr->visible_child_count; + self.ptr->named_child_count += child.ptr->named_child_count; + } + + if (ts_subtree_has_external_tokens(child)) self.ptr->has_external_tokens = true; + + if (ts_subtree_is_error(child)) { + self.ptr->fragile_left = self.ptr->fragile_right = true; + self.ptr->parse_state = TS_TREE_STATE_NONE; + } + + if (!ts_subtree_extra(child)) structural_index++; + } + + self.ptr->lookahead_bytes = lookahead_end_byte - self.ptr->size.bytes - self.ptr->padding.bytes; + + if ( + self.ptr->symbol == ts_builtin_sym_error || + self.ptr->symbol == ts_builtin_sym_error_repeat + ) { + self.ptr->error_cost += + ERROR_COST_PER_RECOVERY + + ERROR_COST_PER_SKIPPED_CHAR * self.ptr->size.bytes + + ERROR_COST_PER_SKIPPED_LINE * self.ptr->size.extent.row; + } + + if (self.ptr->child_count > 0) { + Subtree first_child = children[0]; + Subtree last_child = children[self.ptr->child_count - 1]; + + self.ptr->first_leaf.symbol = ts_subtree_leaf_symbol(first_child); + self.ptr->first_leaf.parse_state = ts_subtree_leaf_parse_state(first_child); + + if (ts_subtree_fragile_left(first_child)) self.ptr->fragile_left = true; + if (ts_subtree_fragile_right(last_child)) self.ptr->fragile_right = true; + + if ( + self.ptr->child_count >= 2 && + !self.ptr->visible && + !self.ptr->named && + ts_subtree_symbol(first_child) == self.ptr->symbol + ) { + if (ts_subtree_repeat_depth(first_child) > ts_subtree_repeat_depth(last_child)) { + self.ptr->repeat_depth = ts_subtree_repeat_depth(first_child) + 1; + } else { + self.ptr->repeat_depth = ts_subtree_repeat_depth(last_child) + 1; + } + } + } +} + +// Create a new parent node with the given children. +// +// This takes ownership of the children array. +MutableSubtree ts_subtree_new_node( + TSSymbol symbol, + SubtreeArray *children, + unsigned production_id, + const TSLanguage *language +) { + TSSymbolMetadata metadata = ts_language_symbol_metadata(language, symbol); + bool fragile = symbol == ts_builtin_sym_error || symbol == ts_builtin_sym_error_repeat; + + // Allocate the node's data at the end of the array of children. + size_t new_byte_size = ts_subtree_alloc_size(children->size); + if (children->capacity * sizeof(Subtree) < new_byte_size) { + children->contents = ts_realloc(children->contents, new_byte_size); + children->capacity = (uint32_t)(new_byte_size / sizeof(Subtree)); + } + SubtreeHeapData *data = (SubtreeHeapData *)&children->contents[children->size]; + + *data = (SubtreeHeapData) { + .ref_count = 1, + .symbol = symbol, + .child_count = children->size, + .visible = metadata.visible, + .named = metadata.named, + .has_changes = false, + .has_external_scanner_state_change = false, + .fragile_left = fragile, + .fragile_right = fragile, + .is_keyword = false, + {{ + .visible_descendant_count = 0, + .production_id = production_id, + .first_leaf = {.symbol = 0, .parse_state = 0}, + }} + }; + MutableSubtree result = {.ptr = data}; + ts_subtree_summarize_children(result, language); + return result; +} + +// Create a new error node containing the given children. +// +// This node is treated as 'extra'. Its children are prevented from having +// having any effect on the parse state. +Subtree ts_subtree_new_error_node( + SubtreeArray *children, + bool extra, + const TSLanguage *language +) { + MutableSubtree result = ts_subtree_new_node( + ts_builtin_sym_error, children, 0, language + ); + result.ptr->extra = extra; + return ts_subtree_from_mut(result); +} + +// Create a new 'missing leaf' node. +// +// This node is treated as 'extra'. Its children are prevented from having +// having any effect on the parse state. +Subtree ts_subtree_new_missing_leaf( + SubtreePool *pool, + TSSymbol symbol, + Length padding, + uint32_t lookahead_bytes, + const TSLanguage *language +) { + Subtree result = ts_subtree_new_leaf( + pool, symbol, padding, length_zero(), lookahead_bytes, + 0, false, false, false, language + ); + if (result.data.is_inline) { + result.data.is_missing = true; + } else { + ((SubtreeHeapData *)result.ptr)->is_missing = true; + } + return result; +} + +void ts_subtree_retain(Subtree self) { + if (self.data.is_inline) return; + ts_assert(self.ptr->ref_count > 0); + atomic_inc((volatile uint32_t *)&self.ptr->ref_count); + ts_assert(self.ptr->ref_count != 0); +} + +void ts_subtree_release(SubtreePool *pool, Subtree self) { + if (self.data.is_inline) return; + array_clear(&pool->tree_stack); + + ts_assert(self.ptr->ref_count > 0); + if (atomic_dec((volatile uint32_t *)&self.ptr->ref_count) == 0) { + array_push(&pool->tree_stack, ts_subtree_to_mut_unsafe(self)); + } + + while (pool->tree_stack.size > 0) { + MutableSubtree tree = array_pop(&pool->tree_stack); + if (tree.ptr->child_count > 0) { + Subtree *children = ts_subtree_children(tree); + for (uint32_t i = 0; i < tree.ptr->child_count; i++) { + Subtree child = children[i]; + if (child.data.is_inline) continue; + ts_assert(child.ptr->ref_count > 0); + if (atomic_dec((volatile uint32_t *)&child.ptr->ref_count) == 0) { + array_push(&pool->tree_stack, ts_subtree_to_mut_unsafe(child)); + } + } + ts_free(children); + } else { + if (tree.ptr->has_external_tokens) { + ts_external_scanner_state_delete(&tree.ptr->external_scanner_state); + } + ts_subtree_pool_free(pool, tree.ptr); + } + } +} + +int ts_subtree_compare(Subtree left, Subtree right, SubtreePool *pool) { + array_push(&pool->tree_stack, ts_subtree_to_mut_unsafe(left)); + array_push(&pool->tree_stack, ts_subtree_to_mut_unsafe(right)); + + while (pool->tree_stack.size > 0) { + right = ts_subtree_from_mut(array_pop(&pool->tree_stack)); + left = ts_subtree_from_mut(array_pop(&pool->tree_stack)); + + int result = 0; + if (ts_subtree_symbol(left) < ts_subtree_symbol(right)) result = -1; + else if (ts_subtree_symbol(right) < ts_subtree_symbol(left)) result = 1; + else if (ts_subtree_child_count(left) < ts_subtree_child_count(right)) result = -1; + else if (ts_subtree_child_count(right) < ts_subtree_child_count(left)) result = 1; + if (result != 0) { + array_clear(&pool->tree_stack); + return result; + } + + for (uint32_t i = ts_subtree_child_count(left); i > 0; i--) { + Subtree left_child = ts_subtree_children(left)[i - 1]; + Subtree right_child = ts_subtree_children(right)[i - 1]; + array_push(&pool->tree_stack, ts_subtree_to_mut_unsafe(left_child)); + array_push(&pool->tree_stack, ts_subtree_to_mut_unsafe(right_child)); + } + } + + return 0; +} + +static inline void ts_subtree_set_has_changes(MutableSubtree *self) { + if (self->data.is_inline) { + self->data.has_changes = true; + } else { + self->ptr->has_changes = true; + } +} + +Subtree ts_subtree_edit(Subtree self, const TSInputEdit *input_edit, SubtreePool *pool) { + typedef struct { + Subtree *tree; + Edit edit; + } EditEntry; + + Array(EditEntry) stack = array_new(); + array_push(&stack, ((EditEntry) { + .tree = &self, + .edit = (Edit) { + .start = {input_edit->start_byte, input_edit->start_point}, + .old_end = {input_edit->old_end_byte, input_edit->old_end_point}, + .new_end = {input_edit->new_end_byte, input_edit->new_end_point}, + }, + })); + + while (stack.size) { + EditEntry entry = array_pop(&stack); + Edit edit = entry.edit; + bool is_noop = edit.old_end.bytes == edit.start.bytes && edit.new_end.bytes == edit.start.bytes; + bool is_pure_insertion = edit.old_end.bytes == edit.start.bytes; + bool parent_depends_on_column = ts_subtree_depends_on_column(*entry.tree); + bool column_shifted = edit.new_end.extent.column != edit.old_end.extent.column; + + Length size = ts_subtree_size(*entry.tree); + Length padding = ts_subtree_padding(*entry.tree); + Length total_size = length_add(padding, size); + uint32_t lookahead_bytes = ts_subtree_lookahead_bytes(*entry.tree); + uint32_t end_byte = total_size.bytes + lookahead_bytes; + if (edit.start.bytes > end_byte || (is_noop && edit.start.bytes == end_byte)) continue; + + // If the edit is entirely within the space before this subtree, then shift this + // subtree over according to the edit without changing its size. + if (edit.old_end.bytes <= padding.bytes) { + padding = length_add(edit.new_end, length_sub(padding, edit.old_end)); + } + + // If the edit starts in the space before this subtree and extends into this subtree, + // shrink the subtree's content to compensate for the change in the space before it. + else if (edit.start.bytes < padding.bytes) { + size = length_saturating_sub(size, length_sub(edit.old_end, padding)); + padding = edit.new_end; + } + + // If the edit is within this subtree, resize the subtree to reflect the edit. + else if ( + edit.start.bytes < total_size.bytes || + (edit.start.bytes == total_size.bytes && is_pure_insertion) + ) { + size = length_add( + length_sub(edit.new_end, padding), + length_saturating_sub(total_size, edit.old_end) + ); + } + + MutableSubtree result = ts_subtree_make_mut(pool, *entry.tree); + + if (result.data.is_inline) { + if (ts_subtree_can_inline(padding, size, lookahead_bytes)) { + result.data.padding_bytes = padding.bytes; + result.data.padding_rows = padding.extent.row; + result.data.padding_columns = padding.extent.column; + result.data.size_bytes = size.bytes; + } else { + SubtreeHeapData *data = ts_subtree_pool_allocate(pool); + data->ref_count = 1; + data->padding = padding; + data->size = size; + data->lookahead_bytes = lookahead_bytes; + data->error_cost = 0; + data->child_count = 0; + data->symbol = result.data.symbol; + data->parse_state = result.data.parse_state; + data->visible = result.data.visible; + data->named = result.data.named; + data->extra = result.data.extra; + data->fragile_left = false; + data->fragile_right = false; + data->has_changes = false; + data->has_external_tokens = false; + data->depends_on_column = false; + data->is_missing = result.data.is_missing; + data->is_keyword = result.data.is_keyword; + result.ptr = data; + } + } else { + result.ptr->padding = padding; + result.ptr->size = size; + } + + ts_subtree_set_has_changes(&result); + *entry.tree = ts_subtree_from_mut(result); + + Length child_left, child_right = length_zero(); + for (uint32_t i = 0, n = ts_subtree_child_count(*entry.tree); i < n; i++) { + Subtree *child = &ts_subtree_children(*entry.tree)[i]; + Length child_size = ts_subtree_total_size(*child); + child_left = child_right; + child_right = length_add(child_left, child_size); + + // If this child ends before the edit, it is not affected. + if (child_right.bytes + ts_subtree_lookahead_bytes(*child) < edit.start.bytes) continue; + + // Keep editing child nodes until a node is reached that starts after the edit. + // Also, if this node's validity depends on its column position, then continue + // invalidating child nodes until reaching a line break. + if (( + (child_left.bytes > edit.old_end.bytes) || + (child_left.bytes == edit.old_end.bytes && child_size.bytes > 0 && i > 0) + ) && ( + !parent_depends_on_column || + child_left.extent.row > padding.extent.row + ) && ( + !ts_subtree_depends_on_column(*child) || + !column_shifted || + child_left.extent.row > edit.old_end.extent.row + )) { + break; + } + + // Transform edit into the child's coordinate space. + Edit child_edit = { + .start = length_saturating_sub(edit.start, child_left), + .old_end = length_saturating_sub(edit.old_end, child_left), + .new_end = length_saturating_sub(edit.new_end, child_left), + }; + + // Interpret all inserted text as applying to the *first* child that touches the edit. + // Subsequent children are only never have any text inserted into them; they are only + // shrunk to compensate for the edit. + if ( + child_right.bytes > edit.start.bytes || + (child_right.bytes == edit.start.bytes && is_pure_insertion) + ) { + edit.new_end = edit.start; + } + + // Children that occur before the edit are not reshaped by the edit. + else { + child_edit.old_end = child_edit.start; + child_edit.new_end = child_edit.start; + } + + // Queue processing of this child's subtree. + array_push(&stack, ((EditEntry) { + .tree = child, + .edit = child_edit, + })); + } + } + + array_delete(&stack); + return self; +} + +Subtree ts_subtree_last_external_token(Subtree tree) { + if (!ts_subtree_has_external_tokens(tree)) return NULL_SUBTREE; + while (tree.ptr->child_count > 0) { + for (uint32_t i = tree.ptr->child_count - 1; i + 1 > 0; i--) { + Subtree child = ts_subtree_children(tree)[i]; + if (ts_subtree_has_external_tokens(child)) { + tree = child; + break; + } + } + } + return tree; +} + +static size_t ts_subtree__write_char_to_string(char *str, size_t n, int32_t chr) { + if (chr == -1) + return snprintf(str, n, "INVALID"); + else if (chr == '\0') + return snprintf(str, n, "'\\0'"); + else if (chr == '\n') + return snprintf(str, n, "'\\n'"); + else if (chr == '\t') + return snprintf(str, n, "'\\t'"); + else if (chr == '\r') + return snprintf(str, n, "'\\r'"); + else if (0 < chr && chr < 128 && isprint(chr)) + return snprintf(str, n, "'%c'", chr); + else + return snprintf(str, n, "%d", chr); +} + +static const char *const ROOT_FIELD = "__ROOT__"; + +static size_t ts_subtree__write_to_string( + Subtree self, char *string, size_t limit, + const TSLanguage *language, bool include_all, + TSSymbol alias_symbol, bool alias_is_named, const char *field_name +) { + if (!self.ptr) return snprintf(string, limit, "(NULL)"); + + char *cursor = string; + char **writer = (limit > 1) ? &cursor : &string; + bool is_root = field_name == ROOT_FIELD; + bool is_visible = + include_all || + ts_subtree_missing(self) || + ( + alias_symbol + ? alias_is_named + : ts_subtree_visible(self) && ts_subtree_named(self) + ); + + if (is_visible) { + if (!is_root) { + cursor += snprintf(*writer, limit, " "); + if (field_name) { + cursor += snprintf(*writer, limit, "%s: ", field_name); + } + } + + if (ts_subtree_is_error(self) && ts_subtree_child_count(self) == 0 && self.ptr->size.bytes > 0) { + cursor += snprintf(*writer, limit, "(UNEXPECTED "); + cursor += ts_subtree__write_char_to_string(*writer, limit, self.ptr->lookahead_char); + } else { + TSSymbol symbol = alias_symbol ? alias_symbol : ts_subtree_symbol(self); + const char *symbol_name = ts_language_symbol_name(language, symbol); + if (ts_subtree_missing(self)) { + cursor += snprintf(*writer, limit, "(MISSING "); + if (alias_is_named || ts_subtree_named(self)) { + cursor += snprintf(*writer, limit, "%s", symbol_name); + } else { + cursor += snprintf(*writer, limit, "\"%s\"", symbol_name); + } + } else { + cursor += snprintf(*writer, limit, "(%s", symbol_name); + } + } + } else if (is_root) { + TSSymbol symbol = alias_symbol ? alias_symbol : ts_subtree_symbol(self); + const char *symbol_name = ts_language_symbol_name(language, symbol); + if (ts_subtree_child_count(self) > 0) { + cursor += snprintf(*writer, limit, "(%s", symbol_name); + } else if (ts_subtree_named(self)) { + cursor += snprintf(*writer, limit, "(%s)", symbol_name); + } else { + cursor += snprintf(*writer, limit, "(\"%s\")", symbol_name); + } + } + + if (ts_subtree_child_count(self)) { + const TSSymbol *alias_sequence = ts_language_alias_sequence(language, self.ptr->production_id); + const TSFieldMapEntry *field_map, *field_map_end; + ts_language_field_map( + language, + self.ptr->production_id, + &field_map, + &field_map_end + ); + + uint32_t structural_child_index = 0; + for (uint32_t i = 0; i < self.ptr->child_count; i++) { + Subtree child = ts_subtree_children(self)[i]; + if (ts_subtree_extra(child)) { + cursor += ts_subtree__write_to_string( + child, *writer, limit, + language, include_all, + 0, false, NULL + ); + } else { + TSSymbol subtree_alias_symbol = alias_sequence + ? alias_sequence[structural_child_index] + : 0; + bool subtree_alias_is_named = subtree_alias_symbol + ? ts_language_symbol_metadata(language, subtree_alias_symbol).named + : false; + + const char *child_field_name = is_visible ? NULL : field_name; + for (const TSFieldMapEntry *map = field_map; map < field_map_end; map++) { + if (!map->inherited && map->child_index == structural_child_index) { + child_field_name = language->field_names[map->field_id]; + break; + } + } + + cursor += ts_subtree__write_to_string( + child, *writer, limit, + language, include_all, + subtree_alias_symbol, subtree_alias_is_named, child_field_name + ); + structural_child_index++; + } + } + } + + if (is_visible) cursor += snprintf(*writer, limit, ")"); + + return cursor - string; +} + +char *ts_subtree_string( + Subtree self, + TSSymbol alias_symbol, + bool alias_is_named, + const TSLanguage *language, + bool include_all +) { + char scratch_string[1]; + size_t size = ts_subtree__write_to_string( + self, scratch_string, 1, + language, include_all, + alias_symbol, alias_is_named, ROOT_FIELD + ) + 1; + char *result = ts_malloc(size * sizeof(char)); + ts_subtree__write_to_string( + self, result, size, + language, include_all, + alias_symbol, alias_is_named, ROOT_FIELD + ); + return result; +} + +void ts_subtree__print_dot_graph(const Subtree *self, uint32_t start_offset, + const TSLanguage *language, TSSymbol alias_symbol, + FILE *f) { + TSSymbol subtree_symbol = ts_subtree_symbol(*self); + TSSymbol symbol = alias_symbol ? alias_symbol : subtree_symbol; + uint32_t end_offset = start_offset + ts_subtree_total_bytes(*self); + fprintf(f, "tree_%p [label=\"", (void *)self); + ts_language_write_symbol_as_dot_string(language, f, symbol); + fprintf(f, "\""); + + if (ts_subtree_child_count(*self) == 0) fprintf(f, ", shape=plaintext"); + if (ts_subtree_extra(*self)) fprintf(f, ", fontcolor=gray"); + if (ts_subtree_has_changes(*self)) fprintf(f, ", color=green, penwidth=2"); + + fprintf(f, ", tooltip=\"" + "range: %u - %u\n" + "state: %d\n" + "error-cost: %u\n" + "has-changes: %u\n" + "depends-on-column: %u\n" + "descendant-count: %u\n" + "repeat-depth: %u\n" + "lookahead-bytes: %u", + start_offset, end_offset, + ts_subtree_parse_state(*self), + ts_subtree_error_cost(*self), + ts_subtree_has_changes(*self), + ts_subtree_depends_on_column(*self), + ts_subtree_visible_descendant_count(*self), + ts_subtree_repeat_depth(*self), + ts_subtree_lookahead_bytes(*self) + ); + + if (ts_subtree_is_error(*self) && ts_subtree_child_count(*self) == 0 && self->ptr->lookahead_char != 0) { + fprintf(f, "\ncharacter: '%c'", self->ptr->lookahead_char); + } + + fprintf(f, "\"]\n"); + + uint32_t child_start_offset = start_offset; + uint32_t child_info_offset = + language->max_alias_sequence_length * + ts_subtree_production_id(*self); + for (uint32_t i = 0, n = ts_subtree_child_count(*self); i < n; i++) { + const Subtree *child = &ts_subtree_children(*self)[i]; + TSSymbol subtree_alias_symbol = 0; + if (!ts_subtree_extra(*child) && child_info_offset) { + subtree_alias_symbol = language->alias_sequences[child_info_offset]; + child_info_offset++; + } + ts_subtree__print_dot_graph(child, child_start_offset, language, subtree_alias_symbol, f); + fprintf(f, "tree_%p -> tree_%p [tooltip=%u]\n", (void *)self, (void *)child, i); + child_start_offset += ts_subtree_total_bytes(*child); + } +} + +void ts_subtree_print_dot_graph(Subtree self, const TSLanguage *language, FILE *f) { + fprintf(f, "digraph tree {\n"); + fprintf(f, "edge [arrowhead=none]\n"); + ts_subtree__print_dot_graph(&self, 0, language, 0, f); + fprintf(f, "}\n"); +} + +const ExternalScannerState *ts_subtree_external_scanner_state(Subtree self) { + static const ExternalScannerState empty_state = {{.short_data = {0}}, .length = 0}; + if ( + self.ptr && + !self.data.is_inline && + self.ptr->has_external_tokens && + self.ptr->child_count == 0 + ) { + return &self.ptr->external_scanner_state; + } else { + return &empty_state; + } +} + +bool ts_subtree_external_scanner_state_eq(Subtree self, Subtree other) { + const ExternalScannerState *state_self = ts_subtree_external_scanner_state(self); + const ExternalScannerState *state_other = ts_subtree_external_scanner_state(other); + return ts_external_scanner_state_eq( + state_self, + ts_external_scanner_state_data(state_other), + state_other->length + ); +} diff --git a/vendor/tree-sitter/src/subtree.h b/vendor/tree-sitter/src/subtree.h new file mode 100644 index 0000000..ffc5fb7 --- /dev/null +++ b/vendor/tree-sitter/src/subtree.h @@ -0,0 +1,399 @@ +#ifndef TREE_SITTER_SUBTREE_H_ +#define TREE_SITTER_SUBTREE_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include +#include "./length.h" +#include "./array.h" +#include "./error_costs.h" +#include "./host.h" +#include "tree_sitter/api.h" +#include "./parser.h" + +#define TS_TREE_STATE_NONE USHRT_MAX +#define NULL_SUBTREE ((Subtree) {.ptr = NULL}) + +// The serialized state of an external scanner. +// +// Every time an external token subtree is created after a call to an +// external scanner, the scanner's `serialize` function is called to +// retrieve a serialized copy of its state. The bytes are then copied +// onto the subtree itself so that the scanner's state can later be +// restored using its `deserialize` function. +// +// Small byte arrays are stored inline, and long ones are allocated +// separately on the heap. +typedef struct { + union { + char *long_data; + char short_data[24]; + }; + uint32_t length; +} ExternalScannerState; + +// A compact representation of a subtree. +// +// This representation is used for small leaf nodes that are not +// errors, and were not created by an external scanner. +// +// The idea behind the layout of this struct is that the `is_inline` +// bit will fall exactly into the same location as the least significant +// bit of the pointer in `Subtree` or `MutableSubtree`, respectively. +// Because of alignment, for any valid pointer this will be 0, giving +// us the opportunity to make use of this bit to signify whether to use +// the pointer or the inline struct. +typedef struct SubtreeInlineData SubtreeInlineData; + +#define SUBTREE_BITS \ + bool visible : 1; \ + bool named : 1; \ + bool extra : 1; \ + bool has_changes : 1; \ + bool is_missing : 1; \ + bool is_keyword : 1; + +#define SUBTREE_SIZE \ + uint8_t padding_columns; \ + uint8_t padding_rows : 4; \ + uint8_t lookahead_bytes : 4; \ + uint8_t padding_bytes; \ + uint8_t size_bytes; + +#if TS_BIG_ENDIAN +#if TS_PTR_SIZE == 32 + +struct SubtreeInlineData { + uint16_t parse_state; + uint8_t symbol; + SUBTREE_BITS + bool unused : 1; + bool is_inline : 1; + SUBTREE_SIZE +}; + +#else + +struct SubtreeInlineData { + SUBTREE_SIZE + uint16_t parse_state; + uint8_t symbol; + SUBTREE_BITS + bool unused : 1; + bool is_inline : 1; +}; + +#endif +#else + +struct SubtreeInlineData { + bool is_inline : 1; + SUBTREE_BITS + uint8_t symbol; + uint16_t parse_state; + SUBTREE_SIZE +}; + +#endif + +#undef SUBTREE_BITS +#undef SUBTREE_SIZE + +// A heap-allocated representation of a subtree. +// +// This representation is used for parent nodes, external tokens, +// errors, and other leaf nodes whose data is too large to fit into +// the inline representation. +typedef struct { + volatile uint32_t ref_count; + Length padding; + Length size; + uint32_t lookahead_bytes; + uint32_t error_cost; + uint32_t child_count; + TSSymbol symbol; + TSStateId parse_state; + + bool visible : 1; + bool named : 1; + bool extra : 1; + bool fragile_left : 1; + bool fragile_right : 1; + bool has_changes : 1; + bool has_external_tokens : 1; + bool has_external_scanner_state_change : 1; + bool depends_on_column: 1; + bool is_missing : 1; + bool is_keyword : 1; + + union { + // Non-terminal subtrees (`child_count > 0`) + struct { + uint32_t visible_child_count; + uint32_t named_child_count; + uint32_t visible_descendant_count; + int32_t dynamic_precedence; + uint16_t repeat_depth; + uint16_t production_id; + struct { + TSSymbol symbol; + TSStateId parse_state; + } first_leaf; + }; + + // External terminal subtrees (`child_count == 0 && has_external_tokens`) + ExternalScannerState external_scanner_state; + + // Error terminal subtrees (`child_count == 0 && symbol == ts_builtin_sym_error`) + int32_t lookahead_char; + }; +} SubtreeHeapData; + +// The fundamental building block of a syntax tree. +typedef union { + SubtreeInlineData data; + const SubtreeHeapData *ptr; +} Subtree; + +// Like Subtree, but mutable. +typedef union { + SubtreeInlineData data; + SubtreeHeapData *ptr; +} MutableSubtree; + +typedef Array(Subtree) SubtreeArray; +typedef Array(MutableSubtree) MutableSubtreeArray; + +typedef struct { + MutableSubtreeArray free_trees; + MutableSubtreeArray tree_stack; +} SubtreePool; + +void ts_external_scanner_state_init(ExternalScannerState *self, const char *data, unsigned length); +const char *ts_external_scanner_state_data(const ExternalScannerState *self); +bool ts_external_scanner_state_eq(const ExternalScannerState *self, const char *buffer, unsigned length); +void ts_external_scanner_state_delete(ExternalScannerState *self); + +void ts_subtree_array_copy(SubtreeArray self, SubtreeArray *dest); +void ts_subtree_array_clear(SubtreePool *pool, SubtreeArray *self); +void ts_subtree_array_delete(SubtreePool *pool, SubtreeArray *self); +void ts_subtree_array_remove_trailing_extras(SubtreeArray *self, SubtreeArray *destination); +void ts_subtree_array_reverse(SubtreeArray *self); + +SubtreePool ts_subtree_pool_new(uint32_t capacity); +void ts_subtree_pool_delete(SubtreePool *self); + +Subtree ts_subtree_new_leaf( + SubtreePool *pool, TSSymbol symbol, Length padding, Length size, + uint32_t lookahead_bytes, TSStateId parse_state, + bool has_external_tokens, bool depends_on_column, + bool is_keyword, const TSLanguage *language +); +Subtree ts_subtree_new_error( + SubtreePool *pool, int32_t lookahead_char, Length padding, Length size, + uint32_t bytes_scanned, TSStateId parse_state, const TSLanguage *language +); +MutableSubtree ts_subtree_new_node( + TSSymbol symbol, + SubtreeArray *chiildren, + unsigned production_id, + const TSLanguage *language +); +Subtree ts_subtree_new_error_node( + SubtreeArray *children, + bool extra, + const TSLanguage * language +); +Subtree ts_subtree_new_missing_leaf( + SubtreePool *pool, + TSSymbol symbol, + Length padding, + uint32_t lookahead_bytes, + const TSLanguage *language +); +MutableSubtree ts_subtree_make_mut(SubtreePool *pool, Subtree self); +void ts_subtree_retain(Subtree self); +void ts_subtree_release(SubtreePool *pool, Subtree self); +int ts_subtree_compare(Subtree left, Subtree right, SubtreePool *pool); +void ts_subtree_set_symbol(MutableSubtree *self, TSSymbol symbol, const TSLanguage *language); +void ts_subtree_compress(MutableSubtree self, unsigned count, const TSLanguage *language, MutableSubtreeArray *stack); +void ts_subtree_summarize_children(MutableSubtree self, const TSLanguage *language); +Subtree ts_subtree_edit(Subtree self, const TSInputEdit *edit, SubtreePool *pool); +char *ts_subtree_string(Subtree self, TSSymbol alias_symbol, bool alias_is_named, const TSLanguage *language, bool include_all); +void ts_subtree_print_dot_graph(Subtree self, const TSLanguage *language, FILE *f); +Subtree ts_subtree_last_external_token(Subtree tree); +const ExternalScannerState *ts_subtree_external_scanner_state(Subtree self); +bool ts_subtree_external_scanner_state_eq(Subtree self, Subtree other); + +#define SUBTREE_GET(self, name) ((self).data.is_inline ? (self).data.name : (self).ptr->name) + +static inline TSSymbol ts_subtree_symbol(Subtree self) { return SUBTREE_GET(self, symbol); } +static inline bool ts_subtree_visible(Subtree self) { return SUBTREE_GET(self, visible); } +static inline bool ts_subtree_named(Subtree self) { return SUBTREE_GET(self, named); } +static inline bool ts_subtree_extra(Subtree self) { return SUBTREE_GET(self, extra); } +static inline bool ts_subtree_has_changes(Subtree self) { return SUBTREE_GET(self, has_changes); } +static inline bool ts_subtree_missing(Subtree self) { return SUBTREE_GET(self, is_missing); } +static inline bool ts_subtree_is_keyword(Subtree self) { return SUBTREE_GET(self, is_keyword); } +static inline TSStateId ts_subtree_parse_state(Subtree self) { return SUBTREE_GET(self, parse_state); } +static inline uint32_t ts_subtree_lookahead_bytes(Subtree self) { return SUBTREE_GET(self, lookahead_bytes); } + +#undef SUBTREE_GET + +// Get the size needed to store a heap-allocated subtree with the given +// number of children. +static inline size_t ts_subtree_alloc_size(uint32_t child_count) { + return child_count * sizeof(Subtree) + sizeof(SubtreeHeapData); +} + +// Get a subtree's children, which are allocated immediately before the +// tree's own heap data. +#define ts_subtree_children(self) \ + ((self).data.is_inline ? NULL : (Subtree *)((self).ptr) - (self).ptr->child_count) + +static inline void ts_subtree_set_extra(MutableSubtree *self, bool is_extra) { + if (self->data.is_inline) { + self->data.extra = is_extra; + } else { + self->ptr->extra = is_extra; + } +} + +static inline TSSymbol ts_subtree_leaf_symbol(Subtree self) { + if (self.data.is_inline) return self.data.symbol; + if (self.ptr->child_count == 0) return self.ptr->symbol; + return self.ptr->first_leaf.symbol; +} + +static inline TSStateId ts_subtree_leaf_parse_state(Subtree self) { + if (self.data.is_inline) return self.data.parse_state; + if (self.ptr->child_count == 0) return self.ptr->parse_state; + return self.ptr->first_leaf.parse_state; +} + +static inline Length ts_subtree_padding(Subtree self) { + if (self.data.is_inline) { + Length result = {self.data.padding_bytes, {self.data.padding_rows, self.data.padding_columns}}; + return result; + } else { + return self.ptr->padding; + } +} + +static inline Length ts_subtree_size(Subtree self) { + if (self.data.is_inline) { + Length result = {self.data.size_bytes, {0, self.data.size_bytes}}; + return result; + } else { + return self.ptr->size; + } +} + +static inline Length ts_subtree_total_size(Subtree self) { + return length_add(ts_subtree_padding(self), ts_subtree_size(self)); +} + +static inline uint32_t ts_subtree_total_bytes(Subtree self) { + return ts_subtree_total_size(self).bytes; +} + +static inline uint32_t ts_subtree_child_count(Subtree self) { + return self.data.is_inline ? 0 : self.ptr->child_count; +} + +static inline uint32_t ts_subtree_repeat_depth(Subtree self) { + return self.data.is_inline ? 0 : self.ptr->repeat_depth; +} + +static inline uint32_t ts_subtree_is_repetition(Subtree self) { + return self.data.is_inline + ? 0 + : !self.ptr->named && !self.ptr->visible && self.ptr->child_count != 0; +} + +static inline uint32_t ts_subtree_visible_descendant_count(Subtree self) { + return (self.data.is_inline || self.ptr->child_count == 0) + ? 0 + : self.ptr->visible_descendant_count; +} + +static inline uint32_t ts_subtree_visible_child_count(Subtree self) { + if (ts_subtree_child_count(self) > 0) { + return self.ptr->visible_child_count; + } else { + return 0; + } +} + +static inline uint32_t ts_subtree_error_cost(Subtree self) { + if (ts_subtree_missing(self)) { + return ERROR_COST_PER_MISSING_TREE + ERROR_COST_PER_RECOVERY; + } else { + return self.data.is_inline ? 0 : self.ptr->error_cost; + } +} + +static inline int32_t ts_subtree_dynamic_precedence(Subtree self) { + return (self.data.is_inline || self.ptr->child_count == 0) ? 0 : self.ptr->dynamic_precedence; +} + +static inline uint16_t ts_subtree_production_id(Subtree self) { + if (ts_subtree_child_count(self) > 0) { + return self.ptr->production_id; + } else { + return 0; + } +} + +static inline bool ts_subtree_fragile_left(Subtree self) { + return self.data.is_inline ? false : self.ptr->fragile_left; +} + +static inline bool ts_subtree_fragile_right(Subtree self) { + return self.data.is_inline ? false : self.ptr->fragile_right; +} + +static inline bool ts_subtree_has_external_tokens(Subtree self) { + return self.data.is_inline ? false : self.ptr->has_external_tokens; +} + +static inline bool ts_subtree_has_external_scanner_state_change(Subtree self) { + return self.data.is_inline ? false : self.ptr->has_external_scanner_state_change; +} + +static inline bool ts_subtree_depends_on_column(Subtree self) { + return self.data.is_inline ? false : self.ptr->depends_on_column; +} + +static inline bool ts_subtree_is_fragile(Subtree self) { + return self.data.is_inline ? false : (self.ptr->fragile_left || self.ptr->fragile_right); +} + +static inline bool ts_subtree_is_error(Subtree self) { + return ts_subtree_symbol(self) == ts_builtin_sym_error; +} + +static inline bool ts_subtree_is_eof(Subtree self) { + return ts_subtree_symbol(self) == ts_builtin_sym_end; +} + +static inline Subtree ts_subtree_from_mut(MutableSubtree self) { + Subtree result; + result.data = self.data; + return result; +} + +static inline MutableSubtree ts_subtree_to_mut_unsafe(Subtree self) { + MutableSubtree result; + result.data = self.data; + return result; +} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_SUBTREE_H_ diff --git a/vendor/tree-sitter/src/tree.c b/vendor/tree-sitter/src/tree.c new file mode 100644 index 0000000..bb45118 --- /dev/null +++ b/vendor/tree-sitter/src/tree.c @@ -0,0 +1,170 @@ +#include "tree_sitter/api.h" +#include "./array.h" +#include "./get_changed_ranges.h" +#include "./length.h" +#include "./subtree.h" +#include "./tree_cursor.h" +#include "./tree.h" + +TSTree *ts_tree_new( + Subtree root, const TSLanguage *language, + const TSRange *included_ranges, unsigned included_range_count +) { + TSTree *result = ts_malloc(sizeof(TSTree)); + result->root = root; + result->language = ts_language_copy(language); + result->included_ranges = ts_calloc(included_range_count, sizeof(TSRange)); + memcpy(result->included_ranges, included_ranges, included_range_count * sizeof(TSRange)); + result->included_range_count = included_range_count; + return result; +} + +TSTree *ts_tree_copy(const TSTree *self) { + ts_subtree_retain(self->root); + return ts_tree_new(self->root, self->language, self->included_ranges, self->included_range_count); +} + +void ts_tree_delete(TSTree *self) { + if (!self) return; + + SubtreePool pool = ts_subtree_pool_new(0); + ts_subtree_release(&pool, self->root); + ts_subtree_pool_delete(&pool); + ts_language_delete(self->language); + ts_free(self->included_ranges); + ts_free(self); +} + +TSNode ts_tree_root_node(const TSTree *self) { + return ts_node_new(self, &self->root, ts_subtree_padding(self->root), 0); +} + +TSNode ts_tree_root_node_with_offset( + const TSTree *self, + uint32_t offset_bytes, + TSPoint offset_extent +) { + Length offset = {offset_bytes, offset_extent}; + return ts_node_new(self, &self->root, length_add(offset, ts_subtree_padding(self->root)), 0); +} + +const TSLanguage *ts_tree_language(const TSTree *self) { + return self->language; +} + +void ts_tree_edit(TSTree *self, const TSInputEdit *edit) { + for (unsigned i = 0; i < self->included_range_count; i++) { + TSRange *range = &self->included_ranges[i]; + if (range->end_byte >= edit->old_end_byte) { + if (range->end_byte != UINT32_MAX) { + range->end_byte = edit->new_end_byte + (range->end_byte - edit->old_end_byte); + range->end_point = point_add( + edit->new_end_point, + point_sub(range->end_point, edit->old_end_point) + ); + if (range->end_byte < edit->new_end_byte) { + range->end_byte = UINT32_MAX; + range->end_point = POINT_MAX; + } + } + } else if (range->end_byte > edit->start_byte) { + range->end_byte = edit->start_byte; + range->end_point = edit->start_point; + } + if (range->start_byte >= edit->old_end_byte) { + range->start_byte = edit->new_end_byte + (range->start_byte - edit->old_end_byte); + range->start_point = point_add( + edit->new_end_point, + point_sub(range->start_point, edit->old_end_point) + ); + if (range->start_byte < edit->new_end_byte) { + range->start_byte = UINT32_MAX; + range->start_point = POINT_MAX; + } + } else if (range->start_byte > edit->start_byte) { + range->start_byte = edit->start_byte; + range->start_point = edit->start_point; + } + } + + SubtreePool pool = ts_subtree_pool_new(0); + self->root = ts_subtree_edit(self->root, edit, &pool); + ts_subtree_pool_delete(&pool); +} + +TSRange *ts_tree_included_ranges(const TSTree *self, uint32_t *length) { + *length = self->included_range_count; + TSRange *ranges = ts_calloc(self->included_range_count, sizeof(TSRange)); + memcpy(ranges, self->included_ranges, self->included_range_count * sizeof(TSRange)); + return ranges; +} + +TSRange *ts_tree_get_changed_ranges(const TSTree *old_tree, const TSTree *new_tree, uint32_t *length) { + TreeCursor cursor1 = {NULL, array_new(), 0}; + TreeCursor cursor2 = {NULL, array_new(), 0}; + ts_tree_cursor_init(&cursor1, ts_tree_root_node(old_tree)); + ts_tree_cursor_init(&cursor2, ts_tree_root_node(new_tree)); + + TSRangeArray included_range_differences = array_new(); + ts_range_array_get_changed_ranges( + old_tree->included_ranges, old_tree->included_range_count, + new_tree->included_ranges, new_tree->included_range_count, + &included_range_differences + ); + + TSRange *result; + *length = ts_subtree_get_changed_ranges( + &old_tree->root, &new_tree->root, &cursor1, &cursor2, + old_tree->language, &included_range_differences, &result + ); + + array_delete(&included_range_differences); + array_delete(&cursor1.stack); + array_delete(&cursor2.stack); + return result; +} + +#ifdef _WIN32 + +#include +#include + +int _ts_dup(HANDLE handle) { + HANDLE dup_handle; + if (!DuplicateHandle( + GetCurrentProcess(), handle, + GetCurrentProcess(), &dup_handle, + 0, FALSE, DUPLICATE_SAME_ACCESS + )) return -1; + + return _open_osfhandle((intptr_t)dup_handle, 0); +} + +void ts_tree_print_dot_graph(const TSTree *self, int fd) { + FILE *file = _fdopen(_ts_dup((HANDLE)_get_osfhandle(fd)), "a"); + ts_subtree_print_dot_graph(self->root, self->language, file); + fclose(file); +} + +#elif !defined(__wasi__) // WASI doesn't support dup + +#include + +int _ts_dup(int file_descriptor) { + return dup(file_descriptor); +} + +void ts_tree_print_dot_graph(const TSTree *self, int file_descriptor) { + FILE *file = fdopen(_ts_dup(file_descriptor), "a"); + ts_subtree_print_dot_graph(self->root, self->language, file); + fclose(file); +} + +#else + +void ts_tree_print_dot_graph(const TSTree *self, int file_descriptor) { + (void)self; + (void)file_descriptor; +} + +#endif diff --git a/vendor/tree-sitter/src/tree.h b/vendor/tree-sitter/src/tree.h new file mode 100644 index 0000000..9328f55 --- /dev/null +++ b/vendor/tree-sitter/src/tree.h @@ -0,0 +1,31 @@ +#ifndef TREE_SITTER_TREE_H_ +#define TREE_SITTER_TREE_H_ + +#include "./subtree.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const Subtree *child; + const Subtree *parent; + Length position; + TSSymbol alias_symbol; +} ParentCacheEntry; + +struct TSTree { + Subtree root; + const TSLanguage *language; + TSRange *included_ranges; + unsigned included_range_count; +}; + +TSTree *ts_tree_new(Subtree root, const TSLanguage *language, const TSRange *included_ranges, unsigned included_range_count); +TSNode ts_node_new(const TSTree *tree, const Subtree *subtree, Length position, TSSymbol alias); + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_TREE_H_ diff --git a/vendor/tree-sitter/src/tree_cursor.c b/vendor/tree-sitter/src/tree_cursor.c new file mode 100644 index 0000000..70ef5e3 --- /dev/null +++ b/vendor/tree-sitter/src/tree_cursor.c @@ -0,0 +1,716 @@ +#include "tree_sitter/api.h" +#include "./tree_cursor.h" +#include "./language.h" +#include "./tree.h" + +typedef struct { + Subtree parent; + const TSTree *tree; + Length position; + uint32_t child_index; + uint32_t structural_child_index; + uint32_t descendant_index; + const TSSymbol *alias_sequence; +} CursorChildIterator; + +// CursorChildIterator + +static inline bool ts_tree_cursor_is_entry_visible(const TreeCursor *self, uint32_t index) { + TreeCursorEntry *entry = array_get(&self->stack, index); + if (index == 0 || ts_subtree_visible(*entry->subtree)) { + return true; + } else if (!ts_subtree_extra(*entry->subtree)) { + TreeCursorEntry *parent_entry = array_get(&self->stack, index - 1); + return ts_language_alias_at( + self->tree->language, + parent_entry->subtree->ptr->production_id, + entry->structural_child_index + ); + } else { + return false; + } +} + +static inline CursorChildIterator ts_tree_cursor_iterate_children(const TreeCursor *self) { + TreeCursorEntry *last_entry = array_back(&self->stack); + if (ts_subtree_child_count(*last_entry->subtree) == 0) { + return (CursorChildIterator) {NULL_SUBTREE, self->tree, length_zero(), 0, 0, 0, NULL}; + } + const TSSymbol *alias_sequence = ts_language_alias_sequence( + self->tree->language, + last_entry->subtree->ptr->production_id + ); + + uint32_t descendant_index = last_entry->descendant_index; + if (ts_tree_cursor_is_entry_visible(self, self->stack.size - 1)) { + descendant_index += 1; + } + + return (CursorChildIterator) { + .tree = self->tree, + .parent = *last_entry->subtree, + .position = last_entry->position, + .child_index = 0, + .structural_child_index = 0, + .descendant_index = descendant_index, + .alias_sequence = alias_sequence, + }; +} + +static inline bool ts_tree_cursor_child_iterator_next( + CursorChildIterator *self, + TreeCursorEntry *result, + bool *visible +) { + if (!self->parent.ptr || self->child_index == self->parent.ptr->child_count) return false; + const Subtree *child = &ts_subtree_children(self->parent)[self->child_index]; + *result = (TreeCursorEntry) { + .subtree = child, + .position = self->position, + .child_index = self->child_index, + .structural_child_index = self->structural_child_index, + .descendant_index = self->descendant_index, + }; + *visible = ts_subtree_visible(*child); + bool extra = ts_subtree_extra(*child); + if (!extra) { + if (self->alias_sequence) { + *visible |= self->alias_sequence[self->structural_child_index]; + } + self->structural_child_index++; + } + + self->descendant_index += ts_subtree_visible_descendant_count(*child); + if (*visible) { + self->descendant_index += 1; + } + + self->position = length_add(self->position, ts_subtree_size(*child)); + self->child_index++; + + if (self->child_index < self->parent.ptr->child_count) { + Subtree next_child = ts_subtree_children(self->parent)[self->child_index]; + self->position = length_add(self->position, ts_subtree_padding(next_child)); + } + + return true; +} + +// Return a position that, when `b` is added to it, yields `a`. This +// can only be computed if `b` has zero rows. Otherwise, this function +// returns `LENGTH_UNDEFINED`, and the caller needs to recompute +// the position some other way. +static inline Length length_backtrack(Length a, Length b) { + if (length_is_undefined(a) || b.extent.row != 0) { + return LENGTH_UNDEFINED; + } + + Length result; + result.bytes = a.bytes - b.bytes; + result.extent.row = a.extent.row; + result.extent.column = a.extent.column - b.extent.column; + return result; +} + +static inline bool ts_tree_cursor_child_iterator_previous( + CursorChildIterator *self, + TreeCursorEntry *result, + bool *visible +) { + // this is mostly a reverse `ts_tree_cursor_child_iterator_next` taking into + // account unsigned underflow + if (!self->parent.ptr || (int8_t)self->child_index == -1) return false; + const Subtree *child = &ts_subtree_children(self->parent)[self->child_index]; + *result = (TreeCursorEntry) { + .subtree = child, + .position = self->position, + .child_index = self->child_index, + .structural_child_index = self->structural_child_index, + }; + *visible = ts_subtree_visible(*child); + bool extra = ts_subtree_extra(*child); + + self->position = length_backtrack(self->position, ts_subtree_padding(*child)); + self->child_index--; + + if (!extra && self->alias_sequence) { + *visible |= self->alias_sequence[self->structural_child_index]; + if (self->structural_child_index > 0) { + self->structural_child_index--; + } + } + + // unsigned can underflow so compare it to child_count + if (self->child_index < self->parent.ptr->child_count) { + Subtree previous_child = ts_subtree_children(self->parent)[self->child_index]; + Length size = ts_subtree_size(previous_child); + self->position = length_backtrack(self->position, size); + } + + return true; +} + +// TSTreeCursor - lifecycle + +TSTreeCursor ts_tree_cursor_new(TSNode node) { + TSTreeCursor self = {NULL, NULL, {0, 0, 0}}; + ts_tree_cursor_init((TreeCursor *)&self, node); + return self; +} + +void ts_tree_cursor_reset(TSTreeCursor *_self, TSNode node) { + ts_tree_cursor_init((TreeCursor *)_self, node); +} + +void ts_tree_cursor_init(TreeCursor *self, TSNode node) { + self->tree = node.tree; + self->root_alias_symbol = node.context[3]; + array_clear(&self->stack); + array_push(&self->stack, ((TreeCursorEntry) { + .subtree = (const Subtree *)node.id, + .position = { + ts_node_start_byte(node), + ts_node_start_point(node) + }, + .child_index = 0, + .structural_child_index = 0, + .descendant_index = 0, + })); +} + +void ts_tree_cursor_delete(TSTreeCursor *_self) { + TreeCursor *self = (TreeCursor *)_self; + array_delete(&self->stack); +} + +// TSTreeCursor - walking the tree + +TreeCursorStep ts_tree_cursor_goto_first_child_internal(TSTreeCursor *_self) { + TreeCursor *self = (TreeCursor *)_self; + bool visible; + TreeCursorEntry entry; + CursorChildIterator iterator = ts_tree_cursor_iterate_children(self); + while (ts_tree_cursor_child_iterator_next(&iterator, &entry, &visible)) { + if (visible) { + array_push(&self->stack, entry); + return TreeCursorStepVisible; + } + if (ts_subtree_visible_child_count(*entry.subtree) > 0) { + array_push(&self->stack, entry); + return TreeCursorStepHidden; + } + } + return TreeCursorStepNone; +} + +bool ts_tree_cursor_goto_first_child(TSTreeCursor *self) { + for (;;) { + switch (ts_tree_cursor_goto_first_child_internal(self)) { + case TreeCursorStepHidden: + continue; + case TreeCursorStepVisible: + return true; + default: + return false; + } + } +} + +TreeCursorStep ts_tree_cursor_goto_last_child_internal(TSTreeCursor *_self) { + TreeCursor *self = (TreeCursor *)_self; + bool visible; + TreeCursorEntry entry; + CursorChildIterator iterator = ts_tree_cursor_iterate_children(self); + if (!iterator.parent.ptr || iterator.parent.ptr->child_count == 0) return TreeCursorStepNone; + + TreeCursorEntry last_entry = {0}; + TreeCursorStep last_step = TreeCursorStepNone; + while (ts_tree_cursor_child_iterator_next(&iterator, &entry, &visible)) { + if (visible) { + last_entry = entry; + last_step = TreeCursorStepVisible; + } + else if (ts_subtree_visible_child_count(*entry.subtree) > 0) { + last_entry = entry; + last_step = TreeCursorStepHidden; + } + } + if (last_entry.subtree) { + array_push(&self->stack, last_entry); + return last_step; + } + + return TreeCursorStepNone; +} + +bool ts_tree_cursor_goto_last_child(TSTreeCursor *self) { + for (;;) { + switch (ts_tree_cursor_goto_last_child_internal(self)) { + case TreeCursorStepHidden: + continue; + case TreeCursorStepVisible: + return true; + default: + return false; + } + } +} + +static inline int64_t ts_tree_cursor_goto_first_child_for_byte_and_point( + TSTreeCursor *_self, + uint32_t goal_byte, + TSPoint goal_point +) { + TreeCursor *self = (TreeCursor *)_self; + uint32_t initial_size = self->stack.size; + uint32_t visible_child_index = 0; + + bool did_descend; + do { + did_descend = false; + + bool visible; + TreeCursorEntry entry; + CursorChildIterator iterator = ts_tree_cursor_iterate_children(self); + while (ts_tree_cursor_child_iterator_next(&iterator, &entry, &visible)) { + Length entry_end = length_add(entry.position, ts_subtree_size(*entry.subtree)); + bool at_goal = entry_end.bytes > goal_byte && point_gt(entry_end.extent, goal_point); + uint32_t visible_child_count = ts_subtree_visible_child_count(*entry.subtree); + if (at_goal) { + if (visible) { + array_push(&self->stack, entry); + return visible_child_index; + } + if (visible_child_count > 0) { + array_push(&self->stack, entry); + did_descend = true; + break; + } + } else if (visible) { + visible_child_index++; + } else { + visible_child_index += visible_child_count; + } + } + } while (did_descend); + + self->stack.size = initial_size; + return -1; +} + +int64_t ts_tree_cursor_goto_first_child_for_byte(TSTreeCursor *self, uint32_t goal_byte) { + return ts_tree_cursor_goto_first_child_for_byte_and_point(self, goal_byte, POINT_ZERO); +} + +int64_t ts_tree_cursor_goto_first_child_for_point(TSTreeCursor *self, TSPoint goal_point) { + return ts_tree_cursor_goto_first_child_for_byte_and_point(self, 0, goal_point); +} + +TreeCursorStep ts_tree_cursor_goto_sibling_internal( + TSTreeCursor *_self, + bool (*advance)(CursorChildIterator *, TreeCursorEntry *, bool *) +) { + TreeCursor *self = (TreeCursor *)_self; + uint32_t initial_size = self->stack.size; + + while (self->stack.size > 1) { + TreeCursorEntry entry = array_pop(&self->stack); + CursorChildIterator iterator = ts_tree_cursor_iterate_children(self); + iterator.child_index = entry.child_index; + iterator.structural_child_index = entry.structural_child_index; + iterator.position = entry.position; + iterator.descendant_index = entry.descendant_index; + + bool visible = false; + advance(&iterator, &entry, &visible); + if (visible && self->stack.size + 1 < initial_size) break; + + while (advance(&iterator, &entry, &visible)) { + if (visible) { + array_push(&self->stack, entry); + return TreeCursorStepVisible; + } + + if (ts_subtree_visible_child_count(*entry.subtree)) { + array_push(&self->stack, entry); + return TreeCursorStepHidden; + } + } + } + + self->stack.size = initial_size; + return TreeCursorStepNone; +} + +TreeCursorStep ts_tree_cursor_goto_next_sibling_internal(TSTreeCursor *_self) { + return ts_tree_cursor_goto_sibling_internal(_self, ts_tree_cursor_child_iterator_next); +} + +bool ts_tree_cursor_goto_next_sibling(TSTreeCursor *self) { + switch (ts_tree_cursor_goto_next_sibling_internal(self)) { + case TreeCursorStepHidden: + ts_tree_cursor_goto_first_child(self); + return true; + case TreeCursorStepVisible: + return true; + default: + return false; + } +} + +TreeCursorStep ts_tree_cursor_goto_previous_sibling_internal(TSTreeCursor *_self) { + // since subtracting across row loses column information, we may have to + // restore it + TreeCursor *self = (TreeCursor *)_self; + + // for that, save current position before traversing + TreeCursorStep step = ts_tree_cursor_goto_sibling_internal( + _self, ts_tree_cursor_child_iterator_previous); + if (step == TreeCursorStepNone) + return step; + + // if length is already valid, there's no need to recompute it + if (!length_is_undefined(array_back(&self->stack)->position)) + return step; + + // restore position from the parent node + const TreeCursorEntry *parent = array_get(&self->stack, self->stack.size - 2); + Length position = parent->position; + uint32_t child_index = array_back(&self->stack)->child_index; + const Subtree *children = ts_subtree_children((*(parent->subtree))); + + if (child_index > 0) { + // skip first child padding since its position should match the position of the parent + position = length_add(position, ts_subtree_size(children[0])); + for (uint32_t i = 1; i < child_index; ++i) { + position = length_add(position, ts_subtree_total_size(children[i])); + } + position = length_add(position, ts_subtree_padding(children[child_index])); + } + + array_back(&self->stack)->position = position; + + return step; +} + +bool ts_tree_cursor_goto_previous_sibling(TSTreeCursor *self) { + switch (ts_tree_cursor_goto_previous_sibling_internal(self)) { + case TreeCursorStepHidden: + ts_tree_cursor_goto_last_child(self); + return true; + case TreeCursorStepVisible: + return true; + default: + return false; + } +} + +bool ts_tree_cursor_goto_parent(TSTreeCursor *_self) { + TreeCursor *self = (TreeCursor *)_self; + for (unsigned i = self->stack.size - 2; i + 1 > 0; i--) { + if (ts_tree_cursor_is_entry_visible(self, i)) { + self->stack.size = i + 1; + return true; + } + } + return false; +} + +void ts_tree_cursor_goto_descendant( + TSTreeCursor *_self, + uint32_t goal_descendant_index +) { + TreeCursor *self = (TreeCursor *)_self; + + // Ascend to the lowest ancestor that contains the goal node. + for (;;) { + uint32_t i = self->stack.size - 1; + TreeCursorEntry *entry = array_get(&self->stack, i); + uint32_t next_descendant_index = + entry->descendant_index + + (ts_tree_cursor_is_entry_visible(self, i) ? 1 : 0) + + ts_subtree_visible_descendant_count(*entry->subtree); + if ( + (entry->descendant_index <= goal_descendant_index) && + (next_descendant_index > goal_descendant_index) + ) { + break; + } else if (self->stack.size <= 1) { + return; + } else { + self->stack.size--; + } + } + + // Descend to the goal node. + bool did_descend = true; + do { + did_descend = false; + bool visible; + TreeCursorEntry entry; + CursorChildIterator iterator = ts_tree_cursor_iterate_children(self); + if (iterator.descendant_index > goal_descendant_index) { + return; + } + + while (ts_tree_cursor_child_iterator_next(&iterator, &entry, &visible)) { + if (iterator.descendant_index > goal_descendant_index) { + array_push(&self->stack, entry); + if (visible && entry.descendant_index == goal_descendant_index) { + return; + } else { + did_descend = true; + break; + } + } + } + } while (did_descend); +} + +uint32_t ts_tree_cursor_current_descendant_index(const TSTreeCursor *_self) { + const TreeCursor *self = (const TreeCursor *)_self; + TreeCursorEntry *last_entry = array_back(&self->stack); + return last_entry->descendant_index; +} + +TSNode ts_tree_cursor_current_node(const TSTreeCursor *_self) { + const TreeCursor *self = (const TreeCursor *)_self; + TreeCursorEntry *last_entry = array_back(&self->stack); + bool is_extra = ts_subtree_extra(*last_entry->subtree); + TSSymbol alias_symbol = is_extra ? 0 : self->root_alias_symbol; + if (self->stack.size > 1 && !is_extra) { + TreeCursorEntry *parent_entry = array_get(&self->stack, self->stack.size - 2); + alias_symbol = ts_language_alias_at( + self->tree->language, + parent_entry->subtree->ptr->production_id, + last_entry->structural_child_index + ); + } + return ts_node_new( + self->tree, + last_entry->subtree, + last_entry->position, + alias_symbol + ); +} + +// Private - Get various facts about the current node that are needed +// when executing tree queries. +void ts_tree_cursor_current_status( + const TSTreeCursor *_self, + TSFieldId *field_id, + bool *has_later_siblings, + bool *has_later_named_siblings, + bool *can_have_later_siblings_with_this_field, + TSSymbol *supertypes, + unsigned *supertype_count +) { + const TreeCursor *self = (const TreeCursor *)_self; + unsigned max_supertypes = *supertype_count; + *field_id = 0; + *supertype_count = 0; + *has_later_siblings = false; + *has_later_named_siblings = false; + *can_have_later_siblings_with_this_field = false; + + // Walk up the tree, visiting the current node and its invisible ancestors, + // because fields can refer to nodes through invisible *wrapper* nodes, + for (unsigned i = self->stack.size - 1; i > 0; i--) { + TreeCursorEntry *entry = array_get(&self->stack, i); + TreeCursorEntry *parent_entry = array_get(&self->stack, i - 1); + + const TSSymbol *alias_sequence = ts_language_alias_sequence( + self->tree->language, + parent_entry->subtree->ptr->production_id + ); + + #define subtree_symbol(subtree, structural_child_index) \ + (( \ + !ts_subtree_extra(subtree) && \ + alias_sequence && \ + alias_sequence[structural_child_index] \ + ) ? \ + alias_sequence[structural_child_index] : \ + ts_subtree_symbol(subtree)) + + // Stop walking up when a visible ancestor is found. + TSSymbol entry_symbol = subtree_symbol( + *entry->subtree, + entry->structural_child_index + ); + TSSymbolMetadata entry_metadata = ts_language_symbol_metadata( + self->tree->language, + entry_symbol + ); + if (i != self->stack.size - 1 && entry_metadata.visible) break; + + // Record any supertypes + if (entry_metadata.supertype && *supertype_count < max_supertypes) { + supertypes[*supertype_count] = entry_symbol; + (*supertype_count)++; + } + + // Determine if the current node has later siblings. + if (!*has_later_siblings) { + unsigned sibling_count = parent_entry->subtree->ptr->child_count; + unsigned structural_child_index = entry->structural_child_index; + if (!ts_subtree_extra(*entry->subtree)) structural_child_index++; + for (unsigned j = entry->child_index + 1; j < sibling_count; j++) { + Subtree sibling = ts_subtree_children(*parent_entry->subtree)[j]; + TSSymbolMetadata sibling_metadata = ts_language_symbol_metadata( + self->tree->language, + subtree_symbol(sibling, structural_child_index) + ); + if (sibling_metadata.visible) { + *has_later_siblings = true; + if (*has_later_named_siblings) break; + if (sibling_metadata.named) { + *has_later_named_siblings = true; + break; + } + } else if (ts_subtree_visible_child_count(sibling) > 0) { + *has_later_siblings = true; + if (*has_later_named_siblings) break; + if (sibling.ptr->named_child_count > 0) { + *has_later_named_siblings = true; + break; + } + } + if (!ts_subtree_extra(sibling)) structural_child_index++; + } + } + + #undef subtree_symbol + + if (!ts_subtree_extra(*entry->subtree)) { + const TSFieldMapEntry *field_map, *field_map_end; + ts_language_field_map( + self->tree->language, + parent_entry->subtree->ptr->production_id, + &field_map, &field_map_end + ); + + // Look for a field name associated with the current node. + if (!*field_id) { + for (const TSFieldMapEntry *map = field_map; map < field_map_end; map++) { + if (!map->inherited && map->child_index == entry->structural_child_index) { + *field_id = map->field_id; + break; + } + } + } + + // Determine if the current node can have later siblings with the same field name. + if (*field_id) { + for (const TSFieldMapEntry *map = field_map; map < field_map_end; map++) { + if ( + map->field_id == *field_id && + map->child_index > entry->structural_child_index + ) { + *can_have_later_siblings_with_this_field = true; + break; + } + } + } + } + } +} + +uint32_t ts_tree_cursor_current_depth(const TSTreeCursor *_self) { + const TreeCursor *self = (const TreeCursor *)_self; + uint32_t depth = 0; + for (unsigned i = 1; i < self->stack.size; i++) { + if (ts_tree_cursor_is_entry_visible(self, i)) { + depth++; + } + } + return depth; +} + +TSNode ts_tree_cursor_parent_node(const TSTreeCursor *_self) { + const TreeCursor *self = (const TreeCursor *)_self; + for (int i = (int)self->stack.size - 2; i >= 0; i--) { + TreeCursorEntry *entry = array_get(&self->stack, i); + bool is_visible = true; + TSSymbol alias_symbol = 0; + if (i > 0) { + TreeCursorEntry *parent_entry = array_get(&self->stack, i - 1); + alias_symbol = ts_language_alias_at( + self->tree->language, + parent_entry->subtree->ptr->production_id, + entry->structural_child_index + ); + is_visible = (alias_symbol != 0) || ts_subtree_visible(*entry->subtree); + } + if (is_visible) { + return ts_node_new( + self->tree, + entry->subtree, + entry->position, + alias_symbol + ); + } + } + return ts_node_new(NULL, NULL, length_zero(), 0); +} + +TSFieldId ts_tree_cursor_current_field_id(const TSTreeCursor *_self) { + const TreeCursor *self = (const TreeCursor *)_self; + + // Walk up the tree, visiting the current node and its invisible ancestors. + for (unsigned i = self->stack.size - 1; i > 0; i--) { + TreeCursorEntry *entry = array_get(&self->stack, i); + TreeCursorEntry *parent_entry = array_get(&self->stack, i - 1); + + // Stop walking up when another visible node is found. + if ( + i != self->stack.size - 1 && + ts_tree_cursor_is_entry_visible(self, i) + ) break; + + if (ts_subtree_extra(*entry->subtree)) break; + + const TSFieldMapEntry *field_map, *field_map_end; + ts_language_field_map( + self->tree->language, + parent_entry->subtree->ptr->production_id, + &field_map, &field_map_end + ); + for (const TSFieldMapEntry *map = field_map; map < field_map_end; map++) { + if (!map->inherited && map->child_index == entry->structural_child_index) { + return map->field_id; + } + } + } + return 0; +} + +const char *ts_tree_cursor_current_field_name(const TSTreeCursor *_self) { + TSFieldId id = ts_tree_cursor_current_field_id(_self); + if (id) { + const TreeCursor *self = (const TreeCursor *)_self; + return self->tree->language->field_names[id]; + } else { + return NULL; + } +} + +TSTreeCursor ts_tree_cursor_copy(const TSTreeCursor *_cursor) { + const TreeCursor *cursor = (const TreeCursor *)_cursor; + TSTreeCursor res = {NULL, NULL, {0, 0}}; + TreeCursor *copy = (TreeCursor *)&res; + copy->tree = cursor->tree; + copy->root_alias_symbol = cursor->root_alias_symbol; + array_init(©->stack); + array_push_all(©->stack, &cursor->stack); + return res; +} + +void ts_tree_cursor_reset_to(TSTreeCursor *_dst, const TSTreeCursor *_src) { + const TreeCursor *cursor = (const TreeCursor *)_src; + TreeCursor *copy = (TreeCursor *)_dst; + copy->tree = cursor->tree; + copy->root_alias_symbol = cursor->root_alias_symbol; + array_clear(©->stack); + array_push_all(©->stack, &cursor->stack); +} diff --git a/vendor/tree-sitter/src/tree_cursor.h b/vendor/tree-sitter/src/tree_cursor.h new file mode 100644 index 0000000..7d4e7ef --- /dev/null +++ b/vendor/tree-sitter/src/tree_cursor.h @@ -0,0 +1,48 @@ +#ifndef TREE_SITTER_TREE_CURSOR_H_ +#define TREE_SITTER_TREE_CURSOR_H_ + +#include "./subtree.h" + +typedef struct { + const Subtree *subtree; + Length position; + uint32_t child_index; + uint32_t structural_child_index; + uint32_t descendant_index; +} TreeCursorEntry; + +typedef struct { + const TSTree *tree; + Array(TreeCursorEntry) stack; + TSSymbol root_alias_symbol; +} TreeCursor; + +typedef enum { + TreeCursorStepNone, + TreeCursorStepHidden, + TreeCursorStepVisible, +} TreeCursorStep; + +void ts_tree_cursor_init(TreeCursor *self, TSNode node); +void ts_tree_cursor_current_status( + const TSTreeCursor *_self, + TSFieldId *field_id, + bool *has_later_siblings, + bool *has_later_named_siblings, + bool *can_have_later_siblings_with_this_field, + TSSymbol *supertypes, + unsigned *supertype_count +); + +TreeCursorStep ts_tree_cursor_goto_first_child_internal(TSTreeCursor *_self); +TreeCursorStep ts_tree_cursor_goto_next_sibling_internal(TSTreeCursor *_self); + +static inline Subtree ts_tree_cursor_current_subtree(const TSTreeCursor *_self) { + const TreeCursor *self = (const TreeCursor *)_self; + TreeCursorEntry *last_entry = array_back(&self->stack); + return *last_entry->subtree; +} + +TSNode ts_tree_cursor_parent_node(const TSTreeCursor *_self); + +#endif // TREE_SITTER_TREE_CURSOR_H_ diff --git a/vendor/tree-sitter/src/ts_assert.h b/vendor/tree-sitter/src/ts_assert.h new file mode 100644 index 0000000..4cb8f36 --- /dev/null +++ b/vendor/tree-sitter/src/ts_assert.h @@ -0,0 +1,11 @@ +#ifndef TREE_SITTER_ASSERT_H_ +#define TREE_SITTER_ASSERT_H_ + +#ifdef NDEBUG +#define ts_assert(e) ((void)(e)) +#else +#include +#define ts_assert(e) assert(e) +#endif + +#endif // TREE_SITTER_ASSERT_H_ diff --git a/vendor/tree-sitter/src/unicode.h b/vendor/tree-sitter/src/unicode.h new file mode 100644 index 0000000..0fba3f2 --- /dev/null +++ b/vendor/tree-sitter/src/unicode.h @@ -0,0 +1,75 @@ +#ifndef TREE_SITTER_UNICODE_H_ +#define TREE_SITTER_UNICODE_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#define U_EXPORT +#define U_EXPORT2 +#include "unicode/utf8.h" +#include "unicode/utf16.h" +#include "portable/endian.h" + +#define U16_NEXT_LE(s, i, length, c) UPRV_BLOCK_MACRO_BEGIN { \ + (c)=le16toh((s)[(i)++]); \ + if(U16_IS_LEAD(c)) { \ + uint16_t __c2; \ + if((i)!=(length) && U16_IS_TRAIL(__c2=(s)[(i)])) { \ + ++(i); \ + (c)=U16_GET_SUPPLEMENTARY((c), __c2); \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +#define U16_NEXT_BE(s, i, length, c) UPRV_BLOCK_MACRO_BEGIN { \ + (c)=be16toh((s)[(i)++]); \ + if(U16_IS_LEAD(c)) { \ + uint16_t __c2; \ + if((i)!=(length) && U16_IS_TRAIL(__c2=(s)[(i)])) { \ + ++(i); \ + (c)=U16_GET_SUPPLEMENTARY((c), __c2); \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +static const int32_t TS_DECODE_ERROR = U_SENTINEL; + +static inline uint32_t ts_decode_utf8( + const uint8_t *string, + uint32_t length, + int32_t *code_point +) { + uint32_t i = 0; + U8_NEXT(string, i, length, *code_point); + return i; +} + +static inline uint32_t ts_decode_utf16_le( + const uint8_t *string, + uint32_t length, + int32_t *code_point +) { + uint32_t i = 0; + U16_NEXT_LE(((uint16_t *)string), i, length, *code_point); + return i * 2; +} + +static inline uint32_t ts_decode_utf16_be( + const uint8_t *string, + uint32_t length, + int32_t *code_point +) { + uint32_t i = 0; + U16_NEXT_BE(((uint16_t *)string), i, length, *code_point); + return i * 2; +} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_UNICODE_H_ diff --git a/vendor/tree-sitter/src/unicode/ICU_SHA b/vendor/tree-sitter/src/unicode/ICU_SHA new file mode 100644 index 0000000..3622283 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/ICU_SHA @@ -0,0 +1 @@ +552b01f61127d30d6589aa4bf99468224979b661 diff --git a/vendor/tree-sitter/src/unicode/LICENSE b/vendor/tree-sitter/src/unicode/LICENSE new file mode 100644 index 0000000..2e01e36 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/LICENSE @@ -0,0 +1,414 @@ +COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later) + +Copyright © 1991-2019 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. + +--------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +1. ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +3. Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (c) 2013 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: http://code.google.com/p/lao-dictionary/ + # Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt + # License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary, with slight + # modifications. + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, + # are permitted provided that the following conditions are met: + # + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in + # binary form must reproduce the above copyright notice, this list of + # conditions and the following disclaimer in the documentation and/or + # other materials provided with the distribution. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +4. Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +5. Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + +6. Google double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/tree-sitter/src/unicode/README.md b/vendor/tree-sitter/src/unicode/README.md new file mode 100644 index 0000000..623b8e3 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/README.md @@ -0,0 +1,29 @@ +# ICU Parts + +This directory contains a small subset of files from the Unicode organization's [ICU repository](https://github.com/unicode-org/icu). + +### License + +The license for these files is contained in the `LICENSE` file within this directory. + +### Contents + +* Source files taken from the [`icu4c/source/common/unicode`](https://github.com/unicode-org/icu/tree/552b01f61127d30d6589aa4bf99468224979b661/icu4c/source/common/unicode) directory: + * `utf8.h` + * `utf16.h` + * `umachine.h` +* Empty source files that are referenced by the above source files, but whose original contents in `libicu` are not needed: + * `ptypes.h` + * `urename.h` + * `utf.h` +* `ICU_SHA` - File containing the Git SHA of the commit in the `icu` repository from which the files were obtained. +* `LICENSE` - The license file from the [`icu4c`](https://github.com/unicode-org/icu/tree/552b01f61127d30d6589aa4bf99468224979b661/icu4c) directory of the `icu` repository. +* `README.md` - This text file. + +### Updating ICU + +To incorporate changes from the upstream `icu` repository: + +* Update `ICU_SHA` with the new Git SHA. +* Update `LICENSE` with the license text from the directory mentioned above. +* Update `utf8.h`, `utf16.h`, and `umachine.h` with their new contents in the `icu` repository. diff --git a/vendor/tree-sitter/src/unicode/ptypes.h b/vendor/tree-sitter/src/unicode/ptypes.h new file mode 100644 index 0000000..ac79ad0 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/ptypes.h @@ -0,0 +1 @@ +// This file must exist in order for `utf8.h` and `utf16.h` to be used. diff --git a/vendor/tree-sitter/src/unicode/umachine.h b/vendor/tree-sitter/src/unicode/umachine.h new file mode 100644 index 0000000..9195824 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/umachine.h @@ -0,0 +1,448 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* +****************************************************************************** +* +* Copyright (C) 1999-2015, International Business Machines +* Corporation and others. All Rights Reserved. +* +****************************************************************************** +* file name: umachine.h +* encoding: UTF-8 +* tab size: 8 (not used) +* indentation:4 +* +* created on: 1999sep13 +* created by: Markus W. Scherer +* +* This file defines basic types and constants for ICU to be +* platform-independent. umachine.h and utf.h are included into +* utypes.h to provide all the general definitions for ICU. +* All of these definitions used to be in utypes.h before +* the UTF-handling macros made this unmaintainable. +*/ + +#ifndef __UMACHINE_H__ +#define __UMACHINE_H__ + + +/** + * \file + * \brief Basic types and constants for UTF + * + *

    Basic types and constants for UTF

    + * This file defines basic types and constants for utf.h to be + * platform-independent. umachine.h and utf.h are included into + * utypes.h to provide all the general definitions for ICU. + * All of these definitions used to be in utypes.h before + * the UTF-handling macros made this unmaintainable. + * + */ +/*==========================================================================*/ +/* Include platform-dependent definitions */ +/* which are contained in the platform-specific file platform.h */ +/*==========================================================================*/ + +#include "unicode/ptypes.h" /* platform.h is included in ptypes.h */ + +/* + * ANSI C headers: + * stddef.h defines wchar_t + */ +#include + +/*==========================================================================*/ +/* For C wrappers, we use the symbol U_STABLE. */ +/* This works properly if the includer is C or C++. */ +/* Functions are declared U_STABLE return-type U_EXPORT2 function-name()... */ +/*==========================================================================*/ + +/** + * \def U_CFUNC + * This is used in a declaration of a library private ICU C function. + * @stable ICU 2.4 + */ + +/** + * \def U_CDECL_BEGIN + * This is used to begin a declaration of a library private ICU C API. + * @stable ICU 2.4 + */ + +/** + * \def U_CDECL_END + * This is used to end a declaration of a library private ICU C API + * @stable ICU 2.4 + */ + +#ifdef __cplusplus +# define U_CFUNC extern "C" +# define U_CDECL_BEGIN extern "C" { +# define U_CDECL_END } +#else +# define U_CFUNC extern +# define U_CDECL_BEGIN +# define U_CDECL_END +#endif + +#ifndef U_ATTRIBUTE_DEPRECATED +/** + * \def U_ATTRIBUTE_DEPRECATED + * This is used for GCC specific attributes + * @internal + */ +#if U_GCC_MAJOR_MINOR >= 302 +# define U_ATTRIBUTE_DEPRECATED __attribute__ ((deprecated)) +/** + * \def U_ATTRIBUTE_DEPRECATED + * This is used for Visual C++ specific attributes + * @internal + */ +#elif defined(_MSC_VER) && (_MSC_VER >= 1400) +# define U_ATTRIBUTE_DEPRECATED __declspec(deprecated) +#else +# define U_ATTRIBUTE_DEPRECATED +#endif +#endif + +/** This is used to declare a function as a public ICU C API @stable ICU 2.0*/ +#define U_CAPI U_CFUNC U_EXPORT +/** This is used to declare a function as a stable public ICU C API*/ +#define U_STABLE U_CAPI +/** This is used to declare a function as a draft public ICU C API */ +#define U_DRAFT U_CAPI +/** This is used to declare a function as a deprecated public ICU C API */ +#define U_DEPRECATED U_CAPI U_ATTRIBUTE_DEPRECATED +/** This is used to declare a function as an obsolete public ICU C API */ +#define U_OBSOLETE U_CAPI +/** This is used to declare a function as an internal ICU C API */ +#define U_INTERNAL U_CAPI + +/** + * \def U_OVERRIDE + * Defined to the C++11 "override" keyword if available. + * Denotes a class or member which is an override of the base class. + * May result in an error if it applied to something not an override. + * @internal + */ +#ifndef U_OVERRIDE +#define U_OVERRIDE override +#endif + +/** + * \def U_FINAL + * Defined to the C++11 "final" keyword if available. + * Denotes a class or member which may not be overridden in subclasses. + * May result in an error if subclasses attempt to override. + * @internal + */ +#if !defined(U_FINAL) || defined(U_IN_DOXYGEN) +#define U_FINAL final +#endif + +// Before ICU 65, function-like, multi-statement ICU macros were just defined as +// series of statements wrapped in { } blocks and the caller could choose to +// either treat them as if they were actual functions and end the invocation +// with a trailing ; creating an empty statement after the block or else omit +// this trailing ; using the knowledge that the macro would expand to { }. +// +// But doing so doesn't work well with macros that look like functions and +// compiler warnings about empty statements (ICU-20601) and ICU 65 therefore +// switches to the standard solution of wrapping such macros in do { } while. +// +// This will however break existing code that depends on being able to invoke +// these macros without a trailing ; so to be able to remain compatible with +// such code the wrapper is itself defined as macros so that it's possible to +// build ICU 65 and later with the old macro behaviour, like this: +// +// CPPFLAGS='-DUPRV_BLOCK_MACRO_BEGIN="" -DUPRV_BLOCK_MACRO_END=""' +// runConfigureICU ... + +/** + * \def UPRV_BLOCK_MACRO_BEGIN + * Defined as the "do" keyword by default. + * @internal + */ +#ifndef UPRV_BLOCK_MACRO_BEGIN +#define UPRV_BLOCK_MACRO_BEGIN do +#endif + +/** + * \def UPRV_BLOCK_MACRO_END + * Defined as "while (FALSE)" by default. + * @internal + */ +#ifndef UPRV_BLOCK_MACRO_END +#define UPRV_BLOCK_MACRO_END while (FALSE) +#endif + +/*==========================================================================*/ +/* limits for int32_t etc., like in POSIX inttypes.h */ +/*==========================================================================*/ + +#ifndef INT8_MIN +/** The smallest value an 8 bit signed integer can hold @stable ICU 2.0 */ +# define INT8_MIN ((int8_t)(-128)) +#endif +#ifndef INT16_MIN +/** The smallest value a 16 bit signed integer can hold @stable ICU 2.0 */ +# define INT16_MIN ((int16_t)(-32767-1)) +#endif +#ifndef INT32_MIN +/** The smallest value a 32 bit signed integer can hold @stable ICU 2.0 */ +# define INT32_MIN ((int32_t)(-2147483647-1)) +#endif + +#ifndef INT8_MAX +/** The largest value an 8 bit signed integer can hold @stable ICU 2.0 */ +# define INT8_MAX ((int8_t)(127)) +#endif +#ifndef INT16_MAX +/** The largest value a 16 bit signed integer can hold @stable ICU 2.0 */ +# define INT16_MAX ((int16_t)(32767)) +#endif +#ifndef INT32_MAX +/** The largest value a 32 bit signed integer can hold @stable ICU 2.0 */ +# define INT32_MAX ((int32_t)(2147483647)) +#endif + +#ifndef UINT8_MAX +/** The largest value an 8 bit unsigned integer can hold @stable ICU 2.0 */ +# define UINT8_MAX ((uint8_t)(255U)) +#endif +#ifndef UINT16_MAX +/** The largest value a 16 bit unsigned integer can hold @stable ICU 2.0 */ +# define UINT16_MAX ((uint16_t)(65535U)) +#endif +#ifndef UINT32_MAX +/** The largest value a 32 bit unsigned integer can hold @stable ICU 2.0 */ +# define UINT32_MAX ((uint32_t)(4294967295U)) +#endif + +#if defined(U_INT64_T_UNAVAILABLE) +# error int64_t is required for decimal format and rule-based number format. +#else +# ifndef INT64_C +/** + * Provides a platform independent way to specify a signed 64-bit integer constant. + * note: may be wrong for some 64 bit platforms - ensure your compiler provides INT64_C + * @stable ICU 2.8 + */ +# define INT64_C(c) c ## LL +# endif +# ifndef UINT64_C +/** + * Provides a platform independent way to specify an unsigned 64-bit integer constant. + * note: may be wrong for some 64 bit platforms - ensure your compiler provides UINT64_C + * @stable ICU 2.8 + */ +# define UINT64_C(c) c ## ULL +# endif +# ifndef U_INT64_MIN +/** The smallest value a 64 bit signed integer can hold @stable ICU 2.8 */ +# define U_INT64_MIN ((int64_t)(INT64_C(-9223372036854775807)-1)) +# endif +# ifndef U_INT64_MAX +/** The largest value a 64 bit signed integer can hold @stable ICU 2.8 */ +# define U_INT64_MAX ((int64_t)(INT64_C(9223372036854775807))) +# endif +# ifndef U_UINT64_MAX +/** The largest value a 64 bit unsigned integer can hold @stable ICU 2.8 */ +# define U_UINT64_MAX ((uint64_t)(UINT64_C(18446744073709551615))) +# endif +#endif + +/*==========================================================================*/ +/* Boolean data type */ +/*==========================================================================*/ + +/** The ICU boolean type @stable ICU 2.0 */ +typedef int8_t UBool; + +#ifndef TRUE +/** The TRUE value of a UBool @stable ICU 2.0 */ +# define TRUE 1 +#endif +#ifndef FALSE +/** The FALSE value of a UBool @stable ICU 2.0 */ +# define FALSE 0 +#endif + + +/*==========================================================================*/ +/* Unicode data types */ +/*==========================================================================*/ + +/* wchar_t-related definitions -------------------------------------------- */ + +/* + * \def U_WCHAR_IS_UTF16 + * Defined if wchar_t uses UTF-16. + * + * @stable ICU 2.0 + */ +/* + * \def U_WCHAR_IS_UTF32 + * Defined if wchar_t uses UTF-32. + * + * @stable ICU 2.0 + */ +#if !defined(U_WCHAR_IS_UTF16) && !defined(U_WCHAR_IS_UTF32) +# ifdef __STDC_ISO_10646__ +# if (U_SIZEOF_WCHAR_T==2) +# define U_WCHAR_IS_UTF16 +# elif (U_SIZEOF_WCHAR_T==4) +# define U_WCHAR_IS_UTF32 +# endif +# elif defined __UCS2__ +# if (U_PF_OS390 <= U_PLATFORM && U_PLATFORM <= U_PF_OS400) && (U_SIZEOF_WCHAR_T==2) +# define U_WCHAR_IS_UTF16 +# endif +# elif defined(__UCS4__) || (U_PLATFORM == U_PF_OS400 && defined(__UTF32__)) +# if (U_SIZEOF_WCHAR_T==4) +# define U_WCHAR_IS_UTF32 +# endif +# elif U_PLATFORM_IS_DARWIN_BASED || (U_SIZEOF_WCHAR_T==4 && U_PLATFORM_IS_LINUX_BASED) +# define U_WCHAR_IS_UTF32 +# elif U_PLATFORM_HAS_WIN32_API +# define U_WCHAR_IS_UTF16 +# endif +#endif + +/* UChar and UChar32 definitions -------------------------------------------- */ + +/** Number of bytes in a UChar. @stable ICU 2.0 */ +#define U_SIZEOF_UCHAR 2 + +/** + * \def U_CHAR16_IS_TYPEDEF + * If 1, then char16_t is a typedef and not a real type (yet) + * @internal + */ +#if (U_PLATFORM == U_PF_AIX) && defined(__cplusplus) &&(U_CPLUSPLUS_VERSION < 11) +// for AIX, uchar.h needs to be included +# include +# define U_CHAR16_IS_TYPEDEF 1 +#elif defined(_MSC_VER) && (_MSC_VER < 1900) +// Versions of Visual Studio/MSVC below 2015 do not support char16_t as a real type, +// and instead use a typedef. https://msdn.microsoft.com/library/bb531344.aspx +# define U_CHAR16_IS_TYPEDEF 1 +#else +# define U_CHAR16_IS_TYPEDEF 0 +#endif + + +/** + * \var UChar + * + * The base type for UTF-16 code units and pointers. + * Unsigned 16-bit integer. + * Starting with ICU 59, C++ API uses char16_t directly, while C API continues to use UChar. + * + * UChar is configurable by defining the macro UCHAR_TYPE + * on the preprocessor or compiler command line: + * -DUCHAR_TYPE=uint16_t or -DUCHAR_TYPE=wchar_t (if U_SIZEOF_WCHAR_T==2) etc. + * (The UCHAR_TYPE can also be \#defined earlier in this file, for outside the ICU library code.) + * This is for transitional use from application code that uses uint16_t or wchar_t for UTF-16. + * + * The default is UChar=char16_t. + * + * C++11 defines char16_t as bit-compatible with uint16_t, but as a distinct type. + * + * In C, char16_t is a simple typedef of uint_least16_t. + * ICU requires uint_least16_t=uint16_t for data memory mapping. + * On macOS, char16_t is not available because the uchar.h standard header is missing. + * + * @stable ICU 4.4 + */ + +#if 1 + // #if 1 is normal. UChar defaults to char16_t in C++. + // For configuration testing of UChar=uint16_t temporarily change this to #if 0. + // The intltest Makefile #defines UCHAR_TYPE=char16_t, + // so we only #define it to uint16_t if it is undefined so far. +#elif !defined(UCHAR_TYPE) +# define UCHAR_TYPE uint16_t +#endif + +#if defined(U_COMBINED_IMPLEMENTATION) || defined(U_COMMON_IMPLEMENTATION) || \ + defined(U_I18N_IMPLEMENTATION) || defined(U_IO_IMPLEMENTATION) + // Inside the ICU library code, never configurable. + typedef char16_t UChar; +#elif defined(UCHAR_TYPE) + typedef UCHAR_TYPE UChar; +#elif defined(__cplusplus) + typedef char16_t UChar; +#else + typedef uint16_t UChar; +#endif + +/** + * \var OldUChar + * Default ICU 58 definition of UChar. + * A base type for UTF-16 code units and pointers. + * Unsigned 16-bit integer. + * + * Define OldUChar to be wchar_t if that is 16 bits wide. + * If wchar_t is not 16 bits wide, then define UChar to be uint16_t. + * + * This makes the definition of OldUChar platform-dependent + * but allows direct string type compatibility with platforms with + * 16-bit wchar_t types. + * + * This is how UChar was defined in ICU 58, for transition convenience. + * Exception: ICU 58 UChar was defined to UCHAR_TYPE if that macro was defined. + * The current UChar responds to UCHAR_TYPE but OldUChar does not. + * + * @stable ICU 59 + */ +#if U_SIZEOF_WCHAR_T==2 + typedef wchar_t OldUChar; +#elif defined(__CHAR16_TYPE__) + typedef __CHAR16_TYPE__ OldUChar; +#else + typedef uint16_t OldUChar; +#endif + +/** + * Define UChar32 as a type for single Unicode code points. + * UChar32 is a signed 32-bit integer (same as int32_t). + * + * The Unicode code point range is 0..0x10ffff. + * All other values (negative or >=0x110000) are illegal as Unicode code points. + * They may be used as sentinel values to indicate "done", "error" + * or similar non-code point conditions. + * + * Before ICU 2.4 (Jitterbug 2146), UChar32 was defined + * to be wchar_t if that is 32 bits wide (wchar_t may be signed or unsigned) + * or else to be uint32_t. + * That is, the definition of UChar32 was platform-dependent. + * + * @see U_SENTINEL + * @stable ICU 2.4 + */ +typedef int32_t UChar32; + +/** + * This value is intended for sentinel values for APIs that + * (take or) return single code points (UChar32). + * It is outside of the Unicode code point range 0..0x10ffff. + * + * For example, a "done" or "error" value in a new API + * could be indicated with U_SENTINEL. + * + * ICU APIs designed before ICU 2.4 usually define service-specific "done" + * values, mostly 0xffff. + * Those may need to be distinguished from + * actual U+ffff text contents by calling functions like + * CharacterIterator::hasNext() or UnicodeString::length(). + * + * @return -1 + * @see UChar32 + * @stable ICU 2.4 + */ +#define U_SENTINEL (-1) + +#include "unicode/urename.h" + +#endif diff --git a/vendor/tree-sitter/src/unicode/urename.h b/vendor/tree-sitter/src/unicode/urename.h new file mode 100644 index 0000000..ac79ad0 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/urename.h @@ -0,0 +1 @@ +// This file must exist in order for `utf8.h` and `utf16.h` to be used. diff --git a/vendor/tree-sitter/src/unicode/utf.h b/vendor/tree-sitter/src/unicode/utf.h new file mode 100644 index 0000000..ac79ad0 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/utf.h @@ -0,0 +1 @@ +// This file must exist in order for `utf8.h` and `utf16.h` to be used. diff --git a/vendor/tree-sitter/src/unicode/utf16.h b/vendor/tree-sitter/src/unicode/utf16.h new file mode 100644 index 0000000..3315214 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/utf16.h @@ -0,0 +1,733 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* +******************************************************************************* +* +* Copyright (C) 1999-2012, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* +* file name: utf16.h +* encoding: UTF-8 +* tab size: 8 (not used) +* indentation:4 +* +* created on: 1999sep09 +* created by: Markus W. Scherer +*/ + +/** + * \file + * \brief C API: 16-bit Unicode handling macros + * + * This file defines macros to deal with 16-bit Unicode (UTF-16) code units and strings. + * + * For more information see utf.h and the ICU User Guide Strings chapter + * (http://userguide.icu-project.org/strings). + * + * Usage: + * ICU coding guidelines for if() statements should be followed when using these macros. + * Compound statements (curly braces {}) must be used for if-else-while... + * bodies and all macro statements should be terminated with semicolon. + */ + +#ifndef __UTF16_H__ +#define __UTF16_H__ + +#include "unicode/umachine.h" +#ifndef __UTF_H__ +# include "unicode/utf.h" +#endif + +/* single-code point definitions -------------------------------------------- */ + +/** + * Does this code unit alone encode a code point (BMP, not a surrogate)? + * @param c 16-bit code unit + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U16_IS_SINGLE(c) !U_IS_SURROGATE(c) + +/** + * Is this code unit a lead surrogate (U+d800..U+dbff)? + * @param c 16-bit code unit + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U16_IS_LEAD(c) (((c)&0xfffffc00)==0xd800) + +/** + * Is this code unit a trail surrogate (U+dc00..U+dfff)? + * @param c 16-bit code unit + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U16_IS_TRAIL(c) (((c)&0xfffffc00)==0xdc00) + +/** + * Is this code unit a surrogate (U+d800..U+dfff)? + * @param c 16-bit code unit + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U16_IS_SURROGATE(c) U_IS_SURROGATE(c) + +/** + * Assuming c is a surrogate code point (U16_IS_SURROGATE(c)), + * is it a lead surrogate? + * @param c 16-bit code unit + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U16_IS_SURROGATE_LEAD(c) (((c)&0x400)==0) + +/** + * Assuming c is a surrogate code point (U16_IS_SURROGATE(c)), + * is it a trail surrogate? + * @param c 16-bit code unit + * @return TRUE or FALSE + * @stable ICU 4.2 + */ +#define U16_IS_SURROGATE_TRAIL(c) (((c)&0x400)!=0) + +/** + * Helper constant for U16_GET_SUPPLEMENTARY. + * @internal + */ +#define U16_SURROGATE_OFFSET ((0xd800<<10UL)+0xdc00-0x10000) + +/** + * Get a supplementary code point value (U+10000..U+10ffff) + * from its lead and trail surrogates. + * The result is undefined if the input values are not + * lead and trail surrogates. + * + * @param lead lead surrogate (U+d800..U+dbff) + * @param trail trail surrogate (U+dc00..U+dfff) + * @return supplementary code point (U+10000..U+10ffff) + * @stable ICU 2.4 + */ +#define U16_GET_SUPPLEMENTARY(lead, trail) \ + (((UChar32)(lead)<<10UL)+(UChar32)(trail)-U16_SURROGATE_OFFSET) + + +/** + * Get the lead surrogate (0xd800..0xdbff) for a + * supplementary code point (0x10000..0x10ffff). + * @param supplementary 32-bit code point (U+10000..U+10ffff) + * @return lead surrogate (U+d800..U+dbff) for supplementary + * @stable ICU 2.4 + */ +#define U16_LEAD(supplementary) (UChar)(((supplementary)>>10)+0xd7c0) + +/** + * Get the trail surrogate (0xdc00..0xdfff) for a + * supplementary code point (0x10000..0x10ffff). + * @param supplementary 32-bit code point (U+10000..U+10ffff) + * @return trail surrogate (U+dc00..U+dfff) for supplementary + * @stable ICU 2.4 + */ +#define U16_TRAIL(supplementary) (UChar)(((supplementary)&0x3ff)|0xdc00) + +/** + * How many 16-bit code units are used to encode this Unicode code point? (1 or 2) + * The result is not defined if c is not a Unicode code point (U+0000..U+10ffff). + * @param c 32-bit code point + * @return 1 or 2 + * @stable ICU 2.4 + */ +#define U16_LENGTH(c) ((uint32_t)(c)<=0xffff ? 1 : 2) + +/** + * The maximum number of 16-bit code units per Unicode code point (U+0000..U+10ffff). + * @return 2 + * @stable ICU 2.4 + */ +#define U16_MAX_LENGTH 2 + +/** + * Get a code point from a string at a random-access offset, + * without changing the offset. + * "Unsafe" macro, assumes well-formed UTF-16. + * + * The offset may point to either the lead or trail surrogate unit + * for a supplementary code point, in which case the macro will read + * the adjacent matching surrogate as well. + * The result is undefined if the offset points to a single, unpaired surrogate. + * Iteration through a string is more efficient with U16_NEXT_UNSAFE or U16_NEXT. + * + * @param s const UChar * string + * @param i string offset + * @param c output UChar32 variable + * @see U16_GET + * @stable ICU 2.4 + */ +#define U16_GET_UNSAFE(s, i, c) UPRV_BLOCK_MACRO_BEGIN { \ + (c)=(s)[i]; \ + if(U16_IS_SURROGATE(c)) { \ + if(U16_IS_SURROGATE_LEAD(c)) { \ + (c)=U16_GET_SUPPLEMENTARY((c), (s)[(i)+1]); \ + } else { \ + (c)=U16_GET_SUPPLEMENTARY((s)[(i)-1], (c)); \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Get a code point from a string at a random-access offset, + * without changing the offset. + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The offset may point to either the lead or trail surrogate unit + * for a supplementary code point, in which case the macro will read + * the adjacent matching surrogate as well. + * + * The length can be negative for a NUL-terminated string. + * + * If the offset points to a single, unpaired surrogate, then + * c is set to that unpaired surrogate. + * Iteration through a string is more efficient with U16_NEXT_UNSAFE or U16_NEXT. + * + * @param s const UChar * string + * @param start starting string offset (usually 0) + * @param i string offset, must be start<=i(start) && U16_IS_LEAD(__c2=(s)[(i)-1])) { \ + (c)=U16_GET_SUPPLEMENTARY(__c2, (c)); \ + } \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Get a code point from a string at a random-access offset, + * without changing the offset. + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The offset may point to either the lead or trail surrogate unit + * for a supplementary code point, in which case the macro will read + * the adjacent matching surrogate as well. + * + * The length can be negative for a NUL-terminated string. + * + * If the offset points to a single, unpaired surrogate, then + * c is set to U+FFFD. + * Iteration through a string is more efficient with U16_NEXT_UNSAFE or U16_NEXT_OR_FFFD. + * + * @param s const UChar * string + * @param start starting string offset (usually 0) + * @param i string offset, must be start<=i(start) && U16_IS_LEAD(__c2=(s)[(i)-1])) { \ + (c)=U16_GET_SUPPLEMENTARY(__c2, (c)); \ + } else { \ + (c)=0xfffd; \ + } \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/* definitions with forward iteration --------------------------------------- */ + +/** + * Get a code point from a string at a code point boundary offset, + * and advance the offset to the next code point boundary. + * (Post-incrementing forward iteration.) + * "Unsafe" macro, assumes well-formed UTF-16. + * + * The offset may point to the lead surrogate unit + * for a supplementary code point, in which case the macro will read + * the following trail surrogate as well. + * If the offset points to a trail surrogate, then that itself + * will be returned as the code point. + * The result is undefined if the offset points to a single, unpaired lead surrogate. + * + * @param s const UChar * string + * @param i string offset + * @param c output UChar32 variable + * @see U16_NEXT + * @stable ICU 2.4 + */ +#define U16_NEXT_UNSAFE(s, i, c) UPRV_BLOCK_MACRO_BEGIN { \ + (c)=(s)[(i)++]; \ + if(U16_IS_LEAD(c)) { \ + (c)=U16_GET_SUPPLEMENTARY((c), (s)[(i)++]); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Get a code point from a string at a code point boundary offset, + * and advance the offset to the next code point boundary. + * (Post-incrementing forward iteration.) + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * The offset may point to the lead surrogate unit + * for a supplementary code point, in which case the macro will read + * the following trail surrogate as well. + * If the offset points to a trail surrogate or + * to a single, unpaired lead surrogate, then c is set to that unpaired surrogate. + * + * @param s const UChar * string + * @param i string offset, must be i>10)+0xd7c0); \ + (s)[(i)++]=(uint16_t)(((c)&0x3ff)|0xdc00); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Append a code point to a string, overwriting 1 or 2 code units. + * The offset points to the current end of the string contents + * and is advanced (post-increment). + * "Safe" macro, checks for a valid code point. + * If a surrogate pair is written, checks for sufficient space in the string. + * If the code point is not valid or a trail surrogate does not fit, + * then isError is set to TRUE. + * + * @param s const UChar * string buffer + * @param i string offset, must be i>10)+0xd7c0); \ + (s)[(i)++]=(uint16_t)(((c)&0x3ff)|0xdc00); \ + } else /* c>0x10ffff or not enough space */ { \ + (isError)=TRUE; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the next. + * (Post-incrementing iteration.) + * "Unsafe" macro, assumes well-formed UTF-16. + * + * @param s const UChar * string + * @param i string offset + * @see U16_FWD_1 + * @stable ICU 2.4 + */ +#define U16_FWD_1_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + if(U16_IS_LEAD((s)[(i)++])) { \ + ++(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the next. + * (Post-incrementing iteration.) + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * @param s const UChar * string + * @param i string offset, must be i0) { \ + U16_FWD_1_UNSAFE(s, i); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the n-th next one, + * i.e., move forward by n code points. + * (Post-incrementing iteration.) + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * @param s const UChar * string + * @param i int32_t string offset, must be i0 && ((i)<(length) || ((length)<0 && (s)[i]!=0))) { \ + U16_FWD_1(s, i, length); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary + * at the start of a code point. + * If the offset points to the trail surrogate of a surrogate pair, + * then the offset is decremented. + * Otherwise, it is not modified. + * "Unsafe" macro, assumes well-formed UTF-16. + * + * @param s const UChar * string + * @param i string offset + * @see U16_SET_CP_START + * @stable ICU 2.4 + */ +#define U16_SET_CP_START_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + if(U16_IS_TRAIL((s)[i])) { \ + --(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary + * at the start of a code point. + * If the offset points to the trail surrogate of a surrogate pair, + * then the offset is decremented. + * Otherwise, it is not modified. + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * @param s const UChar * string + * @param start starting string offset (usually 0) + * @param i string offset, must be start<=i + * @see U16_SET_CP_START_UNSAFE + * @stable ICU 2.4 + */ +#define U16_SET_CP_START(s, start, i) UPRV_BLOCK_MACRO_BEGIN { \ + if(U16_IS_TRAIL((s)[i]) && (i)>(start) && U16_IS_LEAD((s)[(i)-1])) { \ + --(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +/* definitions with backward iteration -------------------------------------- */ + +/** + * Move the string offset from one code point boundary to the previous one + * and get the code point between them. + * (Pre-decrementing backward iteration.) + * "Unsafe" macro, assumes well-formed UTF-16. + * + * The input offset may be the same as the string length. + * If the offset is behind a trail surrogate unit + * for a supplementary code point, then the macro will read + * the preceding lead surrogate as well. + * If the offset is behind a lead surrogate, then that itself + * will be returned as the code point. + * The result is undefined if the offset is behind a single, unpaired trail surrogate. + * + * @param s const UChar * string + * @param i string offset + * @param c output UChar32 variable + * @see U16_PREV + * @stable ICU 2.4 + */ +#define U16_PREV_UNSAFE(s, i, c) UPRV_BLOCK_MACRO_BEGIN { \ + (c)=(s)[--(i)]; \ + if(U16_IS_TRAIL(c)) { \ + (c)=U16_GET_SUPPLEMENTARY((s)[--(i)], (c)); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the previous one + * and get the code point between them. + * (Pre-decrementing backward iteration.) + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The input offset may be the same as the string length. + * If the offset is behind a trail surrogate unit + * for a supplementary code point, then the macro will read + * the preceding lead surrogate as well. + * If the offset is behind a lead surrogate or behind a single, unpaired + * trail surrogate, then c is set to that unpaired surrogate. + * + * @param s const UChar * string + * @param start starting string offset (usually 0) + * @param i string offset, must be start(start) && U16_IS_LEAD(__c2=(s)[(i)-1])) { \ + --(i); \ + (c)=U16_GET_SUPPLEMENTARY(__c2, (c)); \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the previous one + * and get the code point between them. + * (Pre-decrementing backward iteration.) + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The input offset may be the same as the string length. + * If the offset is behind a trail surrogate unit + * for a supplementary code point, then the macro will read + * the preceding lead surrogate as well. + * If the offset is behind a lead surrogate or behind a single, unpaired + * trail surrogate, then c is set to U+FFFD. + * + * @param s const UChar * string + * @param start starting string offset (usually 0) + * @param i string offset, must be start(start) && U16_IS_LEAD(__c2=(s)[(i)-1])) { \ + --(i); \ + (c)=U16_GET_SUPPLEMENTARY(__c2, (c)); \ + } else { \ + (c)=0xfffd; \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the previous one. + * (Pre-decrementing backward iteration.) + * The input offset may be the same as the string length. + * "Unsafe" macro, assumes well-formed UTF-16. + * + * @param s const UChar * string + * @param i string offset + * @see U16_BACK_1 + * @stable ICU 2.4 + */ +#define U16_BACK_1_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + if(U16_IS_TRAIL((s)[--(i)])) { \ + --(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the previous one. + * (Pre-decrementing backward iteration.) + * The input offset may be the same as the string length. + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * @param s const UChar * string + * @param start starting string offset (usually 0) + * @param i string offset, must be start(start) && U16_IS_LEAD((s)[(i)-1])) { \ + --(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the n-th one before it, + * i.e., move backward by n code points. + * (Pre-decrementing backward iteration.) + * The input offset may be the same as the string length. + * "Unsafe" macro, assumes well-formed UTF-16. + * + * @param s const UChar * string + * @param i string offset + * @param n number of code points to skip + * @see U16_BACK_N + * @stable ICU 2.4 + */ +#define U16_BACK_N_UNSAFE(s, i, n) UPRV_BLOCK_MACRO_BEGIN { \ + int32_t __N=(n); \ + while(__N>0) { \ + U16_BACK_1_UNSAFE(s, i); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the n-th one before it, + * i.e., move backward by n code points. + * (Pre-decrementing backward iteration.) + * The input offset may be the same as the string length. + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * @param s const UChar * string + * @param start start of string + * @param i string offset, must be start0 && (i)>(start)) { \ + U16_BACK_1(s, start, i); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary after a code point. + * If the offset is behind the lead surrogate of a surrogate pair, + * then the offset is incremented. + * Otherwise, it is not modified. + * The input offset may be the same as the string length. + * "Unsafe" macro, assumes well-formed UTF-16. + * + * @param s const UChar * string + * @param i string offset + * @see U16_SET_CP_LIMIT + * @stable ICU 2.4 + */ +#define U16_SET_CP_LIMIT_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + if(U16_IS_LEAD((s)[(i)-1])) { \ + ++(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary after a code point. + * If the offset is behind the lead surrogate of a surrogate pair, + * then the offset is incremented. + * Otherwise, it is not modified. + * The input offset may be the same as the string length. + * "Safe" macro, handles unpaired surrogates and checks for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * @param s const UChar * string + * @param start int32_t starting string offset (usually 0) + * @param i int32_t string offset, start<=i<=length + * @param length int32_t string length + * @see U16_SET_CP_LIMIT_UNSAFE + * @stable ICU 2.4 + */ +#define U16_SET_CP_LIMIT(s, start, i, length) UPRV_BLOCK_MACRO_BEGIN { \ + if((start)<(i) && ((i)<(length) || (length)<0) && U16_IS_LEAD((s)[(i)-1]) && U16_IS_TRAIL((s)[i])) { \ + ++(i); \ + } \ +} UPRV_BLOCK_MACRO_END + +#endif diff --git a/vendor/tree-sitter/src/unicode/utf8.h b/vendor/tree-sitter/src/unicode/utf8.h new file mode 100644 index 0000000..bb00130 --- /dev/null +++ b/vendor/tree-sitter/src/unicode/utf8.h @@ -0,0 +1,881 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* +******************************************************************************* +* +* Copyright (C) 1999-2015, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* +* file name: utf8.h +* encoding: UTF-8 +* tab size: 8 (not used) +* indentation:4 +* +* created on: 1999sep13 +* created by: Markus W. Scherer +*/ + +/** + * \file + * \brief C API: 8-bit Unicode handling macros + * + * This file defines macros to deal with 8-bit Unicode (UTF-8) code units (bytes) and strings. + * + * For more information see utf.h and the ICU User Guide Strings chapter + * (http://userguide.icu-project.org/strings). + * + * Usage: + * ICU coding guidelines for if() statements should be followed when using these macros. + * Compound statements (curly braces {}) must be used for if-else-while... + * bodies and all macro statements should be terminated with semicolon. + */ + +#ifndef __UTF8_H__ +#define __UTF8_H__ + +#include "unicode/umachine.h" +#ifndef __UTF_H__ +# include "unicode/utf.h" +#endif + +/* internal definitions ----------------------------------------------------- */ + +/** + * Counts the trail bytes for a UTF-8 lead byte. + * Returns 0 for 0..0xc1 as well as for 0xf5..0xff. + * leadByte might be evaluated multiple times. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is called by public macros in this file and thus must remain stable. + * + * @param leadByte The first byte of a UTF-8 sequence. Must be 0..0xff. + * @internal + */ +#define U8_COUNT_TRAIL_BYTES(leadByte) \ + (U8_IS_LEAD(leadByte) ? \ + ((uint8_t)(leadByte)>=0xe0)+((uint8_t)(leadByte)>=0xf0)+1 : 0) + +/** + * Counts the trail bytes for a UTF-8 lead byte of a valid UTF-8 sequence. + * Returns 0 for 0..0xc1. Undefined for 0xf5..0xff. + * leadByte might be evaluated multiple times. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is called by public macros in this file and thus must remain stable. + * + * @param leadByte The first byte of a UTF-8 sequence. Must be 0..0xff. + * @internal + */ +#define U8_COUNT_TRAIL_BYTES_UNSAFE(leadByte) \ + (((uint8_t)(leadByte)>=0xc2)+((uint8_t)(leadByte)>=0xe0)+((uint8_t)(leadByte)>=0xf0)) + +/** + * Mask a UTF-8 lead byte, leave only the lower bits that form part of the code point value. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is called by public macros in this file and thus must remain stable. + * @internal + */ +#define U8_MASK_LEAD_BYTE(leadByte, countTrailBytes) ((leadByte)&=(1<<(6-(countTrailBytes)))-1) + +/** + * Internal bit vector for 3-byte UTF-8 validity check, for use in U8_IS_VALID_LEAD3_AND_T1. + * Each bit indicates whether one lead byte + first trail byte pair starts a valid sequence. + * Lead byte E0..EF bits 3..0 are used as byte index, + * first trail byte bits 7..5 are used as bit index into that byte. + * @see U8_IS_VALID_LEAD3_AND_T1 + * @internal + */ +#define U8_LEAD3_T1_BITS "\x20\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x10\x30\x30" + +/** + * Internal 3-byte UTF-8 validity check. + * Non-zero if lead byte E0..EF and first trail byte 00..FF start a valid sequence. + * @internal + */ +#define U8_IS_VALID_LEAD3_AND_T1(lead, t1) (U8_LEAD3_T1_BITS[(lead)&0xf]&(1<<((uint8_t)(t1)>>5))) + +/** + * Internal bit vector for 4-byte UTF-8 validity check, for use in U8_IS_VALID_LEAD4_AND_T1. + * Each bit indicates whether one lead byte + first trail byte pair starts a valid sequence. + * First trail byte bits 7..4 are used as byte index, + * lead byte F0..F4 bits 2..0 are used as bit index into that byte. + * @see U8_IS_VALID_LEAD4_AND_T1 + * @internal + */ +#define U8_LEAD4_T1_BITS "\x00\x00\x00\x00\x00\x00\x00\x00\x1E\x0F\x0F\x0F\x00\x00\x00\x00" + +/** + * Internal 4-byte UTF-8 validity check. + * Non-zero if lead byte F0..F4 and first trail byte 00..FF start a valid sequence. + * @internal + */ +#define U8_IS_VALID_LEAD4_AND_T1(lead, t1) (U8_LEAD4_T1_BITS[(uint8_t)(t1)>>4]&(1<<((lead)&7))) + +/** + * Function for handling "next code point" with error-checking. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is U_STABLE (not U_INTERNAL) since it is called by public macros in this + * file and thus must remain stable, and should not be hidden when other internal + * functions are hidden (otherwise public macros would fail to compile). + * @internal + */ +U_STABLE UChar32 U_EXPORT2 +utf8_nextCharSafeBody(const uint8_t *s, int32_t *pi, int32_t length, UChar32 c, UBool strict); + +/** + * Function for handling "append code point" with error-checking. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is U_STABLE (not U_INTERNAL) since it is called by public macros in this + * file and thus must remain stable, and should not be hidden when other internal + * functions are hidden (otherwise public macros would fail to compile). + * @internal + */ +U_STABLE int32_t U_EXPORT2 +utf8_appendCharSafeBody(uint8_t *s, int32_t i, int32_t length, UChar32 c, UBool *pIsError); + +/** + * Function for handling "previous code point" with error-checking. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is U_STABLE (not U_INTERNAL) since it is called by public macros in this + * file and thus must remain stable, and should not be hidden when other internal + * functions are hidden (otherwise public macros would fail to compile). + * @internal + */ +U_STABLE UChar32 U_EXPORT2 +utf8_prevCharSafeBody(const uint8_t *s, int32_t start, int32_t *pi, UChar32 c, UBool strict); + +/** + * Function for handling "skip backward one code point" with error-checking. + * + * This is internal since it is not meant to be called directly by external clients; + * however it is U_STABLE (not U_INTERNAL) since it is called by public macros in this + * file and thus must remain stable, and should not be hidden when other internal + * functions are hidden (otherwise public macros would fail to compile). + * @internal + */ +U_STABLE int32_t U_EXPORT2 +utf8_back1SafeBody(const uint8_t *s, int32_t start, int32_t i); + +/* single-code point definitions -------------------------------------------- */ + +/** + * Does this code unit (byte) encode a code point by itself (US-ASCII 0..0x7f)? + * @param c 8-bit code unit (byte) + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U8_IS_SINGLE(c) (((c)&0x80)==0) + +/** + * Is this code unit (byte) a UTF-8 lead byte? (0xC2..0xF4) + * @param c 8-bit code unit (byte) + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U8_IS_LEAD(c) ((uint8_t)((c)-0xc2)<=0x32) +// 0x32=0xf4-0xc2 + +/** + * Is this code unit (byte) a UTF-8 trail byte? (0x80..0xBF) + * @param c 8-bit code unit (byte) + * @return TRUE or FALSE + * @stable ICU 2.4 + */ +#define U8_IS_TRAIL(c) ((int8_t)(c)<-0x40) + +/** + * How many code units (bytes) are used for the UTF-8 encoding + * of this Unicode code point? + * @param c 32-bit code point + * @return 1..4, or 0 if c is a surrogate or not a Unicode code point + * @stable ICU 2.4 + */ +#define U8_LENGTH(c) \ + ((uint32_t)(c)<=0x7f ? 1 : \ + ((uint32_t)(c)<=0x7ff ? 2 : \ + ((uint32_t)(c)<=0xd7ff ? 3 : \ + ((uint32_t)(c)<=0xdfff || (uint32_t)(c)>0x10ffff ? 0 : \ + ((uint32_t)(c)<=0xffff ? 3 : 4)\ + ) \ + ) \ + ) \ + ) + +/** + * The maximum number of UTF-8 code units (bytes) per Unicode code point (U+0000..U+10ffff). + * @return 4 + * @stable ICU 2.4 + */ +#define U8_MAX_LENGTH 4 + +/** + * Get a code point from a string at a random-access offset, + * without changing the offset. + * The offset may point to either the lead byte or one of the trail bytes + * for a code point, in which case the macro will read all of the bytes + * for the code point. + * The result is undefined if the offset points to an illegal UTF-8 + * byte sequence. + * Iteration through a string is more efficient with U8_NEXT_UNSAFE or U8_NEXT. + * + * @param s const uint8_t * string + * @param i string offset + * @param c output UChar32 variable + * @see U8_GET + * @stable ICU 2.4 + */ +#define U8_GET_UNSAFE(s, i, c) UPRV_BLOCK_MACRO_BEGIN { \ + int32_t _u8_get_unsafe_index=(int32_t)(i); \ + U8_SET_CP_START_UNSAFE(s, _u8_get_unsafe_index); \ + U8_NEXT_UNSAFE(s, _u8_get_unsafe_index, c); \ +} UPRV_BLOCK_MACRO_END + +/** + * Get a code point from a string at a random-access offset, + * without changing the offset. + * The offset may point to either the lead byte or one of the trail bytes + * for a code point, in which case the macro will read all of the bytes + * for the code point. + * + * The length can be negative for a NUL-terminated string. + * + * If the offset points to an illegal UTF-8 byte sequence, then + * c is set to a negative value. + * Iteration through a string is more efficient with U8_NEXT_UNSAFE or U8_NEXT. + * + * @param s const uint8_t * string + * @param start int32_t starting string offset + * @param i int32_t string offset, must be start<=i=0xe0 ? \ + ((c)<0xf0 ? /* U+0800..U+FFFF except surrogates */ \ + U8_LEAD3_T1_BITS[(c)&=0xf]&(1<<((__t=(s)[i])>>5)) && \ + (__t&=0x3f, 1) \ + : /* U+10000..U+10FFFF */ \ + ((c)-=0xf0)<=4 && \ + U8_LEAD4_T1_BITS[(__t=(s)[i])>>4]&(1<<(c)) && \ + ((c)=((c)<<6)|(__t&0x3f), ++(i)!=(length)) && \ + (__t=(s)[i]-0x80)<=0x3f) && \ + /* valid second-to-last trail byte */ \ + ((c)=((c)<<6)|__t, ++(i)!=(length)) \ + : /* U+0080..U+07FF */ \ + (c)>=0xc2 && ((c)&=0x1f, 1)) && \ + /* last trail byte */ \ + (__t=(s)[i]-0x80)<=0x3f && \ + ((c)=((c)<<6)|__t, ++(i), 1)) { \ + } else { \ + (c)=(sub); /* ill-formed*/ \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Append a code point to a string, overwriting 1 to 4 bytes. + * The offset points to the current end of the string contents + * and is advanced (post-increment). + * "Unsafe" macro, assumes a valid code point and sufficient space in the string. + * Otherwise, the result is undefined. + * + * @param s const uint8_t * string buffer + * @param i string offset + * @param c code point to append + * @see U8_APPEND + * @stable ICU 2.4 + */ +#define U8_APPEND_UNSAFE(s, i, c) UPRV_BLOCK_MACRO_BEGIN { \ + uint32_t __uc=(c); \ + if(__uc<=0x7f) { \ + (s)[(i)++]=(uint8_t)__uc; \ + } else { \ + if(__uc<=0x7ff) { \ + (s)[(i)++]=(uint8_t)((__uc>>6)|0xc0); \ + } else { \ + if(__uc<=0xffff) { \ + (s)[(i)++]=(uint8_t)((__uc>>12)|0xe0); \ + } else { \ + (s)[(i)++]=(uint8_t)((__uc>>18)|0xf0); \ + (s)[(i)++]=(uint8_t)(((__uc>>12)&0x3f)|0x80); \ + } \ + (s)[(i)++]=(uint8_t)(((__uc>>6)&0x3f)|0x80); \ + } \ + (s)[(i)++]=(uint8_t)((__uc&0x3f)|0x80); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Append a code point to a string, overwriting 1 to 4 bytes. + * The offset points to the current end of the string contents + * and is advanced (post-increment). + * "Safe" macro, checks for a valid code point. + * If a non-ASCII code point is written, checks for sufficient space in the string. + * If the code point is not valid or trail bytes do not fit, + * then isError is set to TRUE. + * + * @param s const uint8_t * string buffer + * @param i int32_t string offset, must be i>6)|0xc0); \ + (s)[(i)++]=(uint8_t)((__uc&0x3f)|0x80); \ + } else if((__uc<=0xd7ff || (0xe000<=__uc && __uc<=0xffff)) && (i)+2<(capacity)) { \ + (s)[(i)++]=(uint8_t)((__uc>>12)|0xe0); \ + (s)[(i)++]=(uint8_t)(((__uc>>6)&0x3f)|0x80); \ + (s)[(i)++]=(uint8_t)((__uc&0x3f)|0x80); \ + } else if(0xffff<__uc && __uc<=0x10ffff && (i)+3<(capacity)) { \ + (s)[(i)++]=(uint8_t)((__uc>>18)|0xf0); \ + (s)[(i)++]=(uint8_t)(((__uc>>12)&0x3f)|0x80); \ + (s)[(i)++]=(uint8_t)(((__uc>>6)&0x3f)|0x80); \ + (s)[(i)++]=(uint8_t)((__uc&0x3f)|0x80); \ + } else { \ + (isError)=TRUE; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the next. + * (Post-incrementing iteration.) + * "Unsafe" macro, assumes well-formed UTF-8. + * + * @param s const uint8_t * string + * @param i string offset + * @see U8_FWD_1 + * @stable ICU 2.4 + */ +#define U8_FWD_1_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + (i)+=1+U8_COUNT_TRAIL_BYTES_UNSAFE((s)[i]); \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the next. + * (Post-incrementing iteration.) + * "Safe" macro, checks for illegal sequences and for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * @param s const uint8_t * string + * @param i int32_t string offset, must be i=0xf0 */ { \ + if(U8_IS_VALID_LEAD4_AND_T1(__b, __t1) && \ + ++(i)!=(length) && U8_IS_TRAIL((s)[i]) && \ + ++(i)!=(length) && U8_IS_TRAIL((s)[i])) { \ + ++(i); \ + } \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the n-th next one, + * i.e., move forward by n code points. + * (Post-incrementing iteration.) + * "Unsafe" macro, assumes well-formed UTF-8. + * + * @param s const uint8_t * string + * @param i string offset + * @param n number of code points to skip + * @see U8_FWD_N + * @stable ICU 2.4 + */ +#define U8_FWD_N_UNSAFE(s, i, n) UPRV_BLOCK_MACRO_BEGIN { \ + int32_t __N=(n); \ + while(__N>0) { \ + U8_FWD_1_UNSAFE(s, i); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Advance the string offset from one code point boundary to the n-th next one, + * i.e., move forward by n code points. + * (Post-incrementing iteration.) + * "Safe" macro, checks for illegal sequences and for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * @param s const uint8_t * string + * @param i int32_t string offset, must be i0 && ((i)<(length) || ((length)<0 && (s)[i]!=0))) { \ + U8_FWD_1(s, i, length); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary + * at the start of a code point. + * If the offset points to a UTF-8 trail byte, + * then the offset is moved backward to the corresponding lead byte. + * Otherwise, it is not modified. + * "Unsafe" macro, assumes well-formed UTF-8. + * + * @param s const uint8_t * string + * @param i string offset + * @see U8_SET_CP_START + * @stable ICU 2.4 + */ +#define U8_SET_CP_START_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + while(U8_IS_TRAIL((s)[i])) { --(i); } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary + * at the start of a code point. + * If the offset points to a UTF-8 trail byte, + * then the offset is moved backward to the corresponding lead byte. + * Otherwise, it is not modified. + * + * "Safe" macro, checks for illegal sequences and for string boundaries. + * Unlike U8_TRUNCATE_IF_INCOMPLETE(), this macro always reads s[i]. + * + * @param s const uint8_t * string + * @param start int32_t starting string offset (usually 0) + * @param i int32_t string offset, must be start<=i + * @see U8_SET_CP_START_UNSAFE + * @see U8_TRUNCATE_IF_INCOMPLETE + * @stable ICU 2.4 + */ +#define U8_SET_CP_START(s, start, i) UPRV_BLOCK_MACRO_BEGIN { \ + if(U8_IS_TRAIL((s)[(i)])) { \ + (i)=utf8_back1SafeBody(s, start, (i)); \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * If the string ends with a UTF-8 byte sequence that is valid so far + * but incomplete, then reduce the length of the string to end before + * the lead byte of that incomplete sequence. + * For example, if the string ends with E1 80, the length is reduced by 2. + * + * In all other cases (the string ends with a complete sequence, or it is not + * possible for any further trail byte to extend the trailing sequence) + * the length remains unchanged. + * + * Useful for processing text split across multiple buffers + * (save the incomplete sequence for later) + * and for optimizing iteration + * (check for string length only once per character). + * + * "Safe" macro, checks for illegal sequences and for string boundaries. + * Unlike U8_SET_CP_START(), this macro never reads s[length]. + * + * (In UTF-16, simply check for U16_IS_LEAD(last code unit).) + * + * @param s const uint8_t * string + * @param start int32_t starting string offset (usually 0) + * @param length int32_t string length (usually start<=length) + * @see U8_SET_CP_START + * @stable ICU 61 + */ +#define U8_TRUNCATE_IF_INCOMPLETE(s, start, length) UPRV_BLOCK_MACRO_BEGIN { \ + if((length)>(start)) { \ + uint8_t __b1=s[(length)-1]; \ + if(U8_IS_SINGLE(__b1)) { \ + /* common ASCII character */ \ + } else if(U8_IS_LEAD(__b1)) { \ + --(length); \ + } else if(U8_IS_TRAIL(__b1) && ((length)-2)>=(start)) { \ + uint8_t __b2=s[(length)-2]; \ + if(0xe0<=__b2 && __b2<=0xf4) { \ + if(__b2<0xf0 ? U8_IS_VALID_LEAD3_AND_T1(__b2, __b1) : \ + U8_IS_VALID_LEAD4_AND_T1(__b2, __b1)) { \ + (length)-=2; \ + } \ + } else if(U8_IS_TRAIL(__b2) && ((length)-3)>=(start)) { \ + uint8_t __b3=s[(length)-3]; \ + if(0xf0<=__b3 && __b3<=0xf4 && U8_IS_VALID_LEAD4_AND_T1(__b3, __b2)) { \ + (length)-=3; \ + } \ + } \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/* definitions with backward iteration -------------------------------------- */ + +/** + * Move the string offset from one code point boundary to the previous one + * and get the code point between them. + * (Pre-decrementing backward iteration.) + * "Unsafe" macro, assumes well-formed UTF-8. + * + * The input offset may be the same as the string length. + * If the offset is behind a multi-byte sequence, then the macro will read + * the whole sequence. + * If the offset is behind a lead byte, then that itself + * will be returned as the code point. + * The result is undefined if the offset is behind an illegal UTF-8 sequence. + * + * @param s const uint8_t * string + * @param i string offset + * @param c output UChar32 variable + * @see U8_PREV + * @stable ICU 2.4 + */ +#define U8_PREV_UNSAFE(s, i, c) UPRV_BLOCK_MACRO_BEGIN { \ + (c)=(uint8_t)(s)[--(i)]; \ + if(U8_IS_TRAIL(c)) { \ + uint8_t __b, __count=1, __shift=6; \ +\ + /* c is a trail byte */ \ + (c)&=0x3f; \ + for(;;) { \ + __b=(s)[--(i)]; \ + if(__b>=0xc0) { \ + U8_MASK_LEAD_BYTE(__b, __count); \ + (c)|=(UChar32)__b<<__shift; \ + break; \ + } else { \ + (c)|=(UChar32)(__b&0x3f)<<__shift; \ + ++__count; \ + __shift+=6; \ + } \ + } \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the previous one + * and get the code point between them. + * (Pre-decrementing backward iteration.) + * "Safe" macro, checks for illegal sequences and for string boundaries. + * + * The input offset may be the same as the string length. + * If the offset is behind a multi-byte sequence, then the macro will read + * the whole sequence. + * If the offset is behind a lead byte, then that itself + * will be returned as the code point. + * If the offset is behind an illegal UTF-8 sequence, then c is set to a negative value. + * + * @param s const uint8_t * string + * @param start int32_t starting string offset (usually 0) + * @param i int32_t string offset, must be start0) { \ + U8_BACK_1_UNSAFE(s, i); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Move the string offset from one code point boundary to the n-th one before it, + * i.e., move backward by n code points. + * (Pre-decrementing backward iteration.) + * The input offset may be the same as the string length. + * "Safe" macro, checks for illegal sequences and for string boundaries. + * + * @param s const uint8_t * string + * @param start int32_t index of the start of the string + * @param i int32_t string offset, must be start0 && (i)>(start)) { \ + U8_BACK_1(s, start, i); \ + --__N; \ + } \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary after a code point. + * If the offset is behind a partial multi-byte sequence, + * then the offset is incremented to behind the whole sequence. + * Otherwise, it is not modified. + * The input offset may be the same as the string length. + * "Unsafe" macro, assumes well-formed UTF-8. + * + * @param s const uint8_t * string + * @param i string offset + * @see U8_SET_CP_LIMIT + * @stable ICU 2.4 + */ +#define U8_SET_CP_LIMIT_UNSAFE(s, i) UPRV_BLOCK_MACRO_BEGIN { \ + U8_BACK_1_UNSAFE(s, i); \ + U8_FWD_1_UNSAFE(s, i); \ +} UPRV_BLOCK_MACRO_END + +/** + * Adjust a random-access offset to a code point boundary after a code point. + * If the offset is behind a partial multi-byte sequence, + * then the offset is incremented to behind the whole sequence. + * Otherwise, it is not modified. + * The input offset may be the same as the string length. + * "Safe" macro, checks for illegal sequences and for string boundaries. + * + * The length can be negative for a NUL-terminated string. + * + * @param s const uint8_t * string + * @param start int32_t starting string offset (usually 0) + * @param i int32_t string offset, must be start<=i<=length + * @param length int32_t string length + * @see U8_SET_CP_LIMIT_UNSAFE + * @stable ICU 2.4 + */ +#define U8_SET_CP_LIMIT(s, start, i, length) UPRV_BLOCK_MACRO_BEGIN { \ + if((start)<(i) && ((i)<(length) || (length)<0)) { \ + U8_BACK_1(s, start, i); \ + U8_FWD_1(s, i, length); \ + } \ +} UPRV_BLOCK_MACRO_END + +#endif diff --git a/vendor/tree-sitter/src/wasm/stdlib-symbols.txt b/vendor/tree-sitter/src/wasm/stdlib-symbols.txt new file mode 100644 index 0000000..1b6d789 --- /dev/null +++ b/vendor/tree-sitter/src/wasm/stdlib-symbols.txt @@ -0,0 +1,24 @@ +"calloc", +"free", +"iswalnum", +"iswalpha", +"iswblank", +"iswdigit", +"iswlower", +"iswspace", +"iswupper", +"iswxdigit", +"malloc", +"memchr", +"memcmp", +"memcpy", +"memmove", +"memset", +"realloc", +"strcmp", +"strlen", +"strncat", +"strncmp", +"strncpy", +"towlower", +"towupper", diff --git a/vendor/tree-sitter/src/wasm/stdlib.c b/vendor/tree-sitter/src/wasm/stdlib.c new file mode 100644 index 0000000..af8936f --- /dev/null +++ b/vendor/tree-sitter/src/wasm/stdlib.c @@ -0,0 +1,137 @@ +// This file implements a very simple allocator for external scanners running +// in WASM. Allocation is just bumping a static pointer and growing the heap +// as needed, and freeing is just adding the freed region to a free list. +// When additional memory is allocated, the free list is searched first. +// If there is not a suitable region in the free list, the heap is +// grown as necessary, and the allocation is made at the end of the heap. +// When the heap is reset, all allocated memory is considered freed. + +#ifdef TREE_SITTER_FEATURE_WASM + +#include +#include +#include +#include + +extern void tree_sitter_debug_message(const char *, size_t); + +#define PAGESIZE 0x10000 +#define MAX_HEAP_SIZE (4 * 1024 * 1024) + +typedef struct { + size_t size; + struct Region *next; + char data[0]; +} Region; + +static Region *heap_end = NULL; +static Region *heap_start = NULL; +static Region *next = NULL; +static Region *free_list = NULL; + +// Get the region metadata for the given heap pointer. +static inline Region *region_for_ptr(void *ptr) { + return ((Region *)ptr) - 1; +} + +// Get the location of the next region after the given region, +// if the given region had the given size. +static inline Region *region_after(Region *self, size_t len) { + char *address = self->data + len; + char *aligned = (char *)((uintptr_t)(address + 3) & ~0x3); + return (Region *)aligned; +} + +static void *get_heap_end() { + return (void *)(__builtin_wasm_memory_size(0) * PAGESIZE); +} + +static int grow_heap(size_t size) { + size_t new_page_count = ((size - 1) / PAGESIZE) + 1; + return __builtin_wasm_memory_grow(0, new_page_count) != SIZE_MAX; +} + +// Clear out the heap, and move it to the given address. +void reset_heap(void *new_heap_start) { + heap_start = new_heap_start; + next = new_heap_start; + heap_end = get_heap_end(); + free_list = NULL; +} + +void *malloc(size_t size) { + Region *prev = NULL; + Region *curr = free_list; + while (curr != NULL) { + if (curr->size >= size) { + if (prev == NULL) { + free_list = curr->next; + } else { + prev->next = curr->next; + } + return &curr->data; + } + prev = curr; + curr = curr->next; + } + + Region *region_end = region_after(next, size); + + if (region_end > heap_end) { + if ((char *)region_end - (char *)heap_start > MAX_HEAP_SIZE) { + return NULL; + } + if (!grow_heap(size)) return NULL; + heap_end = get_heap_end(); + } + + void *result = &next->data; + next->size = size; + next = region_end; + + return result; +} + +void free(void *ptr) { + if (ptr == NULL) return; + + Region *region = region_for_ptr(ptr); + Region *region_end = region_after(region, region->size); + + // When freeing the last allocated pointer, re-use that + // pointer for the next allocation. + if (region_end == next) { + next = region; + } else { + region->next = free_list; + free_list = region; + } +} + +void *calloc(size_t count, size_t size) { + void *result = malloc(count * size); + memset(result, 0, count * size); + return result; +} + +void *realloc(void *ptr, size_t new_size) { + if (ptr == NULL) { + return malloc(new_size); + } + + Region *region = region_for_ptr(ptr); + Region *region_end = region_after(region, region->size); + + // When reallocating the last allocated region, return + // the same pointer, and skip copying the data. + if (region_end == next) { + next = region; + return malloc(new_size); + } + + void *result = malloc(new_size); + memcpy(result, ®ion->data, region->size); + return result; +} + +#endif diff --git a/vendor/tree-sitter/src/wasm/wasm-stdlib.h b/vendor/tree-sitter/src/wasm/wasm-stdlib.h new file mode 100644 index 0000000..a9d241d --- /dev/null +++ b/vendor/tree-sitter/src/wasm/wasm-stdlib.h @@ -0,0 +1,1314 @@ +#ifdef TREE_SITTER_FEATURE_WASM + +unsigned char STDLIB_WASM[] = { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x1e, 0x06, 0x60, + 0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x00, 0x00, + 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x60, 0x00, 0x01, 0x7f, 0x60, 0x03, 0x7f, + 0x7f, 0x7f, 0x01, 0x7f, 0x02, 0x9e, 0x01, 0x05, 0x03, 0x65, 0x6e, 0x76, + 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x02, 0x03, 0x65, + 0x6e, 0x76, 0x19, 0x5f, 0x5f, 0x69, 0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x5f, 0x66, 0x75, 0x6e, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, + 0x61, 0x62, 0x6c, 0x65, 0x01, 0x70, 0x00, 0x01, 0x16, 0x77, 0x61, 0x73, + 0x69, 0x5f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, + 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x31, 0x08, 0x61, 0x72, 0x67, 0x73, + 0x5f, 0x67, 0x65, 0x74, 0x00, 0x00, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f, + 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, + 0x76, 0x69, 0x65, 0x77, 0x31, 0x0e, 0x61, 0x72, 0x67, 0x73, 0x5f, 0x73, + 0x69, 0x7a, 0x65, 0x73, 0x5f, 0x67, 0x65, 0x74, 0x00, 0x00, 0x16, 0x77, + 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, + 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x31, 0x09, 0x70, 0x72, + 0x6f, 0x63, 0x5f, 0x65, 0x78, 0x69, 0x74, 0x00, 0x01, 0x03, 0x2a, 0x29, + 0x02, 0x00, 0x02, 0x02, 0x01, 0x03, 0x01, 0x00, 0x00, 0x01, 0x04, 0x00, + 0x00, 0x01, 0x02, 0x02, 0x05, 0x05, 0x03, 0x03, 0x05, 0x05, 0x00, 0x03, + 0x00, 0x03, 0x05, 0x03, 0x05, 0x03, 0x03, 0x03, 0x03, 0x05, 0x05, 0x05, + 0x03, 0x03, 0x00, 0x03, 0x03, 0x06, 0x0d, 0x02, 0x7f, 0x01, 0x41, 0x80, + 0x80, 0x04, 0x0b, 0x7f, 0x00, 0x41, 0x00, 0x0b, 0x07, 0xad, 0x02, 0x1c, + 0x11, 0x5f, 0x5f, 0x77, 0x61, 0x73, 0x6d, 0x5f, 0x63, 0x61, 0x6c, 0x6c, + 0x5f, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x00, 0x03, 0x0f, 0x5f, 0x5f, 0x73, + 0x74, 0x61, 0x63, 0x6b, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x03, 0x00, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x06, 0x0a, + 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x70, 0x00, 0x07, + 0x06, 0x6d, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x00, 0x08, 0x04, 0x66, 0x72, + 0x65, 0x65, 0x00, 0x09, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x00, + 0x0a, 0x06, 0x6d, 0x65, 0x6d, 0x73, 0x65, 0x74, 0x00, 0x14, 0x07, 0x72, + 0x65, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x00, 0x0b, 0x06, 0x6d, 0x65, 0x6d, + 0x63, 0x70, 0x79, 0x00, 0x13, 0x06, 0x73, 0x74, 0x72, 0x6c, 0x65, 0x6e, + 0x00, 0x15, 0x08, 0x69, 0x73, 0x77, 0x61, 0x6c, 0x6e, 0x75, 0x6d, 0x00, + 0x2b, 0x08, 0x69, 0x73, 0x77, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x00, 0x16, + 0x08, 0x69, 0x73, 0x77, 0x62, 0x6c, 0x61, 0x6e, 0x6b, 0x00, 0x22, 0x08, + 0x69, 0x73, 0x77, 0x64, 0x69, 0x67, 0x69, 0x74, 0x00, 0x23, 0x08, 0x69, + 0x73, 0x77, 0x6c, 0x6f, 0x77, 0x65, 0x72, 0x00, 0x20, 0x08, 0x69, 0x73, + 0x77, 0x73, 0x70, 0x61, 0x63, 0x65, 0x00, 0x2a, 0x08, 0x69, 0x73, 0x77, + 0x75, 0x70, 0x70, 0x65, 0x72, 0x00, 0x1e, 0x09, 0x69, 0x73, 0x77, 0x78, + 0x64, 0x69, 0x67, 0x69, 0x74, 0x00, 0x27, 0x08, 0x74, 0x6f, 0x77, 0x6c, + 0x6f, 0x77, 0x65, 0x72, 0x00, 0x1a, 0x08, 0x74, 0x6f, 0x77, 0x75, 0x70, + 0x70, 0x65, 0x72, 0x00, 0x1c, 0x06, 0x6d, 0x65, 0x6d, 0x63, 0x68, 0x72, + 0x00, 0x18, 0x06, 0x6d, 0x65, 0x6d, 0x63, 0x6d, 0x70, 0x00, 0x17, 0x07, + 0x6d, 0x65, 0x6d, 0x6d, 0x6f, 0x76, 0x65, 0x00, 0x1f, 0x06, 0x73, 0x74, + 0x72, 0x63, 0x6d, 0x70, 0x00, 0x19, 0x07, 0x73, 0x74, 0x72, 0x6e, 0x63, + 0x61, 0x74, 0x00, 0x24, 0x07, 0x73, 0x74, 0x72, 0x6e, 0x63, 0x6d, 0x70, + 0x00, 0x1d, 0x07, 0x73, 0x74, 0x72, 0x6e, 0x63, 0x70, 0x79, 0x00, 0x26, + 0x08, 0x01, 0x05, 0x0a, 0xff, 0x2b, 0x29, 0x02, 0x00, 0x0b, 0x03, 0x00, + 0x00, 0x0b, 0x0d, 0x00, 0x41, 0xe8, 0xc2, 0x04, 0x41, 0x00, 0x41, 0x10, + 0xfc, 0x0b, 0x00, 0x0b, 0x51, 0x01, 0x01, 0x7f, 0x02, 0x40, 0x02, 0x40, + 0x23, 0x81, 0x80, 0x80, 0x80, 0x00, 0x41, 0xe8, 0xc2, 0x84, 0x80, 0x00, + 0x6a, 0x28, 0x02, 0x00, 0x0d, 0x00, 0x23, 0x81, 0x80, 0x80, 0x80, 0x00, + 0x41, 0xe8, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x41, 0x01, 0x36, 0x02, 0x00, + 0x10, 0x83, 0x80, 0x80, 0x80, 0x00, 0x10, 0x8d, 0x80, 0x80, 0x80, 0x00, + 0x21, 0x00, 0x10, 0x92, 0x80, 0x80, 0x80, 0x00, 0x20, 0x00, 0x0d, 0x01, + 0x0f, 0x0b, 0x00, 0x0b, 0x20, 0x00, 0x10, 0x90, 0x80, 0x80, 0x80, 0x00, + 0x00, 0x0b, 0x37, 0x01, 0x01, 0x7f, 0x23, 0x81, 0x80, 0x80, 0x80, 0x00, + 0x22, 0x01, 0x41, 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x20, 0x00, 0x36, + 0x02, 0x00, 0x20, 0x01, 0x41, 0xec, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x20, + 0x00, 0x36, 0x02, 0x00, 0x20, 0x01, 0x41, 0xf4, 0xc2, 0x84, 0x80, 0x00, + 0x6a, 0x3f, 0x00, 0x41, 0x10, 0x74, 0x36, 0x02, 0x00, 0x0b, 0xb4, 0x01, + 0x01, 0x03, 0x7f, 0x02, 0x40, 0x02, 0x40, 0x23, 0x81, 0x80, 0x80, 0x80, + 0x00, 0x22, 0x01, 0x41, 0xf4, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x28, 0x02, + 0x00, 0x20, 0x01, 0x41, 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x28, 0x02, + 0x00, 0x22, 0x01, 0x20, 0x00, 0x6a, 0x41, 0x07, 0x6a, 0x41, 0x7c, 0x71, + 0x22, 0x02, 0x4f, 0x0d, 0x00, 0x41, 0x00, 0x21, 0x01, 0x20, 0x02, 0x23, + 0x81, 0x80, 0x80, 0x80, 0x00, 0x41, 0xec, 0xc2, 0x84, 0x80, 0x00, 0x6a, + 0x28, 0x02, 0x00, 0x6b, 0x41, 0x80, 0x80, 0x80, 0x02, 0x4a, 0x0d, 0x01, + 0x20, 0x00, 0x41, 0x7f, 0x6a, 0x41, 0x10, 0x76, 0x41, 0x01, 0x6a, 0x40, + 0x00, 0x41, 0x7f, 0x46, 0x0d, 0x01, 0x3f, 0x00, 0x21, 0x01, 0x23, 0x81, + 0x80, 0x80, 0x80, 0x00, 0x22, 0x03, 0x41, 0xf4, 0xc2, 0x84, 0x80, 0x00, + 0x6a, 0x20, 0x01, 0x41, 0x10, 0x74, 0x36, 0x02, 0x00, 0x20, 0x03, 0x41, + 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x28, 0x02, 0x00, 0x21, 0x01, 0x0b, + 0x20, 0x01, 0x20, 0x00, 0x36, 0x02, 0x00, 0x23, 0x81, 0x80, 0x80, 0x80, + 0x00, 0x41, 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x20, 0x02, 0x36, 0x02, + 0x00, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x21, 0x01, 0x0b, 0x20, 0x01, 0x0b, + 0x48, 0x01, 0x02, 0x7f, 0x02, 0x40, 0x20, 0x00, 0x45, 0x0d, 0x00, 0x20, + 0x00, 0x41, 0x7c, 0x6a, 0x22, 0x01, 0x28, 0x02, 0x00, 0x21, 0x02, 0x23, + 0x81, 0x80, 0x80, 0x80, 0x00, 0x41, 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, + 0x28, 0x02, 0x00, 0x20, 0x00, 0x20, 0x02, 0x6a, 0x41, 0x03, 0x6a, 0x41, + 0x7c, 0x71, 0x47, 0x0d, 0x00, 0x23, 0x81, 0x80, 0x80, 0x80, 0x00, 0x41, + 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x20, 0x01, 0x36, 0x02, 0x00, 0x0b, + 0x0b, 0x19, 0x00, 0x20, 0x01, 0x20, 0x00, 0x6c, 0x22, 0x00, 0x10, 0x88, + 0x80, 0x80, 0x80, 0x00, 0x41, 0x00, 0x20, 0x00, 0x10, 0x94, 0x80, 0x80, + 0x80, 0x00, 0x0b, 0x6b, 0x01, 0x02, 0x7f, 0x02, 0x40, 0x20, 0x00, 0x45, + 0x0d, 0x00, 0x20, 0x00, 0x41, 0x7c, 0x6a, 0x22, 0x02, 0x28, 0x02, 0x00, + 0x21, 0x03, 0x02, 0x40, 0x23, 0x81, 0x80, 0x80, 0x80, 0x00, 0x41, 0xf0, + 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x28, 0x02, 0x00, 0x20, 0x00, 0x20, 0x03, + 0x6a, 0x41, 0x03, 0x6a, 0x41, 0x7c, 0x71, 0x47, 0x0d, 0x00, 0x23, 0x81, + 0x80, 0x80, 0x80, 0x00, 0x41, 0xf0, 0xc2, 0x84, 0x80, 0x00, 0x6a, 0x20, + 0x02, 0x36, 0x02, 0x00, 0x0c, 0x01, 0x0b, 0x20, 0x01, 0x10, 0x88, 0x80, + 0x80, 0x80, 0x00, 0x20, 0x00, 0x20, 0x02, 0x28, 0x02, 0x00, 0x10, 0x93, + 0x80, 0x80, 0x80, 0x00, 0x0f, 0x0b, 0x20, 0x01, 0x10, 0x88, 0x80, 0x80, + 0x80, 0x00, 0x0b, 0x0b, 0x00, 0x20, 0x00, 0x10, 0x90, 0x80, 0x80, 0x80, + 0x00, 0x00, 0x0b, 0xd5, 0x01, 0x01, 0x03, 0x7f, 0x23, 0x80, 0x80, 0x80, + 0x80, 0x00, 0x41, 0x10, 0x6b, 0x22, 0x00, 0x24, 0x80, 0x80, 0x80, 0x80, + 0x00, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x20, + 0x00, 0x41, 0x08, 0x6a, 0x20, 0x00, 0x41, 0x0c, 0x6a, 0x10, 0x8f, 0x80, + 0x80, 0x80, 0x00, 0x0d, 0x00, 0x20, 0x00, 0x28, 0x02, 0x08, 0x41, 0x01, + 0x6a, 0x22, 0x01, 0x45, 0x0d, 0x01, 0x20, 0x00, 0x28, 0x02, 0x0c, 0x10, + 0x88, 0x80, 0x80, 0x80, 0x00, 0x22, 0x02, 0x45, 0x0d, 0x02, 0x20, 0x01, + 0x41, 0x04, 0x10, 0x8a, 0x80, 0x80, 0x80, 0x00, 0x22, 0x01, 0x45, 0x0d, + 0x03, 0x20, 0x01, 0x20, 0x02, 0x10, 0x8e, 0x80, 0x80, 0x80, 0x00, 0x0d, + 0x04, 0x20, 0x00, 0x28, 0x02, 0x08, 0x20, 0x01, 0x10, 0x84, 0x80, 0x80, + 0x80, 0x00, 0x21, 0x01, 0x20, 0x00, 0x41, 0x10, 0x6a, 0x24, 0x80, 0x80, + 0x80, 0x80, 0x00, 0x20, 0x01, 0x0f, 0x0b, 0x41, 0xc7, 0x00, 0x10, 0x8c, + 0x80, 0x80, 0x80, 0x00, 0x00, 0x0b, 0x41, 0xc6, 0x00, 0x10, 0x8c, 0x80, + 0x80, 0x80, 0x00, 0x00, 0x0b, 0x41, 0xc6, 0x00, 0x10, 0x8c, 0x80, 0x80, + 0x80, 0x00, 0x00, 0x0b, 0x20, 0x02, 0x10, 0x89, 0x80, 0x80, 0x80, 0x00, + 0x41, 0xc6, 0x00, 0x10, 0x8c, 0x80, 0x80, 0x80, 0x00, 0x00, 0x0b, 0x20, + 0x02, 0x10, 0x89, 0x80, 0x80, 0x80, 0x00, 0x20, 0x01, 0x10, 0x89, 0x80, + 0x80, 0x80, 0x00, 0x41, 0xc7, 0x00, 0x10, 0x8c, 0x80, 0x80, 0x80, 0x00, + 0x00, 0x0b, 0x11, 0x00, 0x20, 0x00, 0x20, 0x01, 0x10, 0x80, 0x80, 0x80, + 0x80, 0x00, 0x41, 0xff, 0xff, 0x03, 0x71, 0x0b, 0x11, 0x00, 0x20, 0x00, + 0x20, 0x01, 0x10, 0x81, 0x80, 0x80, 0x80, 0x00, 0x41, 0xff, 0xff, 0x03, + 0x71, 0x0b, 0x0b, 0x00, 0x20, 0x00, 0x10, 0x82, 0x80, 0x80, 0x80, 0x00, + 0x00, 0x0b, 0x02, 0x00, 0x0b, 0x0e, 0x00, 0x10, 0x91, 0x80, 0x80, 0x80, + 0x00, 0x10, 0x91, 0x80, 0x80, 0x80, 0x00, 0x0b, 0xee, 0x07, 0x01, 0x04, + 0x7f, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x20, 0x02, 0x41, 0x20, 0x4b, + 0x0d, 0x00, 0x20, 0x01, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x01, 0x20, 0x02, + 0x45, 0x0d, 0x01, 0x20, 0x00, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x3a, 0x00, + 0x00, 0x20, 0x02, 0x41, 0x7f, 0x6a, 0x21, 0x03, 0x20, 0x00, 0x41, 0x01, + 0x6a, 0x21, 0x04, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x05, 0x41, 0x03, + 0x71, 0x45, 0x0d, 0x02, 0x20, 0x03, 0x45, 0x0d, 0x02, 0x20, 0x00, 0x20, + 0x01, 0x2d, 0x00, 0x01, 0x3a, 0x00, 0x01, 0x20, 0x02, 0x41, 0x7e, 0x6a, + 0x21, 0x03, 0x20, 0x00, 0x41, 0x02, 0x6a, 0x21, 0x04, 0x20, 0x01, 0x41, + 0x02, 0x6a, 0x22, 0x05, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x02, 0x20, 0x03, + 0x45, 0x0d, 0x02, 0x20, 0x00, 0x20, 0x01, 0x2d, 0x00, 0x02, 0x3a, 0x00, + 0x02, 0x20, 0x02, 0x41, 0x7d, 0x6a, 0x21, 0x03, 0x20, 0x00, 0x41, 0x03, + 0x6a, 0x21, 0x04, 0x20, 0x01, 0x41, 0x03, 0x6a, 0x22, 0x05, 0x41, 0x03, + 0x71, 0x45, 0x0d, 0x02, 0x20, 0x03, 0x45, 0x0d, 0x02, 0x20, 0x00, 0x20, + 0x01, 0x2d, 0x00, 0x03, 0x3a, 0x00, 0x03, 0x20, 0x02, 0x41, 0x7c, 0x6a, + 0x21, 0x03, 0x20, 0x00, 0x41, 0x04, 0x6a, 0x21, 0x04, 0x20, 0x01, 0x41, + 0x04, 0x6a, 0x21, 0x05, 0x0c, 0x02, 0x0b, 0x20, 0x00, 0x20, 0x01, 0x20, + 0x02, 0xfc, 0x0a, 0x00, 0x00, 0x20, 0x00, 0x0f, 0x0b, 0x20, 0x02, 0x21, + 0x03, 0x20, 0x00, 0x21, 0x04, 0x20, 0x01, 0x21, 0x05, 0x0b, 0x02, 0x40, + 0x02, 0x40, 0x20, 0x04, 0x41, 0x03, 0x71, 0x22, 0x02, 0x0d, 0x00, 0x02, + 0x40, 0x02, 0x40, 0x20, 0x03, 0x41, 0x10, 0x4f, 0x0d, 0x00, 0x20, 0x03, + 0x21, 0x02, 0x0c, 0x01, 0x0b, 0x02, 0x40, 0x20, 0x03, 0x41, 0x70, 0x6a, + 0x22, 0x02, 0x41, 0x10, 0x71, 0x0d, 0x00, 0x20, 0x04, 0x20, 0x05, 0x29, + 0x02, 0x00, 0x37, 0x02, 0x00, 0x20, 0x04, 0x20, 0x05, 0x29, 0x02, 0x08, + 0x37, 0x02, 0x08, 0x20, 0x04, 0x41, 0x10, 0x6a, 0x21, 0x04, 0x20, 0x05, + 0x41, 0x10, 0x6a, 0x21, 0x05, 0x20, 0x02, 0x21, 0x03, 0x0b, 0x20, 0x02, + 0x41, 0x10, 0x49, 0x0d, 0x00, 0x20, 0x03, 0x21, 0x02, 0x03, 0x40, 0x20, + 0x04, 0x20, 0x05, 0x29, 0x02, 0x00, 0x37, 0x02, 0x00, 0x20, 0x04, 0x20, + 0x05, 0x29, 0x02, 0x08, 0x37, 0x02, 0x08, 0x20, 0x04, 0x20, 0x05, 0x29, + 0x02, 0x10, 0x37, 0x02, 0x10, 0x20, 0x04, 0x20, 0x05, 0x29, 0x02, 0x18, + 0x37, 0x02, 0x18, 0x20, 0x04, 0x41, 0x20, 0x6a, 0x21, 0x04, 0x20, 0x05, + 0x41, 0x20, 0x6a, 0x21, 0x05, 0x20, 0x02, 0x41, 0x60, 0x6a, 0x22, 0x02, + 0x41, 0x0f, 0x4b, 0x0d, 0x00, 0x0b, 0x0b, 0x02, 0x40, 0x20, 0x02, 0x41, + 0x08, 0x49, 0x0d, 0x00, 0x20, 0x04, 0x20, 0x05, 0x29, 0x02, 0x00, 0x37, + 0x02, 0x00, 0x20, 0x05, 0x41, 0x08, 0x6a, 0x21, 0x05, 0x20, 0x04, 0x41, + 0x08, 0x6a, 0x21, 0x04, 0x0b, 0x02, 0x40, 0x20, 0x02, 0x41, 0x04, 0x71, + 0x45, 0x0d, 0x00, 0x20, 0x04, 0x20, 0x05, 0x28, 0x02, 0x00, 0x36, 0x02, + 0x00, 0x20, 0x05, 0x41, 0x04, 0x6a, 0x21, 0x05, 0x20, 0x04, 0x41, 0x04, + 0x6a, 0x21, 0x04, 0x0b, 0x02, 0x40, 0x20, 0x02, 0x41, 0x02, 0x71, 0x45, + 0x0d, 0x00, 0x20, 0x04, 0x20, 0x05, 0x2f, 0x00, 0x00, 0x3b, 0x00, 0x00, + 0x20, 0x04, 0x41, 0x02, 0x6a, 0x21, 0x04, 0x20, 0x05, 0x41, 0x02, 0x6a, + 0x21, 0x05, 0x0b, 0x20, 0x02, 0x41, 0x01, 0x71, 0x45, 0x0d, 0x01, 0x20, + 0x04, 0x20, 0x05, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x20, 0x00, 0x0f, + 0x0b, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x20, + 0x03, 0x41, 0x20, 0x49, 0x0d, 0x00, 0x20, 0x04, 0x20, 0x05, 0x28, 0x02, + 0x00, 0x22, 0x03, 0x3a, 0x00, 0x00, 0x02, 0x40, 0x02, 0x40, 0x20, 0x02, + 0x41, 0x7f, 0x6a, 0x0e, 0x03, 0x03, 0x00, 0x01, 0x03, 0x0b, 0x20, 0x04, + 0x20, 0x03, 0x41, 0x08, 0x76, 0x3a, 0x00, 0x01, 0x20, 0x04, 0x20, 0x05, + 0x41, 0x06, 0x6a, 0x29, 0x01, 0x00, 0x37, 0x02, 0x06, 0x20, 0x04, 0x20, + 0x05, 0x28, 0x02, 0x04, 0x41, 0x10, 0x74, 0x20, 0x03, 0x41, 0x10, 0x76, + 0x72, 0x36, 0x02, 0x02, 0x20, 0x04, 0x41, 0x12, 0x6a, 0x21, 0x02, 0x20, + 0x05, 0x41, 0x12, 0x6a, 0x21, 0x01, 0x41, 0x0e, 0x21, 0x06, 0x20, 0x05, + 0x41, 0x0e, 0x6a, 0x28, 0x01, 0x00, 0x21, 0x05, 0x41, 0x0e, 0x21, 0x03, + 0x0c, 0x03, 0x0b, 0x20, 0x04, 0x20, 0x05, 0x41, 0x05, 0x6a, 0x29, 0x00, + 0x00, 0x37, 0x02, 0x05, 0x20, 0x04, 0x20, 0x05, 0x28, 0x02, 0x04, 0x41, + 0x18, 0x74, 0x20, 0x03, 0x41, 0x08, 0x76, 0x72, 0x36, 0x02, 0x01, 0x20, + 0x04, 0x41, 0x11, 0x6a, 0x21, 0x02, 0x20, 0x05, 0x41, 0x11, 0x6a, 0x21, + 0x01, 0x41, 0x0d, 0x21, 0x06, 0x20, 0x05, 0x41, 0x0d, 0x6a, 0x28, 0x00, + 0x00, 0x21, 0x05, 0x41, 0x0f, 0x21, 0x03, 0x0c, 0x02, 0x0b, 0x02, 0x40, + 0x02, 0x40, 0x20, 0x03, 0x41, 0x10, 0x4f, 0x0d, 0x00, 0x20, 0x04, 0x21, + 0x02, 0x20, 0x05, 0x21, 0x01, 0x0c, 0x01, 0x0b, 0x20, 0x04, 0x20, 0x05, + 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x20, 0x04, 0x20, 0x05, 0x28, 0x00, + 0x01, 0x36, 0x00, 0x01, 0x20, 0x04, 0x20, 0x05, 0x29, 0x00, 0x05, 0x37, + 0x00, 0x05, 0x20, 0x04, 0x20, 0x05, 0x2f, 0x00, 0x0d, 0x3b, 0x00, 0x0d, + 0x20, 0x04, 0x20, 0x05, 0x2d, 0x00, 0x0f, 0x3a, 0x00, 0x0f, 0x20, 0x04, + 0x41, 0x10, 0x6a, 0x21, 0x02, 0x20, 0x05, 0x41, 0x10, 0x6a, 0x21, 0x01, + 0x0b, 0x20, 0x03, 0x41, 0x08, 0x71, 0x0d, 0x02, 0x0c, 0x03, 0x0b, 0x20, + 0x04, 0x20, 0x03, 0x41, 0x10, 0x76, 0x3a, 0x00, 0x02, 0x20, 0x04, 0x20, + 0x03, 0x41, 0x08, 0x76, 0x3a, 0x00, 0x01, 0x20, 0x04, 0x20, 0x05, 0x41, + 0x07, 0x6a, 0x29, 0x00, 0x00, 0x37, 0x02, 0x07, 0x20, 0x04, 0x20, 0x05, + 0x28, 0x02, 0x04, 0x41, 0x08, 0x74, 0x20, 0x03, 0x41, 0x18, 0x76, 0x72, + 0x36, 0x02, 0x03, 0x20, 0x04, 0x41, 0x13, 0x6a, 0x21, 0x02, 0x20, 0x05, + 0x41, 0x13, 0x6a, 0x21, 0x01, 0x41, 0x0f, 0x21, 0x06, 0x20, 0x05, 0x41, + 0x0f, 0x6a, 0x28, 0x00, 0x00, 0x21, 0x05, 0x41, 0x0d, 0x21, 0x03, 0x0b, + 0x20, 0x04, 0x20, 0x06, 0x6a, 0x20, 0x05, 0x36, 0x02, 0x00, 0x0b, 0x20, + 0x02, 0x20, 0x01, 0x29, 0x00, 0x00, 0x37, 0x00, 0x00, 0x20, 0x02, 0x41, + 0x08, 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x08, 0x6a, 0x21, 0x01, 0x0b, + 0x02, 0x40, 0x20, 0x03, 0x41, 0x04, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x02, + 0x20, 0x01, 0x28, 0x00, 0x00, 0x36, 0x00, 0x00, 0x20, 0x02, 0x41, 0x04, + 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x21, 0x01, 0x0b, 0x02, + 0x40, 0x20, 0x03, 0x41, 0x02, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x02, 0x20, + 0x01, 0x2f, 0x00, 0x00, 0x3b, 0x00, 0x00, 0x20, 0x02, 0x41, 0x02, 0x6a, + 0x21, 0x02, 0x20, 0x01, 0x41, 0x02, 0x6a, 0x21, 0x01, 0x0b, 0x20, 0x03, + 0x41, 0x01, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x02, 0x20, 0x01, 0x2d, 0x00, + 0x00, 0x3a, 0x00, 0x00, 0x0b, 0x20, 0x00, 0x0b, 0x88, 0x03, 0x02, 0x03, + 0x7f, 0x01, 0x7e, 0x02, 0x40, 0x20, 0x02, 0x41, 0x21, 0x49, 0x0d, 0x00, + 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0xfc, 0x0b, 0x00, 0x20, 0x00, 0x0f, + 0x0b, 0x02, 0x40, 0x20, 0x02, 0x45, 0x0d, 0x00, 0x20, 0x00, 0x20, 0x01, + 0x3a, 0x00, 0x00, 0x20, 0x00, 0x20, 0x02, 0x6a, 0x22, 0x03, 0x41, 0x7f, + 0x6a, 0x20, 0x01, 0x3a, 0x00, 0x00, 0x20, 0x02, 0x41, 0x03, 0x49, 0x0d, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x3a, 0x00, 0x02, 0x20, 0x00, 0x20, 0x01, + 0x3a, 0x00, 0x01, 0x20, 0x03, 0x41, 0x7d, 0x6a, 0x20, 0x01, 0x3a, 0x00, + 0x00, 0x20, 0x03, 0x41, 0x7e, 0x6a, 0x20, 0x01, 0x3a, 0x00, 0x00, 0x20, + 0x02, 0x41, 0x07, 0x49, 0x0d, 0x00, 0x20, 0x00, 0x20, 0x01, 0x3a, 0x00, + 0x03, 0x20, 0x03, 0x41, 0x7c, 0x6a, 0x20, 0x01, 0x3a, 0x00, 0x00, 0x20, + 0x02, 0x41, 0x09, 0x49, 0x0d, 0x00, 0x20, 0x00, 0x41, 0x00, 0x20, 0x00, + 0x6b, 0x41, 0x03, 0x71, 0x22, 0x04, 0x6a, 0x22, 0x05, 0x20, 0x01, 0x41, + 0xff, 0x01, 0x71, 0x41, 0x81, 0x82, 0x84, 0x08, 0x6c, 0x22, 0x03, 0x36, + 0x02, 0x00, 0x20, 0x05, 0x20, 0x02, 0x20, 0x04, 0x6b, 0x41, 0x3c, 0x71, + 0x22, 0x01, 0x6a, 0x22, 0x02, 0x41, 0x7c, 0x6a, 0x20, 0x03, 0x36, 0x02, + 0x00, 0x20, 0x01, 0x41, 0x09, 0x49, 0x0d, 0x00, 0x20, 0x05, 0x20, 0x03, + 0x36, 0x02, 0x08, 0x20, 0x05, 0x20, 0x03, 0x36, 0x02, 0x04, 0x20, 0x02, + 0x41, 0x78, 0x6a, 0x20, 0x03, 0x36, 0x02, 0x00, 0x20, 0x02, 0x41, 0x74, + 0x6a, 0x20, 0x03, 0x36, 0x02, 0x00, 0x20, 0x01, 0x41, 0x19, 0x49, 0x0d, + 0x00, 0x20, 0x05, 0x20, 0x03, 0x36, 0x02, 0x18, 0x20, 0x05, 0x20, 0x03, + 0x36, 0x02, 0x14, 0x20, 0x05, 0x20, 0x03, 0x36, 0x02, 0x10, 0x20, 0x05, + 0x20, 0x03, 0x36, 0x02, 0x0c, 0x20, 0x02, 0x41, 0x70, 0x6a, 0x20, 0x03, + 0x36, 0x02, 0x00, 0x20, 0x02, 0x41, 0x6c, 0x6a, 0x20, 0x03, 0x36, 0x02, + 0x00, 0x20, 0x02, 0x41, 0x68, 0x6a, 0x20, 0x03, 0x36, 0x02, 0x00, 0x20, + 0x02, 0x41, 0x64, 0x6a, 0x20, 0x03, 0x36, 0x02, 0x00, 0x20, 0x01, 0x20, + 0x05, 0x41, 0x04, 0x71, 0x41, 0x18, 0x72, 0x22, 0x02, 0x6b, 0x22, 0x01, + 0x41, 0x20, 0x49, 0x0d, 0x00, 0x20, 0x03, 0xad, 0x42, 0x81, 0x80, 0x80, + 0x80, 0x10, 0x7e, 0x21, 0x06, 0x20, 0x05, 0x20, 0x02, 0x6a, 0x21, 0x02, + 0x03, 0x40, 0x20, 0x02, 0x20, 0x06, 0x37, 0x03, 0x18, 0x20, 0x02, 0x20, + 0x06, 0x37, 0x03, 0x10, 0x20, 0x02, 0x20, 0x06, 0x37, 0x03, 0x08, 0x20, + 0x02, 0x20, 0x06, 0x37, 0x03, 0x00, 0x20, 0x02, 0x41, 0x20, 0x6a, 0x21, + 0x02, 0x20, 0x01, 0x41, 0x60, 0x6a, 0x22, 0x01, 0x41, 0x1f, 0x4b, 0x0d, + 0x00, 0x0b, 0x0b, 0x20, 0x00, 0x0b, 0xcf, 0x01, 0x01, 0x03, 0x7f, 0x20, + 0x00, 0x21, 0x01, 0x02, 0x40, 0x02, 0x40, 0x20, 0x00, 0x41, 0x03, 0x71, + 0x45, 0x0d, 0x00, 0x02, 0x40, 0x20, 0x00, 0x2d, 0x00, 0x00, 0x0d, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x6b, 0x0f, 0x0b, 0x20, 0x00, 0x41, 0x01, 0x6a, + 0x22, 0x01, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x01, 0x2d, 0x00, + 0x00, 0x45, 0x0d, 0x01, 0x20, 0x00, 0x41, 0x02, 0x6a, 0x22, 0x01, 0x41, + 0x03, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x45, 0x0d, + 0x01, 0x20, 0x00, 0x41, 0x03, 0x6a, 0x22, 0x01, 0x41, 0x03, 0x71, 0x45, + 0x0d, 0x00, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x45, 0x0d, 0x01, 0x20, 0x00, + 0x41, 0x04, 0x6a, 0x22, 0x01, 0x41, 0x03, 0x71, 0x0d, 0x01, 0x0b, 0x20, + 0x01, 0x41, 0x7c, 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x7b, 0x6a, 0x21, + 0x01, 0x03, 0x40, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x21, 0x01, 0x41, 0x80, + 0x82, 0x84, 0x08, 0x20, 0x02, 0x41, 0x04, 0x6a, 0x22, 0x02, 0x28, 0x02, + 0x00, 0x22, 0x03, 0x6b, 0x20, 0x03, 0x72, 0x41, 0x80, 0x81, 0x82, 0x84, + 0x78, 0x71, 0x41, 0x80, 0x81, 0x82, 0x84, 0x78, 0x46, 0x0d, 0x00, 0x0b, + 0x03, 0x40, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x21, 0x01, 0x20, 0x02, 0x2d, + 0x00, 0x00, 0x21, 0x03, 0x20, 0x02, 0x41, 0x01, 0x6a, 0x21, 0x02, 0x20, + 0x03, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x01, 0x20, 0x00, 0x6b, 0x0b, 0x44, + 0x00, 0x02, 0x40, 0x20, 0x00, 0x41, 0xff, 0xff, 0x07, 0x4b, 0x0d, 0x00, + 0x20, 0x00, 0x41, 0x08, 0x76, 0x41, 0x80, 0x80, 0x84, 0x80, 0x00, 0x6a, + 0x2d, 0x00, 0x00, 0x41, 0x05, 0x74, 0x20, 0x00, 0x41, 0x03, 0x76, 0x41, + 0x1f, 0x71, 0x72, 0x41, 0x80, 0x80, 0x84, 0x80, 0x00, 0x6a, 0x2d, 0x00, + 0x00, 0x20, 0x00, 0x41, 0x07, 0x71, 0x76, 0x41, 0x01, 0x71, 0x0f, 0x0b, + 0x20, 0x00, 0x41, 0xfe, 0xff, 0x0b, 0x49, 0x0b, 0x49, 0x01, 0x03, 0x7f, + 0x41, 0x00, 0x21, 0x03, 0x02, 0x40, 0x20, 0x02, 0x45, 0x0d, 0x00, 0x02, + 0x40, 0x03, 0x40, 0x20, 0x00, 0x2d, 0x00, 0x00, 0x22, 0x04, 0x20, 0x01, + 0x2d, 0x00, 0x00, 0x22, 0x05, 0x47, 0x0d, 0x01, 0x20, 0x01, 0x41, 0x01, + 0x6a, 0x21, 0x01, 0x20, 0x00, 0x41, 0x01, 0x6a, 0x21, 0x00, 0x20, 0x02, + 0x41, 0x7f, 0x6a, 0x22, 0x02, 0x0d, 0x00, 0x0c, 0x02, 0x0b, 0x0b, 0x20, + 0x04, 0x20, 0x05, 0x6b, 0x21, 0x03, 0x0b, 0x20, 0x03, 0x0b, 0xf6, 0x02, + 0x01, 0x03, 0x7f, 0x20, 0x02, 0x41, 0x00, 0x47, 0x21, 0x03, 0x02, 0x40, + 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x20, 0x00, 0x41, 0x03, 0x71, 0x45, + 0x0d, 0x00, 0x20, 0x02, 0x45, 0x0d, 0x00, 0x02, 0x40, 0x20, 0x00, 0x2d, + 0x00, 0x00, 0x20, 0x01, 0x41, 0xff, 0x01, 0x71, 0x47, 0x0d, 0x00, 0x20, + 0x00, 0x21, 0x04, 0x20, 0x02, 0x21, 0x05, 0x0c, 0x03, 0x0b, 0x20, 0x02, + 0x41, 0x7f, 0x6a, 0x22, 0x05, 0x41, 0x00, 0x47, 0x21, 0x03, 0x20, 0x00, + 0x41, 0x01, 0x6a, 0x22, 0x04, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x01, 0x20, + 0x05, 0x45, 0x0d, 0x01, 0x20, 0x04, 0x2d, 0x00, 0x00, 0x20, 0x01, 0x41, + 0xff, 0x01, 0x71, 0x46, 0x0d, 0x02, 0x20, 0x02, 0x41, 0x7e, 0x6a, 0x22, + 0x05, 0x41, 0x00, 0x47, 0x21, 0x03, 0x20, 0x00, 0x41, 0x02, 0x6a, 0x22, + 0x04, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x01, 0x20, 0x05, 0x45, 0x0d, 0x01, + 0x20, 0x04, 0x2d, 0x00, 0x00, 0x20, 0x01, 0x41, 0xff, 0x01, 0x71, 0x46, + 0x0d, 0x02, 0x20, 0x02, 0x41, 0x7d, 0x6a, 0x22, 0x05, 0x41, 0x00, 0x47, + 0x21, 0x03, 0x20, 0x00, 0x41, 0x03, 0x6a, 0x22, 0x04, 0x41, 0x03, 0x71, + 0x45, 0x0d, 0x01, 0x20, 0x05, 0x45, 0x0d, 0x01, 0x20, 0x04, 0x2d, 0x00, + 0x00, 0x20, 0x01, 0x41, 0xff, 0x01, 0x71, 0x46, 0x0d, 0x02, 0x20, 0x00, + 0x41, 0x04, 0x6a, 0x21, 0x04, 0x20, 0x02, 0x41, 0x7c, 0x6a, 0x22, 0x05, + 0x41, 0x00, 0x47, 0x21, 0x03, 0x0c, 0x01, 0x0b, 0x20, 0x02, 0x21, 0x05, + 0x20, 0x00, 0x21, 0x04, 0x0b, 0x20, 0x03, 0x45, 0x0d, 0x01, 0x02, 0x40, + 0x20, 0x04, 0x2d, 0x00, 0x00, 0x20, 0x01, 0x41, 0xff, 0x01, 0x71, 0x46, + 0x0d, 0x00, 0x20, 0x05, 0x41, 0x04, 0x49, 0x0d, 0x00, 0x20, 0x01, 0x41, + 0xff, 0x01, 0x71, 0x41, 0x81, 0x82, 0x84, 0x08, 0x6c, 0x21, 0x00, 0x03, + 0x40, 0x41, 0x80, 0x82, 0x84, 0x08, 0x20, 0x04, 0x28, 0x02, 0x00, 0x20, + 0x00, 0x73, 0x22, 0x02, 0x6b, 0x20, 0x02, 0x72, 0x41, 0x80, 0x81, 0x82, + 0x84, 0x78, 0x71, 0x41, 0x80, 0x81, 0x82, 0x84, 0x78, 0x47, 0x0d, 0x02, + 0x20, 0x04, 0x41, 0x04, 0x6a, 0x21, 0x04, 0x20, 0x05, 0x41, 0x7c, 0x6a, + 0x22, 0x05, 0x41, 0x03, 0x4b, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x05, 0x45, + 0x0d, 0x01, 0x0b, 0x20, 0x01, 0x41, 0xff, 0x01, 0x71, 0x21, 0x02, 0x03, + 0x40, 0x02, 0x40, 0x20, 0x04, 0x2d, 0x00, 0x00, 0x20, 0x02, 0x47, 0x0d, + 0x00, 0x20, 0x04, 0x0f, 0x0b, 0x20, 0x04, 0x41, 0x01, 0x6a, 0x21, 0x04, + 0x20, 0x05, 0x41, 0x7f, 0x6a, 0x22, 0x05, 0x0d, 0x00, 0x0b, 0x0b, 0x41, + 0x00, 0x0b, 0x67, 0x01, 0x02, 0x7f, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x21, + 0x02, 0x02, 0x40, 0x20, 0x00, 0x2d, 0x00, 0x00, 0x22, 0x03, 0x45, 0x0d, + 0x00, 0x20, 0x03, 0x20, 0x02, 0x41, 0xff, 0x01, 0x71, 0x47, 0x0d, 0x00, + 0x20, 0x00, 0x41, 0x01, 0x6a, 0x21, 0x00, 0x20, 0x01, 0x41, 0x01, 0x6a, + 0x21, 0x01, 0x03, 0x40, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x21, 0x02, 0x20, + 0x00, 0x2d, 0x00, 0x00, 0x22, 0x03, 0x45, 0x0d, 0x01, 0x20, 0x00, 0x41, + 0x01, 0x6a, 0x21, 0x00, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x21, 0x01, 0x20, + 0x03, 0x20, 0x02, 0x41, 0xff, 0x01, 0x71, 0x46, 0x0d, 0x00, 0x0b, 0x0b, + 0x20, 0x03, 0x20, 0x02, 0x41, 0xff, 0x01, 0x71, 0x6b, 0x0b, 0x0c, 0x00, + 0x20, 0x00, 0x41, 0x00, 0x10, 0x9b, 0x80, 0x80, 0x80, 0x00, 0x0b, 0xb4, + 0x02, 0x01, 0x07, 0x7f, 0x02, 0x40, 0x20, 0x00, 0x41, 0xff, 0xff, 0x07, + 0x4b, 0x0d, 0x00, 0x20, 0x00, 0x20, 0x00, 0x41, 0xff, 0x01, 0x71, 0x22, + 0x02, 0x41, 0x03, 0x6e, 0x22, 0x03, 0x41, 0x03, 0x6c, 0x6b, 0x41, 0xff, + 0x01, 0x71, 0x41, 0x02, 0x74, 0x41, 0xc0, 0x9e, 0x84, 0x80, 0x00, 0x6a, + 0x28, 0x02, 0x00, 0x20, 0x00, 0x41, 0x08, 0x76, 0x22, 0x04, 0x41, 0xa0, + 0xa9, 0x84, 0x80, 0x00, 0x6a, 0x2d, 0x00, 0x00, 0x41, 0xd6, 0x00, 0x6c, + 0x20, 0x03, 0x6a, 0x41, 0xa0, 0xa9, 0x84, 0x80, 0x00, 0x6a, 0x2d, 0x00, + 0x00, 0x6c, 0x41, 0x0b, 0x76, 0x41, 0x06, 0x70, 0x20, 0x04, 0x41, 0x90, + 0xbe, 0x84, 0x80, 0x00, 0x6a, 0x2d, 0x00, 0x00, 0x6a, 0x41, 0x02, 0x74, + 0x41, 0xd0, 0x9e, 0x84, 0x80, 0x00, 0x6a, 0x28, 0x02, 0x00, 0x22, 0x03, + 0x41, 0x08, 0x75, 0x21, 0x04, 0x02, 0x40, 0x20, 0x03, 0x41, 0xff, 0x01, + 0x71, 0x22, 0x03, 0x41, 0x01, 0x4b, 0x0d, 0x00, 0x20, 0x04, 0x41, 0x00, + 0x20, 0x03, 0x20, 0x01, 0x73, 0x6b, 0x71, 0x20, 0x00, 0x6a, 0x0f, 0x0b, + 0x20, 0x04, 0x41, 0xff, 0x01, 0x71, 0x22, 0x03, 0x45, 0x0d, 0x00, 0x20, + 0x04, 0x41, 0x08, 0x76, 0x21, 0x04, 0x03, 0x40, 0x02, 0x40, 0x20, 0x02, + 0x20, 0x03, 0x41, 0x01, 0x76, 0x22, 0x05, 0x20, 0x04, 0x6a, 0x22, 0x06, + 0x41, 0x01, 0x74, 0x41, 0x90, 0xa6, 0x84, 0x80, 0x00, 0x6a, 0x22, 0x07, + 0x2d, 0x00, 0x00, 0x22, 0x08, 0x47, 0x0d, 0x00, 0x02, 0x40, 0x20, 0x07, + 0x2d, 0x00, 0x01, 0x41, 0x02, 0x74, 0x41, 0xd0, 0x9e, 0x84, 0x80, 0x00, + 0x6a, 0x28, 0x02, 0x00, 0x22, 0x03, 0x41, 0xff, 0x01, 0x71, 0x22, 0x04, + 0x41, 0x01, 0x4b, 0x0d, 0x00, 0x20, 0x03, 0x41, 0x08, 0x75, 0x41, 0x00, + 0x20, 0x04, 0x20, 0x01, 0x73, 0x6b, 0x71, 0x20, 0x00, 0x6a, 0x0f, 0x0b, + 0x41, 0x7f, 0x41, 0x01, 0x20, 0x01, 0x1b, 0x20, 0x00, 0x6a, 0x0f, 0x0b, + 0x20, 0x04, 0x20, 0x06, 0x20, 0x02, 0x20, 0x08, 0x49, 0x22, 0x08, 0x1b, + 0x21, 0x04, 0x20, 0x05, 0x20, 0x03, 0x20, 0x05, 0x6b, 0x20, 0x08, 0x1b, + 0x22, 0x03, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x00, 0x0b, 0x0c, 0x00, 0x20, + 0x00, 0x41, 0x01, 0x10, 0x9b, 0x80, 0x80, 0x80, 0x00, 0x0b, 0x87, 0x01, + 0x01, 0x02, 0x7f, 0x02, 0x40, 0x20, 0x02, 0x0d, 0x00, 0x41, 0x00, 0x0f, + 0x0b, 0x02, 0x40, 0x02, 0x40, 0x20, 0x00, 0x2d, 0x00, 0x00, 0x22, 0x03, + 0x0d, 0x00, 0x41, 0x00, 0x21, 0x03, 0x0c, 0x01, 0x0b, 0x20, 0x00, 0x41, + 0x01, 0x6a, 0x21, 0x00, 0x20, 0x02, 0x41, 0x7f, 0x6a, 0x21, 0x02, 0x02, + 0x40, 0x03, 0x40, 0x20, 0x03, 0x41, 0xff, 0x01, 0x71, 0x20, 0x01, 0x2d, + 0x00, 0x00, 0x22, 0x04, 0x47, 0x0d, 0x01, 0x20, 0x04, 0x45, 0x0d, 0x01, + 0x20, 0x02, 0x41, 0x00, 0x46, 0x0d, 0x01, 0x20, 0x02, 0x41, 0x7f, 0x6a, + 0x21, 0x02, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x21, 0x01, 0x20, 0x00, 0x2d, + 0x00, 0x00, 0x21, 0x03, 0x20, 0x00, 0x41, 0x01, 0x6a, 0x21, 0x00, 0x20, + 0x03, 0x0d, 0x00, 0x0b, 0x41, 0x00, 0x21, 0x03, 0x0b, 0x20, 0x03, 0x41, + 0xff, 0x01, 0x71, 0x21, 0x03, 0x0b, 0x20, 0x03, 0x20, 0x01, 0x2d, 0x00, + 0x00, 0x6b, 0x0b, 0x0d, 0x00, 0x20, 0x00, 0x10, 0x9a, 0x80, 0x80, 0x80, + 0x00, 0x20, 0x00, 0x47, 0x0b, 0xbf, 0x09, 0x01, 0x04, 0x7f, 0x02, 0x40, + 0x02, 0x40, 0x02, 0x40, 0x20, 0x02, 0x41, 0x21, 0x4f, 0x0d, 0x00, 0x20, + 0x00, 0x20, 0x01, 0x46, 0x0d, 0x02, 0x20, 0x01, 0x20, 0x00, 0x20, 0x02, + 0x6a, 0x22, 0x03, 0x6b, 0x41, 0x00, 0x20, 0x02, 0x41, 0x01, 0x74, 0x6b, + 0x4b, 0x0d, 0x01, 0x0b, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0xfc, 0x0a, + 0x00, 0x00, 0x0c, 0x01, 0x0b, 0x20, 0x01, 0x20, 0x00, 0x73, 0x41, 0x03, + 0x71, 0x21, 0x04, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x20, 0x00, 0x20, + 0x01, 0x4f, 0x0d, 0x00, 0x02, 0x40, 0x20, 0x04, 0x45, 0x0d, 0x00, 0x20, + 0x02, 0x21, 0x05, 0x20, 0x00, 0x21, 0x03, 0x0c, 0x03, 0x0b, 0x02, 0x40, + 0x20, 0x00, 0x41, 0x03, 0x71, 0x0d, 0x00, 0x20, 0x02, 0x21, 0x05, 0x20, + 0x00, 0x21, 0x03, 0x0c, 0x02, 0x0b, 0x20, 0x02, 0x45, 0x0d, 0x03, 0x20, + 0x00, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x20, 0x02, 0x41, + 0x7f, 0x6a, 0x21, 0x05, 0x02, 0x40, 0x20, 0x00, 0x41, 0x01, 0x6a, 0x22, + 0x03, 0x41, 0x03, 0x71, 0x0d, 0x00, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x21, + 0x01, 0x0c, 0x02, 0x0b, 0x20, 0x05, 0x45, 0x0d, 0x03, 0x20, 0x00, 0x20, + 0x01, 0x2d, 0x00, 0x01, 0x3a, 0x00, 0x01, 0x20, 0x02, 0x41, 0x7e, 0x6a, + 0x21, 0x05, 0x02, 0x40, 0x20, 0x00, 0x41, 0x02, 0x6a, 0x22, 0x03, 0x41, + 0x03, 0x71, 0x0d, 0x00, 0x20, 0x01, 0x41, 0x02, 0x6a, 0x21, 0x01, 0x0c, + 0x02, 0x0b, 0x20, 0x05, 0x45, 0x0d, 0x03, 0x20, 0x00, 0x20, 0x01, 0x2d, + 0x00, 0x02, 0x3a, 0x00, 0x02, 0x20, 0x02, 0x41, 0x7d, 0x6a, 0x21, 0x05, + 0x02, 0x40, 0x20, 0x00, 0x41, 0x03, 0x6a, 0x22, 0x03, 0x41, 0x03, 0x71, + 0x0d, 0x00, 0x20, 0x01, 0x41, 0x03, 0x6a, 0x21, 0x01, 0x0c, 0x02, 0x0b, + 0x20, 0x05, 0x45, 0x0d, 0x03, 0x20, 0x00, 0x20, 0x01, 0x2d, 0x00, 0x03, + 0x3a, 0x00, 0x03, 0x20, 0x00, 0x41, 0x04, 0x6a, 0x21, 0x03, 0x20, 0x01, + 0x41, 0x04, 0x6a, 0x21, 0x01, 0x20, 0x02, 0x41, 0x7c, 0x6a, 0x21, 0x05, + 0x0c, 0x01, 0x0b, 0x02, 0x40, 0x20, 0x04, 0x0d, 0x00, 0x02, 0x40, 0x20, + 0x03, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x02, 0x45, 0x0d, 0x04, + 0x20, 0x00, 0x20, 0x02, 0x41, 0x7f, 0x6a, 0x22, 0x03, 0x6a, 0x22, 0x04, + 0x20, 0x01, 0x20, 0x03, 0x6a, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x02, + 0x40, 0x20, 0x04, 0x41, 0x03, 0x71, 0x0d, 0x00, 0x20, 0x03, 0x21, 0x02, + 0x0c, 0x01, 0x0b, 0x20, 0x03, 0x45, 0x0d, 0x04, 0x20, 0x00, 0x20, 0x02, + 0x41, 0x7e, 0x6a, 0x22, 0x03, 0x6a, 0x22, 0x04, 0x20, 0x01, 0x20, 0x03, + 0x6a, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x02, 0x40, 0x20, 0x04, 0x41, + 0x03, 0x71, 0x0d, 0x00, 0x20, 0x03, 0x21, 0x02, 0x0c, 0x01, 0x0b, 0x20, + 0x03, 0x45, 0x0d, 0x04, 0x20, 0x00, 0x20, 0x02, 0x41, 0x7d, 0x6a, 0x22, + 0x03, 0x6a, 0x22, 0x04, 0x20, 0x01, 0x20, 0x03, 0x6a, 0x2d, 0x00, 0x00, + 0x3a, 0x00, 0x00, 0x02, 0x40, 0x20, 0x04, 0x41, 0x03, 0x71, 0x0d, 0x00, + 0x20, 0x03, 0x21, 0x02, 0x0c, 0x01, 0x0b, 0x20, 0x03, 0x45, 0x0d, 0x04, + 0x20, 0x00, 0x20, 0x02, 0x41, 0x7c, 0x6a, 0x22, 0x02, 0x6a, 0x20, 0x01, + 0x20, 0x02, 0x6a, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x0b, 0x20, 0x02, + 0x41, 0x04, 0x49, 0x0d, 0x00, 0x02, 0x40, 0x20, 0x02, 0x41, 0x7c, 0x6a, + 0x22, 0x06, 0x41, 0x02, 0x76, 0x41, 0x01, 0x6a, 0x41, 0x03, 0x71, 0x22, + 0x03, 0x45, 0x0d, 0x00, 0x20, 0x01, 0x41, 0x7c, 0x6a, 0x21, 0x04, 0x20, + 0x00, 0x41, 0x7c, 0x6a, 0x21, 0x05, 0x03, 0x40, 0x20, 0x05, 0x20, 0x02, + 0x6a, 0x20, 0x04, 0x20, 0x02, 0x6a, 0x28, 0x02, 0x00, 0x36, 0x02, 0x00, + 0x20, 0x02, 0x41, 0x7c, 0x6a, 0x21, 0x02, 0x20, 0x03, 0x41, 0x7f, 0x6a, + 0x22, 0x03, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x06, 0x41, 0x0c, 0x49, 0x0d, + 0x00, 0x20, 0x01, 0x41, 0x70, 0x6a, 0x21, 0x05, 0x20, 0x00, 0x41, 0x70, + 0x6a, 0x21, 0x06, 0x03, 0x40, 0x20, 0x06, 0x20, 0x02, 0x6a, 0x22, 0x03, + 0x41, 0x0c, 0x6a, 0x20, 0x05, 0x20, 0x02, 0x6a, 0x22, 0x04, 0x41, 0x0c, + 0x6a, 0x28, 0x02, 0x00, 0x36, 0x02, 0x00, 0x20, 0x03, 0x41, 0x08, 0x6a, + 0x20, 0x04, 0x41, 0x08, 0x6a, 0x28, 0x02, 0x00, 0x36, 0x02, 0x00, 0x20, + 0x03, 0x41, 0x04, 0x6a, 0x20, 0x04, 0x41, 0x04, 0x6a, 0x28, 0x02, 0x00, + 0x36, 0x02, 0x00, 0x20, 0x03, 0x20, 0x04, 0x28, 0x02, 0x00, 0x36, 0x02, + 0x00, 0x20, 0x02, 0x41, 0x70, 0x6a, 0x22, 0x02, 0x41, 0x03, 0x4b, 0x0d, + 0x00, 0x0b, 0x0b, 0x20, 0x02, 0x45, 0x0d, 0x02, 0x20, 0x02, 0x21, 0x03, + 0x02, 0x40, 0x20, 0x02, 0x41, 0x03, 0x71, 0x22, 0x04, 0x45, 0x0d, 0x00, + 0x20, 0x01, 0x41, 0x7f, 0x6a, 0x21, 0x05, 0x20, 0x00, 0x41, 0x7f, 0x6a, + 0x21, 0x06, 0x20, 0x02, 0x21, 0x03, 0x03, 0x40, 0x20, 0x06, 0x20, 0x03, + 0x6a, 0x20, 0x05, 0x20, 0x03, 0x6a, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, + 0x20, 0x03, 0x41, 0x7f, 0x6a, 0x21, 0x03, 0x20, 0x04, 0x41, 0x7f, 0x6a, + 0x22, 0x04, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x02, 0x41, 0x04, 0x49, 0x0d, + 0x02, 0x20, 0x01, 0x41, 0x7c, 0x6a, 0x21, 0x04, 0x20, 0x00, 0x41, 0x7c, + 0x6a, 0x21, 0x05, 0x03, 0x40, 0x20, 0x05, 0x20, 0x03, 0x6a, 0x22, 0x01, + 0x41, 0x03, 0x6a, 0x20, 0x04, 0x20, 0x03, 0x6a, 0x22, 0x02, 0x41, 0x03, + 0x6a, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x20, 0x01, 0x41, 0x02, 0x6a, + 0x20, 0x02, 0x41, 0x02, 0x6a, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x20, + 0x01, 0x41, 0x01, 0x6a, 0x20, 0x02, 0x41, 0x01, 0x6a, 0x2d, 0x00, 0x00, + 0x3a, 0x00, 0x00, 0x20, 0x01, 0x20, 0x02, 0x2d, 0x00, 0x00, 0x3a, 0x00, + 0x00, 0x20, 0x03, 0x41, 0x7c, 0x6a, 0x22, 0x03, 0x0d, 0x00, 0x0c, 0x03, + 0x0b, 0x0b, 0x20, 0x05, 0x41, 0x04, 0x49, 0x0d, 0x00, 0x02, 0x40, 0x20, + 0x05, 0x41, 0x7c, 0x6a, 0x22, 0x04, 0x41, 0x02, 0x76, 0x41, 0x01, 0x6a, + 0x41, 0x07, 0x71, 0x22, 0x02, 0x45, 0x0d, 0x00, 0x20, 0x05, 0x20, 0x02, + 0x41, 0x02, 0x74, 0x6b, 0x21, 0x05, 0x03, 0x40, 0x20, 0x03, 0x20, 0x01, + 0x28, 0x02, 0x00, 0x36, 0x02, 0x00, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x21, + 0x01, 0x20, 0x03, 0x41, 0x04, 0x6a, 0x21, 0x03, 0x20, 0x02, 0x41, 0x7f, + 0x6a, 0x22, 0x02, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x04, 0x41, 0x1c, 0x49, + 0x0d, 0x00, 0x03, 0x40, 0x20, 0x03, 0x20, 0x01, 0x28, 0x02, 0x00, 0x36, + 0x02, 0x00, 0x20, 0x03, 0x20, 0x01, 0x28, 0x02, 0x04, 0x36, 0x02, 0x04, + 0x20, 0x03, 0x20, 0x01, 0x28, 0x02, 0x08, 0x36, 0x02, 0x08, 0x20, 0x03, + 0x20, 0x01, 0x28, 0x02, 0x0c, 0x36, 0x02, 0x0c, 0x20, 0x03, 0x20, 0x01, + 0x28, 0x02, 0x10, 0x36, 0x02, 0x10, 0x20, 0x03, 0x20, 0x01, 0x28, 0x02, + 0x14, 0x36, 0x02, 0x14, 0x20, 0x03, 0x20, 0x01, 0x28, 0x02, 0x18, 0x36, + 0x02, 0x18, 0x20, 0x03, 0x20, 0x01, 0x28, 0x02, 0x1c, 0x36, 0x02, 0x1c, + 0x20, 0x01, 0x41, 0x20, 0x6a, 0x21, 0x01, 0x20, 0x03, 0x41, 0x20, 0x6a, + 0x21, 0x03, 0x20, 0x05, 0x41, 0x60, 0x6a, 0x22, 0x05, 0x41, 0x03, 0x4b, + 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x05, 0x45, 0x0d, 0x00, 0x02, 0x40, 0x02, + 0x40, 0x20, 0x05, 0x41, 0x07, 0x71, 0x22, 0x02, 0x0d, 0x00, 0x20, 0x05, + 0x21, 0x04, 0x0c, 0x01, 0x0b, 0x20, 0x05, 0x41, 0x78, 0x71, 0x21, 0x04, + 0x03, 0x40, 0x20, 0x03, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, + 0x20, 0x03, 0x41, 0x01, 0x6a, 0x21, 0x03, 0x20, 0x01, 0x41, 0x01, 0x6a, + 0x21, 0x01, 0x20, 0x02, 0x41, 0x7f, 0x6a, 0x22, 0x02, 0x0d, 0x00, 0x0b, + 0x0b, 0x20, 0x05, 0x41, 0x08, 0x49, 0x0d, 0x00, 0x03, 0x40, 0x20, 0x03, + 0x20, 0x01, 0x2d, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x20, 0x03, 0x20, 0x01, + 0x2d, 0x00, 0x01, 0x3a, 0x00, 0x01, 0x20, 0x03, 0x20, 0x01, 0x2d, 0x00, + 0x02, 0x3a, 0x00, 0x02, 0x20, 0x03, 0x20, 0x01, 0x2d, 0x00, 0x03, 0x3a, + 0x00, 0x03, 0x20, 0x03, 0x20, 0x01, 0x2d, 0x00, 0x04, 0x3a, 0x00, 0x04, + 0x20, 0x03, 0x20, 0x01, 0x2d, 0x00, 0x05, 0x3a, 0x00, 0x05, 0x20, 0x03, + 0x20, 0x01, 0x2d, 0x00, 0x06, 0x3a, 0x00, 0x06, 0x20, 0x03, 0x20, 0x01, + 0x2d, 0x00, 0x07, 0x3a, 0x00, 0x07, 0x20, 0x03, 0x41, 0x08, 0x6a, 0x21, + 0x03, 0x20, 0x01, 0x41, 0x08, 0x6a, 0x21, 0x01, 0x20, 0x04, 0x41, 0x78, + 0x6a, 0x22, 0x04, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x00, 0x0b, 0x0d, 0x00, + 0x20, 0x00, 0x10, 0x9c, 0x80, 0x80, 0x80, 0x00, 0x20, 0x00, 0x47, 0x0b, + 0x0d, 0x00, 0x20, 0x00, 0x41, 0x20, 0x46, 0x20, 0x00, 0x41, 0x09, 0x46, + 0x72, 0x0b, 0x0a, 0x00, 0x20, 0x00, 0x10, 0xa1, 0x80, 0x80, 0x80, 0x00, + 0x0b, 0x0a, 0x00, 0x20, 0x00, 0x41, 0x50, 0x6a, 0x41, 0x0a, 0x49, 0x0b, + 0x4d, 0x01, 0x02, 0x7f, 0x20, 0x00, 0x20, 0x00, 0x10, 0x95, 0x80, 0x80, + 0x80, 0x00, 0x6a, 0x21, 0x03, 0x02, 0x40, 0x20, 0x02, 0x45, 0x0d, 0x00, + 0x03, 0x40, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x22, 0x04, 0x45, 0x0d, 0x01, + 0x20, 0x03, 0x20, 0x04, 0x3a, 0x00, 0x00, 0x20, 0x03, 0x41, 0x01, 0x6a, + 0x21, 0x03, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x21, 0x01, 0x20, 0x02, 0x41, + 0x7f, 0x6a, 0x22, 0x02, 0x0d, 0x00, 0x0b, 0x0b, 0x20, 0x03, 0x41, 0x00, + 0x3a, 0x00, 0x00, 0x20, 0x00, 0x0b, 0xf3, 0x03, 0x01, 0x04, 0x7f, 0x02, + 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x20, 0x01, 0x20, + 0x00, 0x73, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x00, 0x21, 0x03, + 0x0c, 0x01, 0x0b, 0x20, 0x02, 0x41, 0x00, 0x47, 0x21, 0x04, 0x02, 0x40, + 0x02, 0x40, 0x20, 0x01, 0x41, 0x03, 0x71, 0x0d, 0x00, 0x20, 0x00, 0x21, + 0x03, 0x0c, 0x01, 0x0b, 0x02, 0x40, 0x20, 0x02, 0x0d, 0x00, 0x20, 0x00, + 0x21, 0x03, 0x0c, 0x01, 0x0b, 0x20, 0x00, 0x20, 0x01, 0x2d, 0x00, 0x00, + 0x22, 0x03, 0x3a, 0x00, 0x00, 0x02, 0x40, 0x20, 0x03, 0x0d, 0x00, 0x20, + 0x00, 0x21, 0x03, 0x20, 0x02, 0x21, 0x05, 0x0c, 0x05, 0x0b, 0x20, 0x00, + 0x41, 0x01, 0x6a, 0x21, 0x03, 0x20, 0x02, 0x41, 0x7f, 0x6a, 0x22, 0x05, + 0x41, 0x00, 0x47, 0x21, 0x04, 0x02, 0x40, 0x20, 0x01, 0x41, 0x01, 0x6a, + 0x22, 0x06, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x00, 0x20, 0x05, 0x45, 0x0d, + 0x00, 0x20, 0x03, 0x20, 0x06, 0x2d, 0x00, 0x00, 0x22, 0x04, 0x3a, 0x00, + 0x00, 0x20, 0x04, 0x45, 0x0d, 0x05, 0x20, 0x00, 0x41, 0x02, 0x6a, 0x21, + 0x03, 0x20, 0x02, 0x41, 0x7e, 0x6a, 0x22, 0x05, 0x41, 0x00, 0x47, 0x21, + 0x04, 0x02, 0x40, 0x20, 0x01, 0x41, 0x02, 0x6a, 0x22, 0x06, 0x41, 0x03, + 0x71, 0x45, 0x0d, 0x00, 0x20, 0x05, 0x45, 0x0d, 0x00, 0x20, 0x03, 0x20, + 0x06, 0x2d, 0x00, 0x00, 0x22, 0x04, 0x3a, 0x00, 0x00, 0x20, 0x04, 0x45, + 0x0d, 0x06, 0x20, 0x00, 0x41, 0x03, 0x6a, 0x21, 0x03, 0x20, 0x02, 0x41, + 0x7d, 0x6a, 0x22, 0x05, 0x41, 0x00, 0x47, 0x21, 0x04, 0x02, 0x40, 0x20, + 0x01, 0x41, 0x03, 0x6a, 0x22, 0x06, 0x41, 0x03, 0x71, 0x45, 0x0d, 0x00, + 0x20, 0x05, 0x45, 0x0d, 0x00, 0x20, 0x03, 0x20, 0x06, 0x2d, 0x00, 0x00, + 0x22, 0x04, 0x3a, 0x00, 0x00, 0x20, 0x04, 0x45, 0x0d, 0x07, 0x20, 0x00, + 0x41, 0x04, 0x6a, 0x21, 0x03, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x21, 0x01, + 0x20, 0x02, 0x41, 0x7c, 0x6a, 0x22, 0x02, 0x41, 0x00, 0x47, 0x21, 0x04, + 0x0c, 0x03, 0x0b, 0x20, 0x06, 0x21, 0x01, 0x20, 0x05, 0x21, 0x02, 0x0c, + 0x02, 0x0b, 0x20, 0x06, 0x21, 0x01, 0x20, 0x05, 0x21, 0x02, 0x0c, 0x01, + 0x0b, 0x20, 0x06, 0x21, 0x01, 0x20, 0x05, 0x21, 0x02, 0x0b, 0x20, 0x04, + 0x45, 0x0d, 0x02, 0x02, 0x40, 0x20, 0x01, 0x2d, 0x00, 0x00, 0x0d, 0x00, + 0x20, 0x02, 0x21, 0x05, 0x0c, 0x04, 0x0b, 0x20, 0x02, 0x41, 0x04, 0x49, + 0x0d, 0x00, 0x03, 0x40, 0x41, 0x80, 0x82, 0x84, 0x08, 0x20, 0x01, 0x28, + 0x02, 0x00, 0x22, 0x00, 0x6b, 0x20, 0x00, 0x72, 0x41, 0x80, 0x81, 0x82, + 0x84, 0x78, 0x71, 0x41, 0x80, 0x81, 0x82, 0x84, 0x78, 0x47, 0x0d, 0x02, + 0x20, 0x03, 0x20, 0x00, 0x36, 0x02, 0x00, 0x20, 0x03, 0x41, 0x04, 0x6a, + 0x21, 0x03, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x21, 0x01, 0x20, 0x02, 0x41, + 0x7c, 0x6a, 0x22, 0x02, 0x41, 0x03, 0x4b, 0x0d, 0x00, 0x0b, 0x0b, 0x20, + 0x02, 0x45, 0x0d, 0x01, 0x0b, 0x03, 0x40, 0x20, 0x03, 0x20, 0x01, 0x2d, + 0x00, 0x00, 0x22, 0x00, 0x3a, 0x00, 0x00, 0x02, 0x40, 0x20, 0x00, 0x0d, + 0x00, 0x20, 0x02, 0x21, 0x05, 0x0c, 0x03, 0x0b, 0x20, 0x03, 0x41, 0x01, + 0x6a, 0x21, 0x03, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x21, 0x01, 0x20, 0x02, + 0x41, 0x7f, 0x6a, 0x22, 0x02, 0x0d, 0x00, 0x0b, 0x0b, 0x41, 0x00, 0x21, + 0x05, 0x0b, 0x20, 0x03, 0x41, 0x00, 0x20, 0x05, 0x10, 0x94, 0x80, 0x80, + 0x80, 0x00, 0x0b, 0x11, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, + 0xa5, 0x80, 0x80, 0x80, 0x00, 0x1a, 0x20, 0x00, 0x0b, 0x17, 0x00, 0x20, + 0x00, 0x41, 0x50, 0x6a, 0x41, 0x0a, 0x49, 0x20, 0x00, 0x41, 0x20, 0x72, + 0x41, 0x9f, 0x7f, 0x6a, 0x41, 0x06, 0x49, 0x72, 0x0b, 0x2a, 0x01, 0x03, + 0x7f, 0x41, 0x00, 0x21, 0x01, 0x03, 0x40, 0x20, 0x00, 0x20, 0x01, 0x6a, + 0x21, 0x02, 0x20, 0x01, 0x41, 0x04, 0x6a, 0x22, 0x03, 0x21, 0x01, 0x20, + 0x02, 0x28, 0x02, 0x00, 0x0d, 0x00, 0x0b, 0x20, 0x03, 0x41, 0x7c, 0x6a, + 0x41, 0x02, 0x75, 0x0b, 0x45, 0x01, 0x01, 0x7f, 0x02, 0x40, 0x20, 0x01, + 0x45, 0x0d, 0x00, 0x20, 0x00, 0x41, 0x7c, 0x6a, 0x21, 0x00, 0x02, 0x40, + 0x03, 0x40, 0x20, 0x00, 0x41, 0x04, 0x6a, 0x22, 0x00, 0x28, 0x02, 0x00, + 0x22, 0x02, 0x45, 0x0d, 0x01, 0x20, 0x02, 0x20, 0x01, 0x47, 0x0d, 0x00, + 0x0b, 0x0b, 0x20, 0x00, 0x41, 0x00, 0x20, 0x02, 0x1b, 0x0f, 0x0b, 0x20, + 0x00, 0x20, 0x00, 0x10, 0xa8, 0x80, 0x80, 0x80, 0x00, 0x41, 0x02, 0x74, + 0x6a, 0x0b, 0x1d, 0x00, 0x02, 0x40, 0x20, 0x00, 0x0d, 0x00, 0x41, 0x00, + 0x0f, 0x0b, 0x41, 0x90, 0xc2, 0x84, 0x80, 0x00, 0x20, 0x00, 0x10, 0xa9, + 0x80, 0x80, 0x80, 0x00, 0x41, 0x00, 0x47, 0x0b, 0x24, 0x01, 0x01, 0x7f, + 0x41, 0x01, 0x21, 0x01, 0x02, 0x40, 0x20, 0x00, 0x41, 0x50, 0x6a, 0x41, + 0x0a, 0x49, 0x0d, 0x00, 0x20, 0x00, 0x10, 0x96, 0x80, 0x80, 0x80, 0x00, + 0x41, 0x00, 0x47, 0x21, 0x01, 0x0b, 0x20, 0x01, 0x0b, 0x0b, 0xf1, 0x42, + 0x01, 0x00, 0x41, 0x80, 0x80, 0x04, 0x0b, 0xe8, 0x42, 0x12, 0x11, 0x13, + 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x11, 0x22, 0x23, 0x24, 0x11, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x2a, 0x2b, 0x2c, 0x11, 0x2d, 0x2e, 0x2f, 0x10, 0x10, 0x30, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x31, 0x32, 0x33, 0x10, 0x34, 0x35, 0x10, + 0x10, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x36, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x37, 0x11, 0x11, 0x11, 0x11, 0x38, 0x11, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, + 0x3e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x3f, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x11, 0x40, 0x41, 0x11, 0x42, 0x43, + 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x11, 0x4b, 0x4c, 0x4d, 0x4e, + 0x4f, 0x50, 0x51, 0x10, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, + 0x5a, 0x5b, 0x5c, 0x5d, 0x10, 0x5e, 0x5f, 0x60, 0x10, 0x11, 0x11, 0x11, + 0x61, 0x62, 0x63, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x11, 0x11, 0x11, 0x11, 0x64, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x11, 0x11, 0x65, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x11, 0x11, 0x66, + 0x67, 0x10, 0x10, 0x68, 0x69, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x6a, 0x11, 0x11, 0x6b, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x11, 0x6c, 0x6d, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x6e, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x6f, 0x70, 0x71, + 0x72, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x73, 0x74, 0x75, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x76, 0x77, 0x10, 0x10, 0x10, 0x10, 0x78, + 0x10, 0x10, 0x79, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0x07, 0xfe, 0xff, 0xff, + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x20, 0x04, 0xff, 0xff, 0x7f, + 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0x03, 0x00, 0x1f, 0x50, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xdf, 0xbc, 0x40, 0xd7, 0xff, 0xff, 0xfb, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x03, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0xff, 0xff, 0xff, 0x7f, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, + 0x00, 0x00, 0x00, 0xff, 0xbf, 0xb6, 0x00, 0xff, 0xff, 0xff, 0x87, 0x07, + 0x00, 0x00, 0x00, 0xff, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xfe, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xef, 0x1f, 0xfe, 0xe1, 0xff, 0x9f, 0x00, 0x00, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x07, 0x30, 0x04, 0xff, 0xff, 0xff, 0xfc, 0xff, 0x1f, 0x00, + 0x00, 0xff, 0xff, 0xff, 0x01, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xdf, 0x3f, 0x00, 0x00, 0xf0, 0xff, 0xf8, 0x03, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xef, 0xff, 0xdf, 0xe1, + 0xff, 0xcf, 0xff, 0xfe, 0xff, 0xef, 0x9f, 0xf9, 0xff, 0xff, 0xfd, 0xc5, + 0xe3, 0x9f, 0x59, 0x80, 0xb0, 0xcf, 0xff, 0x03, 0x10, 0xee, 0x87, 0xf9, + 0xff, 0xff, 0xfd, 0x6d, 0xc3, 0x87, 0x19, 0x02, 0x5e, 0xc0, 0xff, 0x3f, + 0x00, 0xee, 0xbf, 0xfb, 0xff, 0xff, 0xfd, 0xed, 0xe3, 0xbf, 0x1b, 0x01, + 0x00, 0xcf, 0xff, 0x00, 0x1e, 0xee, 0x9f, 0xf9, 0xff, 0xff, 0xfd, 0xed, + 0xe3, 0x9f, 0x19, 0xc0, 0xb0, 0xcf, 0xff, 0x02, 0x00, 0xec, 0xc7, 0x3d, + 0xd6, 0x18, 0xc7, 0xff, 0xc3, 0xc7, 0x1d, 0x81, 0x00, 0xc0, 0xff, 0x00, + 0x00, 0xef, 0xdf, 0xfd, 0xff, 0xff, 0xfd, 0xff, 0xe3, 0xdf, 0x1d, 0x60, + 0x07, 0xcf, 0xff, 0x00, 0x00, 0xef, 0xdf, 0xfd, 0xff, 0xff, 0xfd, 0xef, + 0xe3, 0xdf, 0x1d, 0x60, 0x40, 0xcf, 0xff, 0x06, 0x00, 0xef, 0xdf, 0xfd, + 0xff, 0xff, 0xff, 0xff, 0xe7, 0xdf, 0x5d, 0xf0, 0x80, 0xcf, 0xff, 0x00, + 0xfc, 0xec, 0xff, 0x7f, 0xfc, 0xff, 0xff, 0xfb, 0x2f, 0x7f, 0x80, 0x5f, + 0xff, 0xc0, 0xff, 0x0c, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, + 0x07, 0x3f, 0x20, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xd6, 0xf7, 0xff, + 0xff, 0xaf, 0xff, 0xff, 0x3b, 0x5f, 0x20, 0xff, 0xf3, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, 0xff, 0xfe, 0xff, + 0xff, 0xff, 0x1f, 0xfe, 0xff, 0x03, 0xff, 0xff, 0xfe, 0xff, 0xff, 0xff, + 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x7f, 0xf9, 0xff, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x20, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x3d, 0x7f, 0x3d, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3d, 0xff, + 0xff, 0xff, 0xff, 0x3d, 0x7f, 0x3d, 0xff, 0x7f, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x3d, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x07, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x3f, 0xfe, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xfe, 0xff, 0xff, + 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc7, 0xff, + 0x01, 0xff, 0xdf, 0x0f, 0x00, 0xff, 0xff, 0x0f, 0x00, 0xff, 0xff, 0x0f, + 0x00, 0xff, 0xdf, 0x0d, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, + 0xff, 0xff, 0x01, 0x80, 0x10, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xff, + 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x3f, 0x00, 0xff, 0xff, 0xff, 0x7f, 0xff, 0x0f, 0xff, + 0x01, 0xc0, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x1f, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x03, 0xff, 0x03, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x7f, 0xfe, 0xff, 0x1f, 0x00, 0xff, 0x03, 0xff, 0x03, 0x80, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xef, 0xff, 0xef, 0x0f, 0xff, 0x03, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xbf, 0xff, 0x03, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, + 0x00, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0x01, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0x6f, + 0x04, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x1f, 0x00, 0xff, 0xff, 0x3f, + 0x3f, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x3f, 0xff, 0xaa, 0xff, 0xff, 0xff, + 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x5f, 0xdc, 0x1f, 0xcf, + 0x0f, 0xff, 0x1f, 0xdc, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x80, 0x00, 0x00, 0xff, + 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x84, 0xfc, 0x2f, 0x3e, 0x50, 0xbd, 0xff, 0xf3, 0xe0, 0x43, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x03, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x78, 0x0c, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x80, 0x00, 0x00, 0xff, 0xff, 0x7f, 0x00, 0x7f, 0x7f, 0x7f, + 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0xfe, 0x03, 0x3e, + 0x1f, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, + 0xe0, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xf7, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0x1f, 0xff, + 0xff, 0xff, 0x0f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xf0, + 0x8f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xfc, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x7c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, + 0xff, 0xbf, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x0f, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x2f, 0x00, 0xff, 0x03, 0x00, 0x00, 0xfc, 0xe8, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x07, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0xff, 0xff, 0xff, + 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf7, 0xff, 0x00, 0x80, 0xff, + 0x03, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, + 0x00, 0xff, 0x3f, 0xff, 0x03, 0xff, 0xff, 0x7f, 0xfc, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x7f, 0x05, 0x00, 0x00, 0x38, 0xff, 0xff, 0x3c, + 0x00, 0x7e, 0x7e, 0x7e, 0x00, 0x7f, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xf7, 0xff, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0xff, 0x03, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0xff, 0xff, 0x7f, 0xf8, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x7f, 0x00, 0xf8, 0xe0, 0xff, 0xfd, 0x7f, 0x5f, 0xdb, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, + 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x1f, 0x00, 0x00, 0xff, 0x03, 0xfe, 0xff, 0xff, 0x07, 0xfe, 0xff, 0xff, + 0x07, 0xc0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x7f, 0xfc, 0xfc, 0xfc, 0x1c, 0x00, 0x00, 0x00, 0x00, 0xff, 0xef, 0xff, + 0xff, 0x7f, 0xff, 0xff, 0xb7, 0xff, 0x3f, 0xff, 0x3f, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x1f, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0x00, 0xe0, 0xff, 0xff, 0xff, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x07, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0x3e, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x3f, 0xff, 0x03, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, + 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x7f, 0x00, 0xff, 0xff, 0x3f, 0x00, 0xff, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xfd, 0xff, 0xff, 0xff, 0xff, 0xbf, + 0x91, 0xff, 0xff, 0x3f, 0x00, 0xff, 0xff, 0x7f, 0x00, 0xff, 0xff, 0xff, + 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x37, + 0x00, 0xff, 0xff, 0x3f, 0x00, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6f, 0xf0, 0xef, + 0xfe, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0x1f, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfe, 0xff, + 0xff, 0x1f, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, + 0x00, 0xff, 0xff, 0x3f, 0x00, 0xff, 0xff, 0x07, 0x00, 0xff, 0xff, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x00, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x1f, 0x80, 0x00, 0xff, + 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x7f, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, + 0x00, 0xc0, 0xff, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x01, 0x00, 0x00, 0xff, 0xff, 0xff, 0x01, 0xff, 0x03, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xc7, 0xff, 0x70, 0x00, 0xff, 0xff, 0xff, 0xff, 0x47, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1e, 0x00, 0xff, + 0x17, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xfb, 0xff, 0xff, 0xff, 0x9f, + 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xbd, 0xff, + 0xbf, 0xff, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0xff, + 0x03, 0xef, 0x9f, 0xf9, 0xff, 0xff, 0xfd, 0xed, 0xe3, 0x9f, 0x19, 0x81, + 0xe0, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xbb, 0x07, 0xff, 0x83, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xb3, 0x00, 0xff, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x3f, 0x7f, 0x00, 0x00, 0x00, 0x3f, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x11, 0x00, 0xff, + 0x03, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, + 0x01, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xe7, 0xff, 0x07, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfc, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xfc, 0x1a, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xe7, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x01, 0xff, 0xfd, 0xff, 0xff, 0xff, 0xff, 0x7f, + 0x7f, 0x01, 0x00, 0xff, 0x03, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xfc, + 0xff, 0xff, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x7f, 0xfb, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xb4, 0xcb, 0x00, 0xff, + 0x03, 0xbf, 0xfd, 0xff, 0xff, 0xff, 0x7f, 0x7b, 0x01, 0xff, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x7f, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x01, 0xff, 0xff, 0xff, 0x7f, 0xff, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, + 0xff, 0xff, 0x3f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, + 0x00, 0x0f, 0x00, 0xff, 0x03, 0xf8, 0xff, 0xff, 0xe0, 0xff, 0xff, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x87, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x0b, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0xff, 0xff, 0xff, + 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0xf0, 0x00, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x07, 0xff, 0x1f, 0xff, 0x01, 0xff, 0x43, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xdf, 0x64, 0xde, 0xff, 0xeb, 0xef, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xe7, 0xdf, 0xdf, 0xff, 0xff, 0xff, + 0x7b, 0x5f, 0xfc, 0xfd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0xff, 0xff, + 0xff, 0xfd, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xdf, + 0xff, 0xff, 0xff, 0xdf, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, 0x7f, 0xff, + 0xff, 0xff, 0xfd, 0xff, 0xff, 0xff, 0xfd, 0xff, 0xff, 0xf7, 0xcf, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xf9, 0xdb, 0x07, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x80, 0x3f, 0xff, 0x43, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x0f, 0xff, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x8f, 0x08, 0xff, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xef, 0xff, 0xff, + 0xff, 0x96, 0xfe, 0xf7, 0x0a, 0x84, 0xea, 0x96, 0xaa, 0x96, 0xf7, 0xf7, + 0x5e, 0xff, 0xfb, 0xff, 0x0f, 0xee, 0xfb, 0xff, 0x0f, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, + 0xff, 0xff, 0x03, 0xff, 0xff, 0xff, 0x03, 0xff, 0xff, 0xff, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x56, 0x01, 0x00, 0x00, 0x39, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x20, 0x00, + 0x00, 0x00, 0xe0, 0xff, 0xff, 0x00, 0xbf, 0x1d, 0x00, 0x00, 0xe7, 0x02, + 0x00, 0x00, 0x79, 0x00, 0x00, 0x02, 0x24, 0x00, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x00, + 0x00, 0x00, 0xfe, 0xff, 0xff, 0x01, 0x39, 0xff, 0xff, 0x00, 0x18, 0xff, + 0xff, 0x01, 0x87, 0xff, 0xff, 0x00, 0xd4, 0xfe, 0xff, 0x00, 0xc3, 0x00, + 0x00, 0x01, 0xd2, 0x00, 0x00, 0x01, 0xce, 0x00, 0x00, 0x01, 0xcd, 0x00, + 0x00, 0x01, 0x4f, 0x00, 0x00, 0x01, 0xca, 0x00, 0x00, 0x01, 0xcb, 0x00, + 0x00, 0x01, 0xcf, 0x00, 0x00, 0x00, 0x61, 0x00, 0x00, 0x01, 0xd3, 0x00, + 0x00, 0x01, 0xd1, 0x00, 0x00, 0x00, 0xa3, 0x00, 0x00, 0x01, 0xd5, 0x00, + 0x00, 0x00, 0x82, 0x00, 0x00, 0x01, 0xd6, 0x00, 0x00, 0x01, 0xda, 0x00, + 0x00, 0x01, 0xd9, 0x00, 0x00, 0x01, 0xdb, 0x00, 0x00, 0x00, 0x38, 0x00, + 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0xb1, 0xff, 0xff, 0x01, 0x9f, 0xff, + 0xff, 0x01, 0xc8, 0xff, 0xff, 0x02, 0x28, 0x24, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x33, 0xff, + 0xff, 0x00, 0x26, 0xff, 0xff, 0x01, 0x7e, 0xff, 0xff, 0x01, 0x2b, 0x2a, + 0x00, 0x01, 0x5d, 0xff, 0xff, 0x01, 0x28, 0x2a, 0x00, 0x00, 0x3f, 0x2a, + 0x00, 0x01, 0x3d, 0xff, 0xff, 0x01, 0x45, 0x00, 0x00, 0x01, 0x47, 0x00, + 0x00, 0x00, 0x1f, 0x2a, 0x00, 0x00, 0x1c, 0x2a, 0x00, 0x00, 0x1e, 0x2a, + 0x00, 0x00, 0x2e, 0xff, 0xff, 0x00, 0x32, 0xff, 0xff, 0x00, 0x36, 0xff, + 0xff, 0x00, 0x35, 0xff, 0xff, 0x00, 0x4f, 0xa5, 0x00, 0x00, 0x4b, 0xa5, + 0x00, 0x00, 0x31, 0xff, 0xff, 0x00, 0x28, 0xa5, 0x00, 0x00, 0x44, 0xa5, + 0x00, 0x00, 0x2f, 0xff, 0xff, 0x00, 0x2d, 0xff, 0xff, 0x00, 0xf7, 0x29, + 0x00, 0x00, 0x41, 0xa5, 0x00, 0x00, 0xfd, 0x29, 0x00, 0x00, 0x2b, 0xff, + 0xff, 0x00, 0x2a, 0xff, 0xff, 0x00, 0xe7, 0x29, 0x00, 0x00, 0x43, 0xa5, + 0x00, 0x00, 0x2a, 0xa5, 0x00, 0x00, 0xbb, 0xff, 0xff, 0x00, 0x27, 0xff, + 0xff, 0x00, 0xb9, 0xff, 0xff, 0x00, 0x25, 0xff, 0xff, 0x00, 0x15, 0xa5, + 0x00, 0x00, 0x12, 0xa5, 0x00, 0x02, 0x24, 0x4c, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x20, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0x01, 0x01, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x54, 0x00, 0x00, 0x01, 0x74, 0x00, + 0x00, 0x01, 0x26, 0x00, 0x00, 0x01, 0x25, 0x00, 0x00, 0x01, 0x40, 0x00, + 0x00, 0x01, 0x3f, 0x00, 0x00, 0x00, 0xda, 0xff, 0xff, 0x00, 0xdb, 0xff, + 0xff, 0x00, 0xe1, 0xff, 0xff, 0x00, 0xc0, 0xff, 0xff, 0x00, 0xc1, 0xff, + 0xff, 0x01, 0x08, 0x00, 0x00, 0x00, 0xc2, 0xff, 0xff, 0x00, 0xc7, 0xff, + 0xff, 0x00, 0xd1, 0xff, 0xff, 0x00, 0xca, 0xff, 0xff, 0x00, 0xf8, 0xff, + 0xff, 0x00, 0xaa, 0xff, 0xff, 0x00, 0xb0, 0xff, 0xff, 0x00, 0x07, 0x00, + 0x00, 0x00, 0x8c, 0xff, 0xff, 0x01, 0xc4, 0xff, 0xff, 0x00, 0xa0, 0xff, + 0xff, 0x01, 0xf9, 0xff, 0xff, 0x02, 0x1a, 0x70, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x01, 0x20, 0x00, 0x00, 0x00, 0xe0, 0xff, + 0xff, 0x01, 0x50, 0x00, 0x00, 0x01, 0x0f, 0x00, 0x00, 0x00, 0xf1, 0xff, + 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x30, 0x00, 0x00, 0x00, 0xd0, 0xff, + 0xff, 0x01, 0x01, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xc0, 0x0b, 0x00, 0x01, 0x60, 0x1c, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0xd0, 0x97, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0xf8, 0xff, + 0xff, 0x02, 0x05, 0x8a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0xf4, + 0xff, 0x00, 0x9e, 0xe7, 0xff, 0x00, 0xc2, 0x89, 0x00, 0x00, 0xdb, 0xe7, + 0xff, 0x00, 0x92, 0xe7, 0xff, 0x00, 0x93, 0xe7, 0xff, 0x00, 0x9c, 0xe7, + 0xff, 0x00, 0x9d, 0xe7, 0xff, 0x00, 0xa4, 0xe7, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x38, 0x8a, 0x00, 0x00, 0x04, 0x8a, 0x00, 0x00, 0xe6, 0x0e, + 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xc5, 0xff, 0xff, 0x01, 0x41, 0xe2, 0xff, 0x02, 0x1d, 0x8f, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x01, 0xf8, 0xff, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x56, 0x00, 0x00, 0x01, 0xaa, 0xff, 0xff, 0x00, 0x4a, 0x00, + 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x70, 0x00, + 0x00, 0x00, 0x7e, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x01, 0xb6, 0xff, + 0xff, 0x01, 0xf7, 0xff, 0xff, 0x00, 0xdb, 0xe3, 0xff, 0x01, 0x9c, 0xff, + 0xff, 0x01, 0x90, 0xff, 0xff, 0x01, 0x80, 0xff, 0xff, 0x01, 0x82, 0xff, + 0xff, 0x02, 0x05, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x10, 0x00, + 0x00, 0x00, 0xf0, 0xff, 0xff, 0x01, 0x1c, 0x00, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0xa3, 0xe2, 0xff, 0x01, 0x41, 0xdf, 0xff, 0x01, 0xba, 0xdf, + 0xff, 0x00, 0xe4, 0xff, 0xff, 0x02, 0x0b, 0xb1, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x01, 0x30, 0x00, 0x00, 0x00, 0xd0, 0xff, + 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x09, 0xd6, 0xff, 0x01, 0x1a, 0xf1, + 0xff, 0x01, 0x19, 0xd6, 0xff, 0x00, 0xd5, 0xd5, 0xff, 0x00, 0xd8, 0xd5, + 0xff, 0x01, 0xe4, 0xd5, 0xff, 0x01, 0x03, 0xd6, 0xff, 0x01, 0xe1, 0xd5, + 0xff, 0x01, 0xe2, 0xd5, 0xff, 0x01, 0xc1, 0xd5, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xa0, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x02, 0x0c, 0xbc, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x01, 0xbc, 0x5a, + 0xff, 0x01, 0xa0, 0x03, 0x00, 0x01, 0xfc, 0x75, 0xff, 0x01, 0xd8, 0x5a, + 0xff, 0x00, 0x30, 0x00, 0x00, 0x01, 0xb1, 0x5a, 0xff, 0x01, 0xb5, 0x5a, + 0xff, 0x01, 0xbf, 0x5a, 0xff, 0x01, 0xee, 0x5a, 0xff, 0x01, 0xd6, 0x5a, + 0xff, 0x01, 0xeb, 0x5a, 0xff, 0x01, 0xd0, 0xff, 0xff, 0x01, 0xbd, 0x5a, + 0xff, 0x01, 0xc8, 0x75, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x68, + 0xff, 0x00, 0x60, 0xfc, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x20, 0x00, + 0x00, 0x00, 0xe0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x28, 0x00, + 0x00, 0x00, 0xd8, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, + 0x00, 0x00, 0xc0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x20, 0x00, + 0x00, 0x00, 0xe0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x20, 0x00, + 0x00, 0x00, 0xe0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x22, 0x00, + 0x00, 0x00, 0xde, 0xff, 0xff, 0x30, 0x0c, 0x31, 0x0d, 0x78, 0x0e, 0x7f, + 0x0f, 0x80, 0x10, 0x81, 0x11, 0x86, 0x12, 0x89, 0x13, 0x8a, 0x13, 0x8e, + 0x14, 0x8f, 0x15, 0x90, 0x16, 0x93, 0x13, 0x94, 0x17, 0x95, 0x18, 0x96, + 0x19, 0x97, 0x1a, 0x9a, 0x1b, 0x9c, 0x19, 0x9d, 0x1c, 0x9e, 0x1d, 0x9f, + 0x1e, 0xa6, 0x1f, 0xa9, 0x1f, 0xae, 0x1f, 0xb1, 0x20, 0xb2, 0x20, 0xb7, + 0x21, 0xbf, 0x22, 0xc5, 0x23, 0xc8, 0x23, 0xcb, 0x23, 0xdd, 0x24, 0xf2, + 0x23, 0xf6, 0x25, 0xf7, 0x26, 0x20, 0x2d, 0x3a, 0x2e, 0x3d, 0x2f, 0x3e, + 0x30, 0x3f, 0x31, 0x40, 0x31, 0x43, 0x32, 0x44, 0x33, 0x45, 0x34, 0x50, + 0x35, 0x51, 0x36, 0x52, 0x37, 0x53, 0x38, 0x54, 0x39, 0x59, 0x3a, 0x5b, + 0x3b, 0x5c, 0x3c, 0x61, 0x3d, 0x63, 0x3e, 0x65, 0x3f, 0x66, 0x40, 0x68, + 0x41, 0x69, 0x42, 0x6a, 0x40, 0x6b, 0x43, 0x6c, 0x44, 0x6f, 0x42, 0x71, + 0x45, 0x72, 0x46, 0x75, 0x47, 0x7d, 0x48, 0x82, 0x49, 0x87, 0x4a, 0x89, + 0x4b, 0x8a, 0x4c, 0x8b, 0x4c, 0x8c, 0x4d, 0x92, 0x4e, 0x9d, 0x4f, 0x9e, + 0x50, 0x45, 0x57, 0x7b, 0x1d, 0x7c, 0x1d, 0x7d, 0x1d, 0x7f, 0x58, 0x86, + 0x59, 0x88, 0x5a, 0x89, 0x5a, 0x8a, 0x5a, 0x8c, 0x5b, 0x8e, 0x5c, 0x8f, + 0x5c, 0xac, 0x5d, 0xad, 0x5e, 0xae, 0x5e, 0xaf, 0x5e, 0xc2, 0x5f, 0xcc, + 0x60, 0xcd, 0x61, 0xce, 0x61, 0xcf, 0x62, 0xd0, 0x63, 0xd1, 0x64, 0xd5, + 0x65, 0xd6, 0x66, 0xd7, 0x67, 0xf0, 0x68, 0xf1, 0x69, 0xf2, 0x6a, 0xf3, + 0x6b, 0xf4, 0x6c, 0xf5, 0x6d, 0xf9, 0x6e, 0xfd, 0x2d, 0xfe, 0x2d, 0xff, + 0x2d, 0x50, 0x69, 0x51, 0x69, 0x52, 0x69, 0x53, 0x69, 0x54, 0x69, 0x55, + 0x69, 0x56, 0x69, 0x57, 0x69, 0x58, 0x69, 0x59, 0x69, 0x5a, 0x69, 0x5b, + 0x69, 0x5c, 0x69, 0x5d, 0x69, 0x5e, 0x69, 0x5f, 0x69, 0x82, 0x00, 0x83, + 0x00, 0x84, 0x00, 0x85, 0x00, 0x86, 0x00, 0x87, 0x00, 0x88, 0x00, 0x89, + 0x00, 0xc0, 0x75, 0xcf, 0x76, 0x80, 0x89, 0x81, 0x8a, 0x82, 0x8b, 0x85, + 0x8c, 0x86, 0x8d, 0x70, 0x9d, 0x71, 0x9d, 0x76, 0x9e, 0x77, 0x9e, 0x78, + 0x9f, 0x79, 0x9f, 0x7a, 0xa0, 0x7b, 0xa0, 0x7c, 0xa1, 0x7d, 0xa1, 0xb3, + 0xa2, 0xba, 0xa3, 0xbb, 0xa3, 0xbc, 0xa4, 0xbe, 0xa5, 0xc3, 0xa2, 0xcc, + 0xa4, 0xda, 0xa6, 0xdb, 0xa6, 0xe5, 0x6a, 0xea, 0xa7, 0xeb, 0xa7, 0xec, + 0x6e, 0xf3, 0xa2, 0xf8, 0xa8, 0xf9, 0xa8, 0xfa, 0xa9, 0xfb, 0xa9, 0xfc, + 0xa4, 0x26, 0xb0, 0x2a, 0xb1, 0x2b, 0xb2, 0x4e, 0xb3, 0x84, 0x08, 0x62, + 0xba, 0x63, 0xbb, 0x64, 0xbc, 0x65, 0xbd, 0x66, 0xbe, 0x6d, 0xbf, 0x6e, + 0xc0, 0x6f, 0xc1, 0x70, 0xc2, 0x7e, 0xc3, 0x7f, 0xc3, 0x7d, 0xcf, 0x8d, + 0xd0, 0x94, 0xd1, 0xab, 0xd2, 0xac, 0xd3, 0xad, 0xd4, 0xb0, 0xd5, 0xb1, + 0xd6, 0xb2, 0xd7, 0xc4, 0xd8, 0xc5, 0xd9, 0xc6, 0xda, 0x07, 0x08, 0x09, + 0x0a, 0x0b, 0x0c, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x0d, 0x06, 0x06, 0x0e, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x0f, 0x10, 0x11, 0x12, 0x06, 0x13, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x14, 0x15, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x16, 0x17, 0x06, 0x06, 0x06, + 0x18, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x19, 0x06, 0x06, 0x06, 0x06, 0x1a, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x1b, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x1c, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x1d, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1e, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x01, 0x00, 0x54, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, + 0x00, 0x00, 0x00, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x07, 0x2b, + 0x2b, 0x5b, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x4a, 0x56, 0x56, + 0x05, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x24, 0x50, 0x79, 0x31, 0x50, 0x31, 0x50, + 0x31, 0x38, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x4e, 0x31, 0x02, 0x4e, 0x0d, 0x0d, 0x4e, + 0x03, 0x4e, 0x00, 0x24, 0x6e, 0x00, 0x4e, 0x31, 0x26, 0x6e, 0x51, 0x4e, + 0x24, 0x50, 0x4e, 0x39, 0x14, 0x81, 0x1b, 0x1d, 0x1d, 0x53, 0x31, 0x50, + 0x31, 0x50, 0x0d, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x1b, 0x53, 0x24, + 0x50, 0x31, 0x02, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, + 0x7b, 0x14, 0x79, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x2d, 0x2b, 0x49, 0x03, + 0x48, 0x03, 0x78, 0x5c, 0x7b, 0x14, 0x00, 0x96, 0x0a, 0x01, 0x2b, 0x28, + 0x06, 0x06, 0x00, 0x2a, 0x06, 0x2a, 0x2a, 0x2b, 0x07, 0xbb, 0xb5, 0x2b, + 0x1e, 0x00, 0x2b, 0x07, 0x2b, 0x2b, 0x2b, 0x01, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x01, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0xcd, 0x46, 0xcd, 0x2b, 0x00, 0x25, + 0x2b, 0x07, 0x01, 0x06, 0x01, 0x55, 0x56, 0x56, 0x56, 0x56, 0x56, 0x55, + 0x56, 0x56, 0x02, 0x24, 0x81, 0x81, 0x81, 0x81, 0x81, 0x15, 0x81, 0x81, + 0x81, 0x00, 0x00, 0x2b, 0x00, 0xb2, 0xd1, 0xb2, 0xd1, 0xb2, 0xd1, 0xb2, + 0xd1, 0x00, 0x00, 0xcd, 0xcc, 0x01, 0x00, 0xd7, 0xd7, 0xd7, 0xd7, 0xd7, + 0x83, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xac, + 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0x1c, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x02, 0x00, 0x00, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x4e, + 0x31, 0x50, 0x31, 0x50, 0x4e, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x02, 0x87, 0xa6, 0x87, + 0xa6, 0x87, 0xa6, 0x87, 0xa6, 0x87, 0xa6, 0x87, 0xa6, 0x87, 0xa6, 0x87, + 0xa6, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x00, 0x00, 0x00, 0x54, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x54, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x0c, 0x00, 0x0c, 0x2a, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x07, 0x2a, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x56, 0x56, 0x6c, + 0x81, 0x15, 0x00, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x07, 0x6c, 0x03, + 0x41, 0x2b, 0x2b, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x2c, 0x56, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x6c, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x25, 0x06, 0x25, 0x06, + 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, + 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, + 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, + 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x56, 0x7a, 0x9e, + 0x26, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, + 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, + 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x25, 0x06, 0x01, 0x2b, 0x2b, 0x4f, + 0x56, 0x56, 0x2c, 0x2b, 0x7f, 0x56, 0x56, 0x39, 0x2b, 0x2b, 0x55, 0x56, + 0x56, 0x2b, 0x2b, 0x4f, 0x56, 0x56, 0x2c, 0x2b, 0x7f, 0x56, 0x56, 0x81, + 0x37, 0x75, 0x5b, 0x7b, 0x5c, 0x2b, 0x2b, 0x4f, 0x56, 0x56, 0x02, 0xac, + 0x04, 0x00, 0x00, 0x39, 0x2b, 0x2b, 0x55, 0x56, 0x56, 0x2b, 0x2b, 0x4f, + 0x56, 0x56, 0x2c, 0x2b, 0x2b, 0x56, 0x56, 0x32, 0x13, 0x81, 0x57, 0x00, + 0x6f, 0x81, 0x7e, 0xc9, 0xd7, 0x7e, 0x2d, 0x81, 0x81, 0x0e, 0x7e, 0x39, + 0x7f, 0x6f, 0x57, 0x00, 0x81, 0x81, 0x7e, 0x15, 0x00, 0x7e, 0x03, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x07, + 0x2b, 0x24, 0x2b, 0x97, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x80, 0x81, 0x81, 0x81, 0x81, 0x39, 0xbb, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x01, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, + 0x81, 0x81, 0x81, 0x81, 0xc9, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, + 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xd0, 0x0d, 0x00, 0x4e, + 0x31, 0x02, 0xb4, 0xc1, 0xc1, 0xd7, 0xd7, 0x24, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0xd7, 0xd7, 0x53, 0xc1, 0x47, 0xd4, 0xd7, + 0xd7, 0xd7, 0x05, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x07, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x4e, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x0d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, + 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x31, 0x50, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x79, 0x5c, 0x7b, 0x5c, 0x7b, 0x4f, + 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, + 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x7b, 0x5c, 0x2d, 0x2b, 0x2b, 0x79, + 0x14, 0x5c, 0x7b, 0x5c, 0x2d, 0x79, 0x2a, 0x5c, 0x27, 0x5c, 0x7b, 0x5c, + 0x7b, 0x5c, 0x7b, 0xa4, 0x00, 0x0a, 0xb4, 0x5c, 0x7b, 0x5c, 0x7b, 0x4f, + 0x03, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x07, 0x00, 0x48, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x55, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x07, + 0x00, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x07, 0x00, 0x00, 0x00, 0x00, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x0e, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x2a, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, + 0x56, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2b, 0x2b, 0x2b, + 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x2b, 0x55, 0x56, 0x56, 0x56, + 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x0e, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x27, 0x51, 0x6f, 0x77, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x00, 0x00, + 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x8e, 0x92, + 0x97, 0x00, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xb4, 0xc4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xc6, 0xc9, 0x00, 0x00, 0x00, 0xdb, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0x00, 0x00, 0x00, + 0x00, 0xe1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe4, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe7, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xea, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xed, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x20, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, + 0x00, 0x0d, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, + 0x00, 0x85, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x01, 0x20, 0x00, + 0x00, 0x02, 0x20, 0x00, 0x00, 0x03, 0x20, 0x00, 0x00, 0x04, 0x20, 0x00, + 0x00, 0x05, 0x20, 0x00, 0x00, 0x06, 0x20, 0x00, 0x00, 0x08, 0x20, 0x00, + 0x00, 0x09, 0x20, 0x00, 0x00, 0x0a, 0x20, 0x00, 0x00, 0x28, 0x20, 0x00, + 0x00, 0x29, 0x20, 0x00, 0x00, 0x5f, 0x20, 0x00, 0x00, 0x00, 0x30, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x05, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x00, 0x0c, 0x0b, 0x73, 0x74, 0x64, 0x6c, 0x69, 0x62, 0x2e, 0x77, + 0x61, 0x73, 0x6d, 0x01, 0xc9, 0x04, 0x2c, 0x00, 0x2a, 0x5f, 0x5f, 0x69, + 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x77, 0x61, 0x73, 0x69, + 0x5f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, + 0x65, 0x76, 0x69, 0x65, 0x77, 0x31, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x5f, + 0x67, 0x65, 0x74, 0x01, 0x30, 0x5f, 0x5f, 0x69, 0x6d, 0x70, 0x6f, 0x72, + 0x74, 0x65, 0x64, 0x5f, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, + 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x31, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x5f, 0x73, 0x69, 0x7a, 0x65, + 0x73, 0x5f, 0x67, 0x65, 0x74, 0x02, 0x2b, 0x5f, 0x5f, 0x69, 0x6d, 0x70, + 0x6f, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, + 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, + 0x69, 0x65, 0x77, 0x31, 0x5f, 0x70, 0x72, 0x6f, 0x63, 0x5f, 0x65, 0x78, + 0x69, 0x74, 0x03, 0x11, 0x5f, 0x5f, 0x77, 0x61, 0x73, 0x6d, 0x5f, 0x63, + 0x61, 0x6c, 0x6c, 0x5f, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x04, 0x13, 0x75, + 0x6e, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x5f, 0x77, 0x65, 0x61, + 0x6b, 0x3a, 0x6d, 0x61, 0x69, 0x6e, 0x05, 0x12, 0x5f, 0x5f, 0x77, 0x61, + 0x73, 0x6d, 0x5f, 0x69, 0x6e, 0x69, 0x74, 0x5f, 0x6d, 0x65, 0x6d, 0x6f, + 0x72, 0x79, 0x06, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x07, 0x0a, + 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x70, 0x08, 0x06, + 0x6d, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x09, 0x04, 0x66, 0x72, 0x65, 0x65, + 0x0a, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x0b, 0x07, 0x72, 0x65, + 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x0c, 0x05, 0x5f, 0x45, 0x78, 0x69, 0x74, + 0x0d, 0x0b, 0x5f, 0x5f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x76, 0x6f, 0x69, + 0x64, 0x0e, 0x0f, 0x5f, 0x5f, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x61, 0x72, + 0x67, 0x73, 0x5f, 0x67, 0x65, 0x74, 0x0f, 0x15, 0x5f, 0x5f, 0x77, 0x61, + 0x73, 0x69, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x5f, 0x73, 0x69, 0x7a, 0x65, + 0x73, 0x5f, 0x67, 0x65, 0x74, 0x10, 0x10, 0x5f, 0x5f, 0x77, 0x61, 0x73, + 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x63, 0x5f, 0x65, 0x78, 0x69, 0x74, 0x11, + 0x05, 0x64, 0x75, 0x6d, 0x6d, 0x79, 0x12, 0x11, 0x5f, 0x5f, 0x77, 0x61, + 0x73, 0x6d, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x74, 0x6f, 0x72, + 0x73, 0x13, 0x06, 0x6d, 0x65, 0x6d, 0x63, 0x70, 0x79, 0x14, 0x06, 0x6d, + 0x65, 0x6d, 0x73, 0x65, 0x74, 0x15, 0x06, 0x73, 0x74, 0x72, 0x6c, 0x65, + 0x6e, 0x16, 0x08, 0x69, 0x73, 0x77, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x17, + 0x06, 0x6d, 0x65, 0x6d, 0x63, 0x6d, 0x70, 0x18, 0x06, 0x6d, 0x65, 0x6d, + 0x63, 0x68, 0x72, 0x19, 0x06, 0x73, 0x74, 0x72, 0x63, 0x6d, 0x70, 0x1a, + 0x08, 0x74, 0x6f, 0x77, 0x6c, 0x6f, 0x77, 0x65, 0x72, 0x1b, 0x07, 0x63, + 0x61, 0x73, 0x65, 0x6d, 0x61, 0x70, 0x1c, 0x08, 0x74, 0x6f, 0x77, 0x75, + 0x70, 0x70, 0x65, 0x72, 0x1d, 0x07, 0x73, 0x74, 0x72, 0x6e, 0x63, 0x6d, + 0x70, 0x1e, 0x08, 0x69, 0x73, 0x77, 0x75, 0x70, 0x70, 0x65, 0x72, 0x1f, + 0x07, 0x6d, 0x65, 0x6d, 0x6d, 0x6f, 0x76, 0x65, 0x20, 0x08, 0x69, 0x73, + 0x77, 0x6c, 0x6f, 0x77, 0x65, 0x72, 0x21, 0x07, 0x69, 0x73, 0x62, 0x6c, + 0x61, 0x6e, 0x6b, 0x22, 0x08, 0x69, 0x73, 0x77, 0x62, 0x6c, 0x61, 0x6e, + 0x6b, 0x23, 0x08, 0x69, 0x73, 0x77, 0x64, 0x69, 0x67, 0x69, 0x74, 0x24, + 0x07, 0x73, 0x74, 0x72, 0x6e, 0x63, 0x61, 0x74, 0x25, 0x09, 0x5f, 0x5f, + 0x73, 0x74, 0x70, 0x6e, 0x63, 0x70, 0x79, 0x26, 0x07, 0x73, 0x74, 0x72, + 0x6e, 0x63, 0x70, 0x79, 0x27, 0x09, 0x69, 0x73, 0x77, 0x78, 0x64, 0x69, + 0x67, 0x69, 0x74, 0x28, 0x06, 0x77, 0x63, 0x73, 0x6c, 0x65, 0x6e, 0x29, + 0x06, 0x77, 0x63, 0x73, 0x63, 0x68, 0x72, 0x2a, 0x08, 0x69, 0x73, 0x77, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x2b, 0x08, 0x69, 0x73, 0x77, 0x61, 0x6c, + 0x6e, 0x75, 0x6d, 0x07, 0x33, 0x02, 0x00, 0x0f, 0x5f, 0x5f, 0x73, 0x74, + 0x61, 0x63, 0x6b, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x01, + 0x1f, 0x47, 0x4f, 0x54, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x5f, 0x5f, 0x6d, 0x65, 0x6d, + 0x6f, 0x72, 0x79, 0x5f, 0x62, 0x61, 0x73, 0x65, 0x09, 0x0a, 0x01, 0x00, + 0x07, 0x2e, 0x72, 0x6f, 0x64, 0x61, 0x74, 0x61, 0x00, 0x8e, 0x01, 0x09, + 0x70, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x65, 0x72, 0x73, 0x02, 0x08, 0x6c, + 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x01, 0x03, 0x43, 0x31, 0x31, + 0x00, 0x0c, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x64, 0x2d, + 0x62, 0x79, 0x01, 0x05, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x5f, 0x31, 0x39, + 0x2e, 0x31, 0x2e, 0x35, 0x2d, 0x77, 0x61, 0x73, 0x69, 0x2d, 0x73, 0x64, + 0x6b, 0x20, 0x28, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x6c, + 0x76, 0x6d, 0x2f, 0x6c, 0x6c, 0x76, 0x6d, 0x2d, 0x70, 0x72, 0x6f, 0x6a, + 0x65, 0x63, 0x74, 0x20, 0x61, 0x62, 0x34, 0x62, 0x35, 0x61, 0x32, 0x64, + 0x62, 0x35, 0x38, 0x32, 0x39, 0x35, 0x38, 0x61, 0x66, 0x31, 0x65, 0x65, + 0x33, 0x30, 0x38, 0x61, 0x37, 0x39, 0x30, 0x63, 0x66, 0x64, 0x62, 0x34, + 0x32, 0x62, 0x64, 0x32, 0x34, 0x37, 0x32, 0x30, 0x29, 0x00, 0x56, 0x0f, + 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x65, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x73, 0x05, 0x2b, 0x0b, 0x62, 0x75, 0x6c, 0x6b, 0x2d, 0x6d, + 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x2b, 0x0a, 0x6d, 0x75, 0x6c, 0x74, 0x69, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2b, 0x0f, 0x6d, 0x75, 0x74, 0x61, 0x62, + 0x6c, 0x65, 0x2d, 0x67, 0x6c, 0x6f, 0x62, 0x61, 0x6c, 0x73, 0x2b, 0x0f, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x2d, 0x74, 0x79, + 0x70, 0x65, 0x73, 0x2b, 0x08, 0x73, 0x69, 0x67, 0x6e, 0x2d, 0x65, 0x78, + 0x74 +}; +unsigned int STDLIB_WASM_LEN = 15673; + +#endif diff --git a/vendor/tree-sitter/src/wasm_store.c b/vendor/tree-sitter/src/wasm_store.c new file mode 100644 index 0000000..e844c2a --- /dev/null +++ b/vendor/tree-sitter/src/wasm_store.c @@ -0,0 +1,1937 @@ +#include "tree_sitter/api.h" +#include "./parser.h" +#include + +#ifdef TREE_SITTER_FEATURE_WASM + +#include "./alloc.h" +#include "./array.h" +#include "./atomic.h" +#include "./language.h" +#include "./lexer.h" +#include "./wasm/wasm-stdlib.h" +#include "./wasm_store.h" + +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4100) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#endif + +#define array_len(a) (sizeof(a) / sizeof(a[0])) + +// The following symbols from the C and C++ standard libraries are available +// for external scanners to use. +const char *STDLIB_SYMBOLS[] = { + #include "./stdlib-symbols.txt" +}; + +// The contents of the `dylink.0` custom section of a wasm module, +// as specified by the current WebAssembly dynamic linking ABI proposal. +typedef struct { + uint32_t memory_size; + uint32_t memory_align; + uint32_t table_size; + uint32_t table_align; +} WasmDylinkInfo; + +// WasmLanguageId - A pointer used to identify a language. This language id is +// reference-counted, so that its ownership can be shared between the language +// itself and the instances of the language that are held in wasm stores. +typedef struct { + volatile uint32_t ref_count; + volatile uint32_t is_language_deleted; +} WasmLanguageId; + +// LanguageWasmModule - Additional data associated with a wasm-backed +// `TSLanguage`. This data is read-only and does not reference a particular +// wasm store, so it can be shared by all users of a `TSLanguage`. A pointer to +// this is stored on the language itself. +typedef struct { + volatile uint32_t ref_count; + WasmLanguageId *language_id; + wasmtime_module_t *module; + const char *name; + char *symbol_name_buffer; + char *field_name_buffer; + WasmDylinkInfo dylink_info; +} LanguageWasmModule; + +// LanguageWasmInstance - Additional data associated with an instantiation of +// a `TSLanguage` in a particular wasm store. The wasm store holds one of +// these structs for each language that it has instantiated. +typedef struct { + WasmLanguageId *language_id; + wasmtime_instance_t instance; + int32_t external_states_address; + int32_t lex_main_fn_index; + int32_t lex_keyword_fn_index; + int32_t scanner_create_fn_index; + int32_t scanner_destroy_fn_index; + int32_t scanner_serialize_fn_index; + int32_t scanner_deserialize_fn_index; + int32_t scanner_scan_fn_index; +} LanguageWasmInstance; + +typedef struct { + uint32_t reset_heap; + uint32_t proc_exit; + uint32_t abort; + uint32_t assert_fail; + uint32_t notify_memory_growth; + uint32_t debug_message; + uint32_t at_exit; + uint32_t args_get; + uint32_t args_sizes_get; +} BuiltinFunctionIndices; + +// TSWasmStore - A struct that allows a given `Parser` to use wasm-backed +// languages. This struct is mutable, and can only be used by one parser at a +// time. +struct TSWasmStore { + wasm_engine_t *engine; + wasmtime_store_t *store; + wasmtime_table_t function_table; + wasmtime_memory_t memory; + TSLexer *current_lexer; + LanguageWasmInstance *current_instance; + Array(LanguageWasmInstance) language_instances; + uint32_t current_memory_offset; + uint32_t current_function_table_offset; + uint32_t *stdlib_fn_indices; + BuiltinFunctionIndices builtin_fn_indices; + wasmtime_global_t stack_pointer_global; + wasm_globaltype_t *const_i32_type; + bool has_error; + uint32_t lexer_address; +}; + +typedef Array(char) StringData; + +// LanguageInWasmMemory - The memory layout of a `TSLanguage` when compiled to +// wasm32. This is used to copy static language data out of the wasm memory. +typedef struct { + uint32_t abi_version; + uint32_t symbol_count; + uint32_t alias_count; + uint32_t token_count; + uint32_t external_token_count; + uint32_t state_count; + uint32_t large_state_count; + uint32_t production_id_count; + uint32_t field_count; + uint16_t max_alias_sequence_length; + int32_t parse_table; + int32_t small_parse_table; + int32_t small_parse_table_map; + int32_t parse_actions; + int32_t symbol_names; + int32_t field_names; + int32_t field_map_slices; + int32_t field_map_entries; + int32_t symbol_metadata; + int32_t public_symbol_map; + int32_t alias_map; + int32_t alias_sequences; + int32_t lex_modes; + int32_t lex_fn; + int32_t keyword_lex_fn; + TSSymbol keyword_capture_token; + struct { + int32_t states; + int32_t symbol_map; + int32_t create; + int32_t destroy; + int32_t scan; + int32_t serialize; + int32_t deserialize; + } external_scanner; + int32_t primary_state_ids; + int32_t name; + int32_t reserved_words; + uint16_t max_reserved_word_set_size; + uint32_t supertype_count; + int32_t supertype_symbols; + int32_t supertype_map_slices; + int32_t supertype_map_entries; + TSLanguageMetadata metadata; +} LanguageInWasmMemory; + +// LexerInWasmMemory - The memory layout of a `TSLexer` when compiled to wasm32. +// This is used to copy mutable lexing state in and out of the wasm memory. +typedef struct { + int32_t lookahead; + TSSymbol result_symbol; + int32_t advance; + int32_t mark_end; + int32_t get_column; + int32_t is_at_included_range_start; + int32_t eof; +} LexerInWasmMemory; + +// Linear memory layout: +// [ <-- stack | stdlib statics | lexer | language statics --> | serialization_buffer | heap --> ] +#define MAX_MEMORY_SIZE (128 * 1024 * 1024 / MEMORY_PAGE_SIZE) + +/************************ + * WasmDylinkMemoryInfo + ***********************/ + +static uint8_t read_u8(const uint8_t **p) { + return *(*p)++; +} + +static inline uint64_t read_uleb128(const uint8_t **p, const uint8_t *end) { + uint64_t value = 0; + unsigned shift = 0; + do { + if (*p == end) return UINT64_MAX; + value += (uint64_t)(**p & 0x7f) << shift; + shift += 7; + } while (*((*p)++) >= 128); + return value; +} + +static bool wasm_dylink_info__parse( + const uint8_t *bytes, + size_t length, + WasmDylinkInfo *info +) { + const uint8_t WASM_MAGIC_NUMBER[4] = {0, 'a', 's', 'm'}; + const uint8_t WASM_VERSION[4] = {1, 0, 0, 0}; + const uint8_t WASM_CUSTOM_SECTION = 0x0; + const uint8_t WASM_DYLINK_MEM_INFO = 0x1; + + const uint8_t *p = bytes; + const uint8_t *end = bytes + length; + + if (length < 8) return false; + if (memcmp(p, WASM_MAGIC_NUMBER, 4) != 0) return false; + p += 4; + if (memcmp(p, WASM_VERSION, 4) != 0) return false; + p += 4; + + while (p < end) { + uint8_t section_id = read_u8(&p); + uint32_t section_length = read_uleb128(&p, end); + const uint8_t *section_end = p + section_length; + if (section_end > end) return false; + + if (section_id == WASM_CUSTOM_SECTION) { + uint32_t name_length = read_uleb128(&p, section_end); + const uint8_t *name_end = p + name_length; + if (name_end > section_end) return false; + + if (name_length == 8 && memcmp(p, "dylink.0", 8) == 0) { + p = name_end; + while (p < section_end) { + uint8_t subsection_type = read_u8(&p); + uint32_t subsection_size = read_uleb128(&p, section_end); + const uint8_t *subsection_end = p + subsection_size; + if (subsection_end > section_end) return false; + if (subsection_type == WASM_DYLINK_MEM_INFO) { + info->memory_size = read_uleb128(&p, subsection_end); + info->memory_align = read_uleb128(&p, subsection_end); + info->table_size = read_uleb128(&p, subsection_end); + info->table_align = read_uleb128(&p, subsection_end); + return true; + } + p = subsection_end; + } + } + } + p = section_end; + } + return false; +} + +/******************************************* + * Native callbacks exposed to wasm modules + *******************************************/ + + static wasm_trap_t *callback__abort( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + return wasmtime_trap_new("wasm module called abort", 24); +} + +static wasm_trap_t *callback__debug_message( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + wasmtime_context_t *context = wasmtime_caller_context(caller); + TSWasmStore *store = env; + ts_assert(args_and_results_len == 2); + uint32_t string_address = args_and_results[0].i32; + uint32_t value = args_and_results[1].i32; + uint8_t *memory = wasmtime_memory_data(context, &store->memory); + printf("DEBUG: %s %u\n", &memory[string_address], value); + return NULL; +} + +static wasm_trap_t *callback__noop( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + return NULL; +} + +static wasm_trap_t *callback__lexer_advance( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + wasmtime_context_t *context = wasmtime_caller_context(caller); + ts_assert(args_and_results_len == 2); + + TSWasmStore *store = env; + TSLexer *lexer = store->current_lexer; + bool skip = args_and_results[1].i32; + lexer->advance(lexer, skip); + + uint8_t *memory = wasmtime_memory_data(context, &store->memory); + memcpy(&memory[store->lexer_address], &lexer->lookahead, sizeof(lexer->lookahead)); + return NULL; +} + +static wasm_trap_t *callback__lexer_mark_end( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + TSWasmStore *store = env; + TSLexer *lexer = store->current_lexer; + lexer->mark_end(lexer); + return NULL; +} + +static wasm_trap_t *callback__lexer_get_column( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + TSWasmStore *store = env; + TSLexer *lexer = store->current_lexer; + uint32_t result = lexer->get_column(lexer); + args_and_results[0].i32 = result; + return NULL; +} + +static wasm_trap_t *callback__lexer_is_at_included_range_start( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + TSWasmStore *store = env; + TSLexer *lexer = store->current_lexer; + bool result = lexer->is_at_included_range_start(lexer); + args_and_results[0].i32 = result; + return NULL; +} + +static wasm_trap_t *callback__lexer_eof( + void *env, + wasmtime_caller_t* caller, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + TSWasmStore *store = env; + TSLexer *lexer = store->current_lexer; + bool result = lexer->eof(lexer); + args_and_results[0].i32 = result; + return NULL; +} + +typedef struct { + uint32_t *storage_location; + wasmtime_func_unchecked_callback_t callback; + wasm_functype_t *type; +} FunctionDefinition; + +static void *copy(const void *data, size_t size) { + void *result = ts_malloc(size); + memcpy(result, data, size); + return result; +} + +static void *copy_unsized_static_array( + const uint8_t *data, + int32_t start_address, + const int32_t all_addresses[], + size_t address_count +) { + int32_t end_address = 0; + for (unsigned i = 0; i < address_count; i++) { + if (all_addresses[i] > start_address) { + if (!end_address || all_addresses[i] < end_address) { + end_address = all_addresses[i]; + } + } + } + + if (!end_address) return NULL; + size_t size = end_address - start_address; + void *result = ts_malloc(size); + memcpy(result, &data[start_address], size); + return result; +} + +static void *copy_strings( + const uint8_t *data, + int32_t array_address, + size_t count, + StringData *string_data +) { + const char **result = ts_malloc(count * sizeof(char *)); + for (unsigned i = 0; i < count; i++) { + int32_t address; + memcpy(&address, &data[array_address + i * sizeof(address)], sizeof(address)); + if (address == 0) { + result[i] = (const char *)-1; + } else { + const uint8_t *string = &data[address]; + uint32_t len = strlen((const char *)string); + result[i] = (const char *)(uintptr_t)string_data->size; + array_extend(string_data, len + 1, string); + } + } + for (unsigned i = 0; i < count; i++) { + if (result[i] == (const char *)-1) { + result[i] = NULL; + } else { + result[i] = string_data->contents + (uintptr_t)result[i]; + } + } + return result; +} + +static void *copy_string( + const uint8_t *data, + int32_t address +) { + const char *string = (const char *)&data[address]; + size_t len = strlen(string); + char *result = ts_malloc(len + 1); + memcpy(result, string, len + 1); + return result; +} + +static bool name_eq(const wasm_name_t *name, const char *string) { + return strncmp(string, name->data, name->size) == 0; +} + +static inline wasm_functype_t* wasm_functype_new_4_0( + wasm_valtype_t* p1, + wasm_valtype_t* p2, + wasm_valtype_t* p3, + wasm_valtype_t* p4 +) { + wasm_valtype_t* ps[4] = {p1, p2, p3, p4}; + wasm_valtype_vec_t params, results; + wasm_valtype_vec_new(¶ms, 4, ps); + wasm_valtype_vec_new_empty(&results); + return wasm_functype_new(¶ms, &results); +} + +#define format(output, ...) \ + do { \ + size_t message_length = snprintf((char *)NULL, 0, __VA_ARGS__); \ + *output = ts_malloc(message_length + 1); \ + snprintf(*output, message_length + 1, __VA_ARGS__); \ + } while (0) + +WasmLanguageId *language_id_new(void) { + WasmLanguageId *self = ts_malloc(sizeof(WasmLanguageId)); + self->is_language_deleted = false; + self->ref_count = 1; + return self; +} + +WasmLanguageId *language_id_clone(WasmLanguageId *self) { + atomic_inc(&self->ref_count); + return self; +} + +void language_id_delete(WasmLanguageId *self) { + if (atomic_dec(&self->ref_count) == 0) { + ts_free(self); + } +} + +static wasmtime_extern_t get_builtin_extern( + wasmtime_table_t *table, + unsigned index +) { + return (wasmtime_extern_t) { + .kind = WASMTIME_EXTERN_FUNC, + .of.func = (wasmtime_func_t) { + .store_id = table->store_id, + .__private = index + } + }; +} + +static bool ts_wasm_store__provide_builtin_import( + TSWasmStore *self, + const wasm_name_t *import_name, + wasmtime_extern_t *import +) { + wasmtime_error_t *error = NULL; + wasmtime_context_t *context = wasmtime_store_context(self->store); + + // Dynamic linking parameters + if (name_eq(import_name, "__memory_base")) { + wasmtime_val_t value = WASM_I32_VAL(self->current_memory_offset); + wasmtime_global_t global; + error = wasmtime_global_new(context, self->const_i32_type, &value, &global); + ts_assert(!error); + *import = (wasmtime_extern_t) {.kind = WASMTIME_EXTERN_GLOBAL, .of.global = global}; + } else if (name_eq(import_name, "__table_base")) { + wasmtime_val_t value = WASM_I32_VAL(self->current_function_table_offset); + wasmtime_global_t global; + error = wasmtime_global_new(context, self->const_i32_type, &value, &global); + ts_assert(!error); + *import = (wasmtime_extern_t) {.kind = WASMTIME_EXTERN_GLOBAL, .of.global = global}; + } else if (name_eq(import_name, "__stack_pointer")) { + *import = (wasmtime_extern_t) {.kind = WASMTIME_EXTERN_GLOBAL, .of.global = self->stack_pointer_global}; + } else if (name_eq(import_name, "__indirect_function_table")) { + *import = (wasmtime_extern_t) {.kind = WASMTIME_EXTERN_TABLE, .of.table = self->function_table}; + } else if (name_eq(import_name, "memory")) { + *import = (wasmtime_extern_t) {.kind = WASMTIME_EXTERN_MEMORY, .of.memory = self->memory}; + } + + // Builtin functions + else if (name_eq(import_name, "__assert_fail")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.assert_fail); + } else if (name_eq(import_name, "__cxa_atexit")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.at_exit); + } else if (name_eq(import_name, "args_get")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.args_get); + } else if (name_eq(import_name, "args_sizes_get")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.args_sizes_get); + } else if (name_eq(import_name, "abort")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.abort); + } else if (name_eq(import_name, "proc_exit")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.proc_exit); + } else if (name_eq(import_name, "emscripten_notify_memory_growth")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.notify_memory_growth); + } else if (name_eq(import_name, "tree_sitter_debug_message")) { + *import = get_builtin_extern(&self->function_table, self->builtin_fn_indices.debug_message); + } else { + return false; + } + + return true; +} + +static bool ts_wasm_store__call_module_initializer( + TSWasmStore *self, + const wasm_name_t *export_name, + wasmtime_extern_t *export, + wasm_trap_t **trap +) { + if ( + name_eq(export_name, "_initialize") || + name_eq(export_name, "__wasm_apply_data_relocs") || + name_eq(export_name, "__wasm_call_ctors") + ) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + wasmtime_func_t initialization_func = export->of.func; + wasmtime_error_t *error = wasmtime_func_call(context, &initialization_func, NULL, 0, NULL, 0, trap); + ts_assert(!error); + return true; + } else { + return false; + } +} + +TSWasmStore *ts_wasm_store_new(TSWasmEngine *engine, TSWasmError *wasm_error) { + TSWasmStore *self = ts_calloc(1, sizeof(TSWasmStore)); + wasmtime_store_t *store = wasmtime_store_new(engine, self, NULL); + wasmtime_context_t *context = wasmtime_store_context(store); + wasmtime_error_t *error = NULL; + wasm_trap_t *trap = NULL; + wasm_message_t message = WASM_EMPTY_VEC; + wasm_exporttype_vec_t export_types = WASM_EMPTY_VEC; + wasm_importtype_vec_t import_types = WASM_EMPTY_VEC; + wasmtime_extern_t *imports = NULL; + wasmtime_module_t *stdlib_module = NULL; + wasm_memorytype_t *memory_type = NULL; + wasm_tabletype_t *table_type = NULL; + + // Define functions called by scanners via function pointers on the lexer. + LexerInWasmMemory lexer = { + .lookahead = 0, + .result_symbol = 0, + }; + FunctionDefinition lexer_definitions[] = { + { + (uint32_t *)&lexer.advance, + callback__lexer_advance, + wasm_functype_new_2_0(wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + (uint32_t *)&lexer.mark_end, + callback__lexer_mark_end, + wasm_functype_new_1_0(wasm_valtype_new_i32()) + }, + { + (uint32_t *)&lexer.get_column, + callback__lexer_get_column, + wasm_functype_new_1_1(wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + (uint32_t *)&lexer.is_at_included_range_start, + callback__lexer_is_at_included_range_start, + wasm_functype_new_1_1(wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + (uint32_t *)&lexer.eof, + callback__lexer_eof, + wasm_functype_new_1_1(wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + }; + + // Define builtin functions that can be imported by scanners. + BuiltinFunctionIndices builtin_fn_indices; + FunctionDefinition builtin_definitions[] = { + { + &builtin_fn_indices.proc_exit, + callback__abort, + wasm_functype_new_1_0(wasm_valtype_new_i32()) + }, + { + &builtin_fn_indices.abort, + callback__abort, + wasm_functype_new_0_0() + }, + { + &builtin_fn_indices.assert_fail, + callback__abort, + wasm_functype_new_4_0(wasm_valtype_new_i32(), wasm_valtype_new_i32(), wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + &builtin_fn_indices.notify_memory_growth, + callback__noop, + wasm_functype_new_1_0(wasm_valtype_new_i32()) + }, + { + &builtin_fn_indices.debug_message, + callback__debug_message, + wasm_functype_new_2_0(wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + &builtin_fn_indices.at_exit, + callback__noop, + wasm_functype_new_3_1(wasm_valtype_new_i32(), wasm_valtype_new_i32(), wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + &builtin_fn_indices.args_get, + callback__noop, + wasm_functype_new_2_1(wasm_valtype_new_i32(), wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + { + &builtin_fn_indices.args_sizes_get, + callback__noop, + wasm_functype_new_2_1(wasm_valtype_new_i32(), wasm_valtype_new_i32(), wasm_valtype_new_i32()) + }, + }; + + // Create all of the wasm functions. + unsigned builtin_definitions_len = array_len(builtin_definitions); + unsigned lexer_definitions_len = array_len(lexer_definitions); + for (unsigned i = 0; i < builtin_definitions_len; i++) { + FunctionDefinition *definition = &builtin_definitions[i]; + wasmtime_func_t func; + wasmtime_func_new_unchecked(context, definition->type, definition->callback, self, NULL, &func); + *definition->storage_location = func.__private; + wasm_functype_delete(definition->type); + } + for (unsigned i = 0; i < lexer_definitions_len; i++) { + FunctionDefinition *definition = &lexer_definitions[i]; + wasmtime_func_t func; + wasmtime_func_new_unchecked(context, definition->type, definition->callback, self, NULL, &func); + *definition->storage_location = func.__private; + wasm_functype_delete(definition->type); + } + + // Compile the stdlib module. + error = wasmtime_module_new(engine, STDLIB_WASM, STDLIB_WASM_LEN, &stdlib_module); + if (error) { + wasmtime_error_message(error, &message); + wasm_error->kind = TSWasmErrorKindCompile; + format( + &wasm_error->message, + "failed to compile wasm stdlib: %.*s", + (int)message.size, message.data + ); + goto error; + } + + // Retrieve the stdlib module's imports. + wasmtime_module_imports(stdlib_module, &import_types); + + // Find the initial number of memory pages needed by the stdlib. + const wasm_memorytype_t *stdlib_memory_type = NULL; + for (unsigned i = 0; i < import_types.size; i++) { + wasm_importtype_t *import_type = import_types.data[i]; + const wasm_name_t *import_name = wasm_importtype_name(import_type); + if (name_eq(import_name, "memory")) { + const wasm_externtype_t *type = wasm_importtype_type(import_type); + stdlib_memory_type = wasm_externtype_as_memorytype_const(type); + } + } + if (!stdlib_memory_type) { + wasm_error->kind = TSWasmErrorKindCompile; + format( + &wasm_error->message, + "wasm stdlib is missing the 'memory' import" + ); + goto error; + } + + // Initialize store's memory + uint64_t initial_memory_pages = wasmtime_memorytype_minimum(stdlib_memory_type); + wasm_limits_t memory_limits = {.min = initial_memory_pages, .max = MAX_MEMORY_SIZE}; + memory_type = wasm_memorytype_new(&memory_limits); + wasmtime_memory_t memory; + error = wasmtime_memory_new(context, memory_type, &memory); + if (error) { + wasmtime_error_message(error, &message); + wasm_error->kind = TSWasmErrorKindAllocate; + format( + &wasm_error->message, + "failed to allocate wasm memory: %.*s", + (int)message.size, message.data + ); + goto error; + } + wasm_memorytype_delete(memory_type); + memory_type = NULL; + + // Initialize store's function table + wasm_limits_t table_limits = {.min = 1, .max = wasm_limits_max_default}; + table_type = wasm_tabletype_new(wasm_valtype_new(WASM_FUNCREF), &table_limits); + wasmtime_val_t initializer = {.kind = WASMTIME_FUNCREF}; + wasmtime_table_t function_table; + error = wasmtime_table_new(context, table_type, &initializer, &function_table); + if (error) { + wasmtime_error_message(error, &message); + wasm_error->kind = TSWasmErrorKindAllocate; + format( + &wasm_error->message, + "failed to allocate wasm table: %.*s", + (int)message.size, message.data + ); + goto error; + } + wasm_tabletype_delete(table_type); + table_type = NULL; + + unsigned stdlib_symbols_len = array_len(STDLIB_SYMBOLS); + + // Define globals for the stack and heap start addresses. + wasm_globaltype_t *const_i32_type = wasm_globaltype_new(wasm_valtype_new_i32(), WASM_CONST); + wasm_globaltype_t *var_i32_type = wasm_globaltype_new(wasm_valtype_new_i32(), WASM_VAR); + + wasmtime_val_t stack_pointer_value = WASM_I32_VAL(0); + wasmtime_global_t stack_pointer_global; + error = wasmtime_global_new(context, var_i32_type, &stack_pointer_value, &stack_pointer_global); + wasm_globaltype_delete(var_i32_type); + ts_assert(!error); + + *self = (TSWasmStore) { + .engine = wasmtime_engine_clone(engine), + .store = store, + .memory = memory, + .function_table = function_table, + .language_instances = array_new(), + .stdlib_fn_indices = ts_calloc(stdlib_symbols_len, sizeof(uint32_t)), + .builtin_fn_indices = builtin_fn_indices, + .stack_pointer_global = stack_pointer_global, + .current_memory_offset = 0, + .current_function_table_offset = 0, + .const_i32_type = const_i32_type, + }; + + // Set up the imports for the stdlib module. + imports = ts_calloc(import_types.size, sizeof(wasmtime_extern_t)); + for (unsigned i = 0; i < import_types.size; i++) { + wasm_importtype_t *type = import_types.data[i]; + const wasm_name_t *import_name = wasm_importtype_name(type); + if (!ts_wasm_store__provide_builtin_import(self, import_name, &imports[i])) { + wasm_error->kind = TSWasmErrorKindInstantiate; + format( + &wasm_error->message, + "unexpected import in wasm stdlib: %.*s\n", + (int)import_name->size, import_name->data + ); + goto error; + } + } + + // Instantiate the stdlib module. + wasmtime_instance_t instance; + error = wasmtime_instance_new(context, stdlib_module, imports, import_types.size, &instance, &trap); + ts_free(imports); + imports = NULL; + if (error) { + wasmtime_error_message(error, &message); + wasm_error->kind = TSWasmErrorKindInstantiate; + format( + &wasm_error->message, + "failed to instantiate wasm stdlib module: %.*s", + (int)message.size, message.data + ); + goto error; + } + if (trap) { + wasm_trap_message(trap, &message); + wasm_error->kind = TSWasmErrorKindInstantiate; + format( + &wasm_error->message, + "trapped when instantiating wasm stdlib module: %.*s", + (int)message.size, message.data + ); + goto error; + } + wasm_importtype_vec_delete(&import_types); + + // Process the stdlib module's exports. + for (unsigned i = 0; i < stdlib_symbols_len; i++) { + self->stdlib_fn_indices[i] = UINT32_MAX; + } + wasmtime_module_exports(stdlib_module, &export_types); + for (unsigned i = 0; i < export_types.size; i++) { + wasm_exporttype_t *export_type = export_types.data[i]; + const wasm_name_t *name = wasm_exporttype_name(export_type); + + char *export_name; + size_t name_len; + wasmtime_extern_t export = {.kind = WASM_EXTERN_GLOBAL}; + bool exists = wasmtime_instance_export_nth(context, &instance, i, &export_name, &name_len, &export); + ts_assert(exists); + + if (export.kind == WASMTIME_EXTERN_GLOBAL) { + if (name_eq(name, "__stack_pointer")) { + self->stack_pointer_global = export.of.global; + } + } + + if (export.kind == WASMTIME_EXTERN_FUNC) { + if (ts_wasm_store__call_module_initializer(self, name, &export, &trap)) { + if (trap) { + wasm_trap_message(trap, &message); + wasm_error->kind = TSWasmErrorKindInstantiate; + format( + &wasm_error->message, + "trap when calling stdlib relocation function: %.*s\n", + (int)message.size, message.data + ); + goto error; + } + continue; + } + + if (name_eq(name, "reset_heap")) { + self->builtin_fn_indices.reset_heap = export.of.func.__private; + continue; + } + + for (unsigned j = 0; j < stdlib_symbols_len; j++) { + if (name_eq(name, STDLIB_SYMBOLS[j])) { + self->stdlib_fn_indices[j] = export.of.func.__private; + break; + } + } + } + } + + if (self->builtin_fn_indices.reset_heap == UINT32_MAX) { + wasm_error->kind = TSWasmErrorKindInstantiate; + format( + &wasm_error->message, + "missing malloc reset function in wasm stdlib" + ); + goto error; + } + + for (unsigned i = 0; i < stdlib_symbols_len; i++) { + if (self->stdlib_fn_indices[i] == UINT32_MAX) { + wasm_error->kind = TSWasmErrorKindInstantiate; + format( + &wasm_error->message, + "missing exported symbol in wasm stdlib: %s", + STDLIB_SYMBOLS[i] + ); + goto error; + } + } + + wasm_exporttype_vec_delete(&export_types); + wasmtime_module_delete(stdlib_module); + + // Add all of the lexer callback functions to the function table. Store their function table + // indices on the in-memory lexer. + uint64_t table_index; + error = wasmtime_table_grow(context, &function_table, lexer_definitions_len, &initializer, &table_index); + if (error) { + wasmtime_error_message(error, &message); + wasm_error->kind = TSWasmErrorKindAllocate; + format( + &wasm_error->message, + "failed to grow wasm table to initial size: %.*s", + (int)message.size, message.data + ); + goto error; + } + for (unsigned i = 0; i < lexer_definitions_len; i++) { + FunctionDefinition *definition = &lexer_definitions[i]; + wasmtime_func_t func = {function_table.store_id, *definition->storage_location}; + wasmtime_val_t func_val = {.kind = WASMTIME_FUNCREF, .of.funcref = func}; + error = wasmtime_table_set(context, &function_table, table_index, &func_val); + ts_assert(!error); + *(int32_t *)(definition->storage_location) = table_index; + table_index++; + } + + self->current_function_table_offset = table_index; + self->lexer_address = initial_memory_pages * MEMORY_PAGE_SIZE; + self->current_memory_offset = self->lexer_address + sizeof(LexerInWasmMemory); + + // Grow the memory enough to hold the builtin lexer and serialization buffer. + uint32_t new_pages_needed = (self->current_memory_offset - self->lexer_address - 1) / MEMORY_PAGE_SIZE + 1; + uint64_t prev_memory_size; + wasmtime_memory_grow(context, &memory, new_pages_needed, &prev_memory_size); + + uint8_t *memory_data = wasmtime_memory_data(context, &memory); + memcpy(&memory_data[self->lexer_address], &lexer, sizeof(lexer)); + return self; + +error: + ts_free(self); + if (stdlib_module) wasmtime_module_delete(stdlib_module); + if (store) wasmtime_store_delete(store); + if (import_types.size) wasm_importtype_vec_delete(&import_types); + if (memory_type) wasm_memorytype_delete(memory_type); + if (table_type) wasm_tabletype_delete(table_type); + if (trap) wasm_trap_delete(trap); + if (error) wasmtime_error_delete(error); + if (message.size) wasm_byte_vec_delete(&message); + if (export_types.size) wasm_exporttype_vec_delete(&export_types); + if (imports) ts_free(imports); + return NULL; +} + +void ts_wasm_store_delete(TSWasmStore *self) { + if (!self) return; + ts_free(self->stdlib_fn_indices); + wasm_globaltype_delete(self->const_i32_type); + wasmtime_store_delete(self->store); + wasm_engine_delete(self->engine); + for (unsigned i = 0; i < self->language_instances.size; i++) { + LanguageWasmInstance *instance = array_get(&self->language_instances, i); + language_id_delete(instance->language_id); + } + array_delete(&self->language_instances); + ts_free(self); +} + +size_t ts_wasm_store_language_count(const TSWasmStore *self) { + size_t result = 0; + for (unsigned i = 0; i < self->language_instances.size; i++) { + const WasmLanguageId *id = array_get(&self->language_instances, i)->language_id; + if (!id->is_language_deleted) { + result++; + } + } + return result; +} + +static uint32_t ts_wasm_store__heap_address(TSWasmStore *self) { + return self->current_memory_offset + TREE_SITTER_SERIALIZATION_BUFFER_SIZE; +} + +static uint32_t ts_wasm_store__serialization_buffer_address(TSWasmStore *self) { + return self->current_memory_offset; +} + +static bool ts_wasm_store__instantiate( + TSWasmStore *self, + wasmtime_module_t *module, + const char *language_name, + const WasmDylinkInfo *dylink_info, + wasmtime_instance_t *result, + int32_t *language_address, + char **error_message +) { + wasmtime_error_t *error = NULL; + wasm_trap_t *trap = NULL; + wasm_message_t message = WASM_EMPTY_VEC; + char *language_function_name = NULL; + wasmtime_extern_t *imports = NULL; + wasmtime_context_t *context = wasmtime_store_context(self->store); + + // Grow the function table to make room for the new functions. + wasmtime_val_t initializer = {.kind = WASMTIME_FUNCREF}; + uint64_t prev_table_size; + error = wasmtime_table_grow(context, &self->function_table, dylink_info->table_size, &initializer, &prev_table_size); + if (error) { + format(error_message, "invalid function table size %u", dylink_info->table_size); + goto error; + } + + // Grow the memory to make room for the new data. + uint32_t needed_memory_size = ts_wasm_store__heap_address(self) + dylink_info->memory_size; + uint32_t current_memory_size = wasmtime_memory_data_size(context, &self->memory); + if (needed_memory_size > current_memory_size) { + uint32_t pages_to_grow = ( + needed_memory_size - current_memory_size + MEMORY_PAGE_SIZE - 1) / + MEMORY_PAGE_SIZE; + uint64_t prev_memory_size; + error = wasmtime_memory_grow(context, &self->memory, pages_to_grow, &prev_memory_size); + if (error) { + format(error_message, "invalid memory size %u", dylink_info->memory_size); + goto error; + } + } + + // Construct the language function name as string. + format(&language_function_name, "tree_sitter_%s", language_name); + + const uint64_t store_id = self->function_table.store_id; + + // Build the imports list for the module. + wasm_importtype_vec_t import_types = WASM_EMPTY_VEC; + wasmtime_module_imports(module, &import_types); + imports = ts_calloc(import_types.size, sizeof(wasmtime_extern_t)); + + for (unsigned i = 0; i < import_types.size; i++) { + const wasm_importtype_t *import_type = import_types.data[i]; + const wasm_name_t *import_name = wasm_importtype_name(import_type); + if (import_name->size == 0) { + format(error_message, "empty import name"); + goto error; + } + + if (ts_wasm_store__provide_builtin_import(self, import_name, &imports[i])) { + continue; + } + + bool defined_in_stdlib = false; + for (unsigned j = 0; j < array_len(STDLIB_SYMBOLS); j++) { + if (name_eq(import_name, STDLIB_SYMBOLS[j])) { + uint16_t address = self->stdlib_fn_indices[j]; + imports[i] = (wasmtime_extern_t) {.kind = WASMTIME_EXTERN_FUNC, .of.func = {store_id, address}}; + defined_in_stdlib = true; + break; + } + } + + if (!defined_in_stdlib) { + format( + error_message, + "invalid import '%.*s'\n", + (int)import_name->size, import_name->data + ); + goto error; + } + } + + wasmtime_instance_t instance; + error = wasmtime_instance_new(context, module, imports, import_types.size, &instance, &trap); + wasm_importtype_vec_delete(&import_types); + ts_free(imports); + imports = NULL; + if (error) { + wasmtime_error_message(error, &message); + format( + error_message, + "error instantiating wasm module: %.*s\n", + (int)message.size, message.data + ); + goto error; + } + if (trap) { + wasm_trap_message(trap, &message); + format( + error_message, + "trap when instantiating wasm module: %.*s\n", + (int)message.size, message.data + ); + goto error; + } + + self->current_memory_offset += dylink_info->memory_size; + self->current_function_table_offset += dylink_info->table_size; + + // Process the module's exports. + bool found_language = false; + wasmtime_extern_t language_extern; + wasm_exporttype_vec_t export_types = WASM_EMPTY_VEC; + wasmtime_module_exports(module, &export_types); + for (unsigned i = 0; i < export_types.size; i++) { + wasm_exporttype_t *export_type = export_types.data[i]; + const wasm_name_t *name = wasm_exporttype_name(export_type); + + size_t name_len; + char *export_name; + wasmtime_extern_t export = {.kind = WASM_EXTERN_GLOBAL}; + bool exists = wasmtime_instance_export_nth(context, &instance, i, &export_name, &name_len, &export); + ts_assert(exists); + + // If the module exports an initialization or data-relocation function, call it. + if (ts_wasm_store__call_module_initializer(self, name, &export, &trap)) { + if (trap) { + wasm_trap_message(trap, &message); + format( + error_message, + "trap when calling data relocation function: %.*s\n", + (int)message.size, message.data + ); + goto error; + } + } + + // Find the main language function for the module. + else if (name_eq(name, language_function_name)) { + language_extern = export; + found_language = true; + } + } + wasm_exporttype_vec_delete(&export_types); + + if (!found_language) { + format( + error_message, + "module did not contain language function: %s", + language_function_name + ); + goto error; + } + + // Invoke the language function to get the static address of the language object. + wasmtime_func_t language_func = language_extern.of.func; + wasmtime_val_t language_address_val; + error = wasmtime_func_call(context, &language_func, NULL, 0, &language_address_val, 1, &trap); + ts_assert(!error); + if (trap) { + wasm_trap_message(trap, &message); + format( + error_message, + "trapped when calling language function: %s: %.*s\n", + language_function_name, (int)message.size, message.data + ); + goto error; + } + + if (language_address_val.kind != WASMTIME_I32) { + format( + error_message, + "language function did not return an integer: %s\n", + language_function_name + ); + goto error; + } + + ts_free(language_function_name); + *result = instance; + *language_address = language_address_val.of.i32; + return true; + +error: + if (language_function_name) ts_free(language_function_name); + if (message.size) wasm_byte_vec_delete(&message); + if (error) wasmtime_error_delete(error); + if (trap) wasm_trap_delete(trap); + if (imports) ts_free(imports); + return false; +} + +static bool ts_wasm_store__sentinel_lex_fn(TSLexer *_lexer, TSStateId state) { + return false; +} + +const TSLanguage *ts_wasm_store_load_language( + TSWasmStore *self, + const char *language_name, + const char *wasm, + uint32_t wasm_len, + TSWasmError *wasm_error +) { + WasmDylinkInfo dylink_info; + wasmtime_module_t *module = NULL; + wasmtime_error_t *error = NULL; + wasm_error->kind = TSWasmErrorKindNone; + + if (!wasm_dylink_info__parse((const unsigned char *)wasm, wasm_len, &dylink_info)) { + wasm_error->kind = TSWasmErrorKindParse; + format(&wasm_error->message, "failed to parse dylink section of wasm module"); + goto error; + } + + // Compile the wasm code. + error = wasmtime_module_new(self->engine, (const uint8_t *)wasm, wasm_len, &module); + if (error) { + wasm_message_t message; + wasmtime_error_message(error, &message); + wasm_error->kind = TSWasmErrorKindCompile; + format(&wasm_error->message, "error compiling wasm module: %.*s", (int)message.size, message.data); + wasm_byte_vec_delete(&message); + goto error; + } + + // Instantiate the module in this store. + wasmtime_instance_t instance; + int32_t language_address; + if (!ts_wasm_store__instantiate( + self, + module, + language_name, + &dylink_info, + &instance, + &language_address, + &wasm_error->message + )) { + wasm_error->kind = TSWasmErrorKindInstantiate; + goto error; + } + + // Copy all of the static data out of the language object in wasm memory, + // constructing a native language object. + LanguageInWasmMemory wasm_language; + wasmtime_context_t *context = wasmtime_store_context(self->store); + const uint8_t *memory = wasmtime_memory_data(context, &self->memory); + memcpy(&wasm_language, &memory[language_address], sizeof(LanguageInWasmMemory)); + + bool has_supertypes = + wasm_language.abi_version > LANGUAGE_VERSION_WITH_RESERVED_WORDS && + wasm_language.supertype_count > 0; + + int32_t addresses[] = { + wasm_language.parse_table, + wasm_language.small_parse_table, + wasm_language.small_parse_table_map, + wasm_language.parse_actions, + wasm_language.symbol_names, + wasm_language.field_names, + wasm_language.field_map_slices, + wasm_language.field_map_entries, + wasm_language.symbol_metadata, + wasm_language.public_symbol_map, + wasm_language.alias_map, + wasm_language.alias_sequences, + wasm_language.lex_modes, + wasm_language.lex_fn, + wasm_language.keyword_lex_fn, + wasm_language.primary_state_ids, + wasm_language.name, + wasm_language.reserved_words, + has_supertypes ? wasm_language.supertype_symbols : 0, + has_supertypes ? wasm_language.supertype_map_entries : 0, + has_supertypes ? wasm_language.supertype_map_slices : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.states : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.symbol_map : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.create : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.destroy : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.scan : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.serialize : 0, + wasm_language.external_token_count > 0 ? wasm_language.external_scanner.deserialize : 0, + language_address, + self->current_memory_offset, + }; + uint32_t address_count = array_len(addresses); + + TSLanguage *language = ts_calloc(1, sizeof(TSLanguage)); + StringData symbol_name_buffer = array_new(); + StringData field_name_buffer = array_new(); + + *language = (TSLanguage) { + .abi_version = wasm_language.abi_version, + .symbol_count = wasm_language.symbol_count, + .alias_count = wasm_language.alias_count, + .token_count = wasm_language.token_count, + .external_token_count = wasm_language.external_token_count, + .state_count = wasm_language.state_count, + .large_state_count = wasm_language.large_state_count, + .production_id_count = wasm_language.production_id_count, + .field_count = wasm_language.field_count, + .supertype_count = wasm_language.supertype_count, + .max_alias_sequence_length = wasm_language.max_alias_sequence_length, + .keyword_capture_token = wasm_language.keyword_capture_token, + .metadata = wasm_language.metadata, + .parse_table = copy( + &memory[wasm_language.parse_table], + wasm_language.large_state_count * wasm_language.symbol_count * sizeof(uint16_t) + ), + .parse_actions = copy_unsized_static_array( + memory, + wasm_language.parse_actions, + addresses, + address_count + ), + .symbol_names = copy_strings( + memory, + wasm_language.symbol_names, + wasm_language.symbol_count + wasm_language.alias_count, + &symbol_name_buffer + ), + .symbol_metadata = copy( + &memory[wasm_language.symbol_metadata], + (wasm_language.symbol_count + wasm_language.alias_count) * sizeof(TSSymbolMetadata) + ), + .public_symbol_map = copy( + &memory[wasm_language.public_symbol_map], + (wasm_language.symbol_count + wasm_language.alias_count) * sizeof(TSSymbol) + ), + .lex_modes = copy( + &memory[wasm_language.lex_modes], + wasm_language.state_count * sizeof(TSLexerMode) + ), + }; + + if (language->field_count > 0 && language->production_id_count > 0) { + language->field_map_slices = copy( + &memory[wasm_language.field_map_slices], + wasm_language.production_id_count * sizeof(TSMapSlice) + ); + + // Determine the number of field map entries by finding the greatest index + // in any of the slices. + uint32_t field_map_entry_count = 0; + for (uint32_t i = 0; i < wasm_language.production_id_count; i++) { + TSMapSlice slice = language->field_map_slices[i]; + uint32_t slice_end = slice.index + slice.length; + if (slice_end > field_map_entry_count) { + field_map_entry_count = slice_end; + } + } + + language->field_map_entries = copy( + &memory[wasm_language.field_map_entries], + field_map_entry_count * sizeof(TSFieldMapEntry) + ); + language->field_names = copy_strings( + memory, + wasm_language.field_names, + wasm_language.field_count + 1, + &field_name_buffer + ); + } + + if (has_supertypes) { + language->supertype_symbols = copy( + &memory[wasm_language.supertype_symbols], + wasm_language.supertype_count * sizeof(TSSymbol) + ); + + // Determine the number of supertype map slices by finding the greatest + // supertype ID. + int largest_supertype = 0; + for (unsigned i = 0; i < language->supertype_count; i++) { + TSSymbol supertype = language->supertype_symbols[i]; + if (supertype > largest_supertype) { + largest_supertype = supertype; + } + } + + language->supertype_map_slices = copy( + &memory[wasm_language.supertype_map_slices], + (largest_supertype + 1) * sizeof(TSMapSlice) + ); + + TSSymbol last_supertype = language->supertype_symbols[language->supertype_count - 1]; + TSMapSlice last_slice = language->supertype_map_slices[last_supertype]; + uint32_t supertype_map_entry_count = last_slice.index + last_slice.length; + + language->supertype_map_entries = copy( + &memory[wasm_language.supertype_map_entries], + supertype_map_entry_count * sizeof(char *) + ); + } + + if (language->max_alias_sequence_length > 0 && language->production_id_count > 0) { + // The alias map contains symbols, alias counts, and aliases, terminated by a null symbol. + int32_t alias_map_size = 0; + for (;;) { + TSSymbol symbol; + memcpy(&symbol, &memory[wasm_language.alias_map + alias_map_size], sizeof(symbol)); + alias_map_size += sizeof(TSSymbol); + if (symbol == 0) break; + uint16_t value_count; + memcpy(&value_count, &memory[wasm_language.alias_map + alias_map_size], sizeof(value_count)); + alias_map_size += sizeof(uint16_t); + alias_map_size += value_count * sizeof(TSSymbol); + } + language->alias_map = copy( + &memory[wasm_language.alias_map], + alias_map_size + ); + language->alias_sequences = copy( + &memory[wasm_language.alias_sequences], + wasm_language.production_id_count * wasm_language.max_alias_sequence_length * sizeof(TSSymbol) + ); + } + + if (language->state_count > language->large_state_count) { + uint32_t small_state_count = wasm_language.state_count - wasm_language.large_state_count; + language->small_parse_table_map = copy( + &memory[wasm_language.small_parse_table_map], + small_state_count * sizeof(uint32_t) + ); + language->small_parse_table = copy_unsized_static_array( + memory, + wasm_language.small_parse_table, + addresses, + address_count + ); + } + + if (language->abi_version >= LANGUAGE_VERSION_WITH_PRIMARY_STATES) { + language->primary_state_ids = copy( + &memory[wasm_language.primary_state_ids], + wasm_language.state_count * sizeof(TSStateId) + ); + } + + if (language->abi_version >= LANGUAGE_VERSION_WITH_RESERVED_WORDS) { + language->name = copy_string(memory, wasm_language.name); + language->reserved_words = copy( + &memory[wasm_language.reserved_words], + wasm_language.max_reserved_word_set_size * sizeof(TSSymbol) + ); + language->max_reserved_word_set_size = wasm_language.max_reserved_word_set_size; + } + + if (language->external_token_count > 0) { + language->external_scanner.symbol_map = copy( + &memory[wasm_language.external_scanner.symbol_map], + wasm_language.external_token_count * sizeof(TSSymbol) + ); + language->external_scanner.states = (void *)(uintptr_t)wasm_language.external_scanner.states; + } + + unsigned name_len = strlen(language_name); + char *name = ts_malloc(name_len + 1); + memcpy(name, language_name, name_len); + name[name_len] = '\0'; + + LanguageWasmModule *language_module = ts_malloc(sizeof(LanguageWasmModule)); + *language_module = (LanguageWasmModule) { + .language_id = language_id_new(), + .module = module, + .name = name, + .symbol_name_buffer = symbol_name_buffer.contents, + .field_name_buffer = field_name_buffer.contents, + .dylink_info = dylink_info, + .ref_count = 1, + }; + + // The lex functions are not used for wasm languages. Use those two fields + // to mark this language as WASM-based and to store the language's + // WASM-specific data. + language->lex_fn = ts_wasm_store__sentinel_lex_fn; + language->keyword_lex_fn = (bool (*)(TSLexer *, TSStateId))language_module; + + // Clear out any instances of languages that have been deleted. + for (unsigned i = 0; i < self->language_instances.size; i++) { + WasmLanguageId *id = array_get(&self->language_instances, i)->language_id; + if (id->is_language_deleted) { + language_id_delete(id); + array_erase(&self->language_instances, i); + i--; + } + } + + // Store this store's instance of this language module. + array_push(&self->language_instances, ((LanguageWasmInstance) { + .language_id = language_id_clone(language_module->language_id), + .instance = instance, + .external_states_address = wasm_language.external_scanner.states, + .lex_main_fn_index = wasm_language.lex_fn, + .lex_keyword_fn_index = wasm_language.keyword_lex_fn, + .scanner_create_fn_index = wasm_language.external_scanner.create, + .scanner_destroy_fn_index = wasm_language.external_scanner.destroy, + .scanner_serialize_fn_index = wasm_language.external_scanner.serialize, + .scanner_deserialize_fn_index = wasm_language.external_scanner.deserialize, + .scanner_scan_fn_index = wasm_language.external_scanner.scan, + })); + + return language; + +error: + if (module) wasmtime_module_delete(module); + return NULL; +} + +bool ts_wasm_store_add_language( + TSWasmStore *self, + const TSLanguage *language, + uint32_t *index +) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + const LanguageWasmModule *language_module = (void *)language->keyword_lex_fn; + + // Search for this store's instance of the language module. Also clear out any + // instances of languages that have been deleted. + bool exists = false; + for (unsigned i = 0; i < self->language_instances.size; i++) { + WasmLanguageId *id = array_get(&self->language_instances, i)->language_id; + if (id->is_language_deleted) { + language_id_delete(id); + array_erase(&self->language_instances, i); + i--; + } else if (id == language_module->language_id) { + exists = true; + *index = i; + } + } + + // If the language module has not been instantiated in this store, then add + // it to this store. + if (!exists) { + *index = self->language_instances.size; + char *message; + wasmtime_instance_t instance; + int32_t language_address; + if (!ts_wasm_store__instantiate( + self, + language_module->module, + language_module->name, + &language_module->dylink_info, + &instance, + &language_address, + &message + )) { + ts_free(message); + return false; + } + + LanguageInWasmMemory wasm_language; + const uint8_t *memory = wasmtime_memory_data(context, &self->memory); + memcpy(&wasm_language, &memory[language_address], sizeof(LanguageInWasmMemory)); + array_push(&self->language_instances, ((LanguageWasmInstance) { + .language_id = language_id_clone(language_module->language_id), + .instance = instance, + .external_states_address = wasm_language.external_scanner.states, + .lex_main_fn_index = wasm_language.lex_fn, + .lex_keyword_fn_index = wasm_language.keyword_lex_fn, + .scanner_create_fn_index = wasm_language.external_scanner.create, + .scanner_destroy_fn_index = wasm_language.external_scanner.destroy, + .scanner_serialize_fn_index = wasm_language.external_scanner.serialize, + .scanner_deserialize_fn_index = wasm_language.external_scanner.deserialize, + .scanner_scan_fn_index = wasm_language.external_scanner.scan, + })); + } + + return true; +} + +void ts_wasm_store_reset_heap(TSWasmStore *self) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + wasmtime_func_t func = { + self->function_table.store_id, + self->builtin_fn_indices.reset_heap + }; + wasm_trap_t *trap = NULL; + wasmtime_val_t args[1] = { + {.of.i32 = ts_wasm_store__heap_address(self), .kind = WASMTIME_I32}, + }; + + wasmtime_error_t *error = wasmtime_func_call(context, &func, args, 1, NULL, 0, &trap); + ts_assert(!error); + ts_assert(!trap); +} + +bool ts_wasm_store_start(TSWasmStore *self, TSLexer *lexer, const TSLanguage *language) { + uint32_t instance_index; + if (!ts_wasm_store_add_language(self, language, &instance_index)) return false; + self->current_lexer = lexer; + self->current_instance = array_get(&self->language_instances, instance_index); + self->has_error = false; + ts_wasm_store_reset_heap(self); + return true; +} + +void ts_wasm_store_reset(TSWasmStore *self) { + self->current_lexer = NULL; + self->current_instance = NULL; + self->has_error = false; + ts_wasm_store_reset_heap(self); +} + +static void ts_wasm_store__call( + TSWasmStore *self, + int32_t function_index, + wasmtime_val_raw_t *args_and_results, + size_t args_and_results_len +) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + wasmtime_val_t value; + bool succeeded = wasmtime_table_get(context, &self->function_table, function_index, &value); + ts_assert(succeeded); + ts_assert(value.kind == WASMTIME_FUNCREF); + wasmtime_func_t func = value.of.funcref; + + wasm_trap_t *trap = NULL; + wasmtime_error_t *error = wasmtime_func_call_unchecked(context, &func, args_and_results, args_and_results_len, &trap); + if (error) { + // wasm_message_t message; + // wasmtime_error_message(error, &message); + // fprintf( + // stderr, + // "error in wasm module: %.*s\n", + // (int)message.size, message.data + // ); + wasmtime_error_delete(error); + self->has_error = true; + } else if (trap) { + // wasm_message_t message; + // wasm_trap_message(trap, &message); + // fprintf( + // stderr, + // "trap in wasm module: %.*s\n", + // (int)message.size, message.data + // ); + wasm_trap_delete(trap); + self->has_error = true; + } +} + +// The data fields of TSLexer, without the function pointers. +// +// This portion of the struct needs to be copied in and out +// of wasm memory before and after calling a scan function. +typedef struct { + int32_t lookahead; + TSSymbol result_symbol; +} TSLexerDataPrefix; + +static bool ts_wasm_store__call_lex_function(TSWasmStore *self, unsigned function_index, TSStateId state) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + uint8_t *memory_data = wasmtime_memory_data(context, &self->memory); + memcpy( + &memory_data[self->lexer_address], + self->current_lexer, + sizeof(TSLexerDataPrefix) + ); + + wasmtime_val_raw_t args[2] = { + {.i32 = self->lexer_address}, + {.i32 = state}, + }; + ts_wasm_store__call(self, function_index, args, 2); + if (self->has_error) return false; + bool result = args[0].i32; + + memcpy( + self->current_lexer, + &memory_data[self->lexer_address], + sizeof(TSLexerDataPrefix) + ); + return result; +} + +bool ts_wasm_store_call_lex_main(TSWasmStore *self, TSStateId state) { + return ts_wasm_store__call_lex_function( + self, + self->current_instance->lex_main_fn_index, + state + ); +} + +bool ts_wasm_store_call_lex_keyword(TSWasmStore *self, TSStateId state) { + return ts_wasm_store__call_lex_function( + self, + self->current_instance->lex_keyword_fn_index, + state + ); +} + +uint32_t ts_wasm_store_call_scanner_create(TSWasmStore *self) { + wasmtime_val_raw_t args[1] = {{.i32 = 0}}; + ts_wasm_store__call(self, self->current_instance->scanner_create_fn_index, args, 1); + if (self->has_error) return 0; + return args[0].i32; +} + +void ts_wasm_store_call_scanner_destroy(TSWasmStore *self, uint32_t scanner_address) { + if (self->current_instance) { + wasmtime_val_raw_t args[1] = {{.i32 = scanner_address}}; + ts_wasm_store__call(self, self->current_instance->scanner_destroy_fn_index, args, 1); + } +} + +bool ts_wasm_store_call_scanner_scan( + TSWasmStore *self, + uint32_t scanner_address, + uint32_t valid_tokens_ix +) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + uint8_t *memory_data = wasmtime_memory_data(context, &self->memory); + + memcpy( + &memory_data[self->lexer_address], + self->current_lexer, + sizeof(TSLexerDataPrefix) + ); + + uint32_t valid_tokens_address = + self->current_instance->external_states_address + + (valid_tokens_ix * sizeof(bool)); + wasmtime_val_raw_t args[3] = { + {.i32 = scanner_address}, + {.i32 = self->lexer_address}, + {.i32 = valid_tokens_address} + }; + ts_wasm_store__call(self, self->current_instance->scanner_scan_fn_index, args, 3); + if (self->has_error) return false; + + memcpy( + self->current_lexer, + &memory_data[self->lexer_address], + sizeof(TSLexerDataPrefix) + ); + return args[0].i32; +} + +uint32_t ts_wasm_store_call_scanner_serialize( + TSWasmStore *self, + uint32_t scanner_address, + char *buffer +) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + uint8_t *memory_data = wasmtime_memory_data(context, &self->memory); + uint32_t serialization_buffer_address = ts_wasm_store__serialization_buffer_address(self); + + wasmtime_val_raw_t args[2] = { + {.i32 = scanner_address}, + {.i32 = serialization_buffer_address}, + }; + ts_wasm_store__call(self, self->current_instance->scanner_serialize_fn_index, args, 2); + if (self->has_error) return 0; + + uint32_t length = args[0].i32; + if (length > TREE_SITTER_SERIALIZATION_BUFFER_SIZE) { + self->has_error = true; + return 0; + } + + if (length > 0) { + memcpy( + ((Lexer *)self->current_lexer)->debug_buffer, + &memory_data[serialization_buffer_address], + length + ); + } + return length; +} + +void ts_wasm_store_call_scanner_deserialize( + TSWasmStore *self, + uint32_t scanner_address, + const char *buffer, + unsigned length +) { + wasmtime_context_t *context = wasmtime_store_context(self->store); + uint8_t *memory_data = wasmtime_memory_data(context, &self->memory); + uint32_t serialization_buffer_address = ts_wasm_store__serialization_buffer_address(self); + + if (length > 0) { + memcpy( + &memory_data[serialization_buffer_address], + buffer, + length + ); + } + + wasmtime_val_raw_t args[3] = { + {.i32 = scanner_address}, + {.i32 = serialization_buffer_address}, + {.i32 = length}, + }; + ts_wasm_store__call(self, self->current_instance->scanner_deserialize_fn_index, args, 3); +} + +bool ts_wasm_store_has_error(const TSWasmStore *self) { + return self->has_error; +} + +bool ts_language_is_wasm(const TSLanguage *self) { + return self->lex_fn == ts_wasm_store__sentinel_lex_fn; +} + +static inline LanguageWasmModule *ts_language__wasm_module(const TSLanguage *self) { + return (LanguageWasmModule *)self->keyword_lex_fn; +} + +void ts_wasm_language_retain(const TSLanguage *self) { + LanguageWasmModule *module = ts_language__wasm_module(self); + ts_assert(module->ref_count > 0); + atomic_inc(&module->ref_count); +} + +void ts_wasm_language_release(const TSLanguage *self) { + LanguageWasmModule *module = ts_language__wasm_module(self); + ts_assert(module->ref_count > 0); + if (atomic_dec(&module->ref_count) == 0) { + // Update the language id to reflect that the language is deleted. This allows any wasm stores + // that hold wasm instances for this language to delete those instances. + atomic_inc(&module->language_id->is_language_deleted); + language_id_delete(module->language_id); + + ts_free((void *)module->field_name_buffer); + ts_free((void *)module->symbol_name_buffer); + ts_free((void *)module->name); + wasmtime_module_delete(module->module); + ts_free(module); + + ts_free((void *)self->alias_map); + ts_free((void *)self->alias_sequences); + ts_free((void *)self->external_scanner.symbol_map); + ts_free((void *)self->field_map_entries); + ts_free((void *)self->field_map_slices); + ts_free((void *)self->supertype_symbols); + ts_free((void *)self->supertype_map_entries); + ts_free((void *)self->supertype_map_slices); + ts_free((void *)self->field_names); + ts_free((void *)self->lex_modes); + ts_free((void *)self->name); + ts_free((void *)self->reserved_words); + ts_free((void *)self->parse_actions); + ts_free((void *)self->parse_table); + ts_free((void *)self->primary_state_ids); + ts_free((void *)self->public_symbol_map); + ts_free((void *)self->small_parse_table); + ts_free((void *)self->small_parse_table_map); + ts_free((void *)self->symbol_metadata); + ts_free((void *)self->symbol_names); + ts_free((void *)self); + } +} + +#ifdef _MSC_VER +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#else + +// If the WASM feature is not enabled, define dummy versions of all of the +// wasm-related functions. + +void ts_wasm_store_delete(TSWasmStore *self) { + (void)self; +} + +bool ts_wasm_store_start( + TSWasmStore *self, + TSLexer *lexer, + const TSLanguage *language +) { + (void)self; + (void)lexer; + (void)language; + return false; +} + +void ts_wasm_store_reset(TSWasmStore *self) { + (void)self; +} + +bool ts_wasm_store_call_lex_main(TSWasmStore *self, TSStateId state) { + (void)self; + (void)state; + return false; +} + +bool ts_wasm_store_call_lex_keyword(TSWasmStore *self, TSStateId state) { + (void)self; + (void)state; + return false; +} + +uint32_t ts_wasm_store_call_scanner_create(TSWasmStore *self) { + (void)self; + return 0; +} + +void ts_wasm_store_call_scanner_destroy( + TSWasmStore *self, + uint32_t scanner_address +) { + (void)self; + (void)scanner_address; +} + +bool ts_wasm_store_call_scanner_scan( + TSWasmStore *self, + uint32_t scanner_address, + uint32_t valid_tokens_ix +) { + (void)self; + (void)scanner_address; + (void)valid_tokens_ix; + return false; +} + +uint32_t ts_wasm_store_call_scanner_serialize( + TSWasmStore *self, + uint32_t scanner_address, + char *buffer +) { + (void)self; + (void)scanner_address; + (void)buffer; + return 0; +} + +void ts_wasm_store_call_scanner_deserialize( + TSWasmStore *self, + uint32_t scanner_address, + const char *buffer, + unsigned length +) { + (void)self; + (void)scanner_address; + (void)buffer; + (void)length; +} + +bool ts_wasm_store_has_error(const TSWasmStore *self) { + (void)self; + return false; +} + +bool ts_language_is_wasm(const TSLanguage *self) { + (void)self; + return false; +} + +void ts_wasm_language_retain(const TSLanguage *self) { + (void)self; +} + +void ts_wasm_language_release(const TSLanguage *self) { + (void)self; +} + +#endif diff --git a/vendor/tree-sitter/src/wasm_store.h b/vendor/tree-sitter/src/wasm_store.h new file mode 100644 index 0000000..0fd17e0 --- /dev/null +++ b/vendor/tree-sitter/src/wasm_store.h @@ -0,0 +1,31 @@ +#ifndef TREE_SITTER_WASM_H_ +#define TREE_SITTER_WASM_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "tree_sitter/api.h" +#include "./parser.h" + +bool ts_wasm_store_start(TSWasmStore *self, TSLexer *lexer, const TSLanguage *language); +void ts_wasm_store_reset(TSWasmStore *self); +bool ts_wasm_store_has_error(const TSWasmStore *self); + +bool ts_wasm_store_call_lex_main(TSWasmStore *self, TSStateId state); +bool ts_wasm_store_call_lex_keyword(TSWasmStore *self, TSStateId state); + +uint32_t ts_wasm_store_call_scanner_create(TSWasmStore *self); +void ts_wasm_store_call_scanner_destroy(TSWasmStore *self, uint32_t scanner_address); +bool ts_wasm_store_call_scanner_scan(TSWasmStore *self, uint32_t scanner_address, uint32_t valid_tokens_ix); +uint32_t ts_wasm_store_call_scanner_serialize(TSWasmStore *self, uint32_t scanner_address, char *buffer); +void ts_wasm_store_call_scanner_deserialize(TSWasmStore *self, uint32_t scanner, const char *buffer, unsigned length); + +void ts_wasm_language_retain(const TSLanguage *self); +void ts_wasm_language_release(const TSLanguage *self); + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_WASM_H_