diff --git a/.dockerignore b/.dockerignore
index 11bc3de..fa7d12f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,5 @@
-# NOTE: The image reads only the Rust workspace and the two licences, so deny
-# NOTE: everything and re-admit exactly those. A narrow context also keeps
-# NOTE: `rust/target` — gigabytes on a developer machine — out of the build.
+# NOTE: The image reads only the Rust workspace and the two licences, so deny everything and re-admit exactly those.
+# NOTE: A narrow context also keeps `rust/target` — gigabytes on a developer machine — out of the build.
*
!rust
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index ae67605..666adf9 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -68,8 +68,7 @@ updates:
prefix: "chore(deps)"
ignore:
# NOTE: @types/vscode has to stay on the version engines.vscode names, or the
- # NOTE: extension compiles against API the editors it claims to support do not
- # NOTE: have.
+ # NOTE: extension compiles against API the editors it claims to support do not have.
- dependency-name: "@types/vscode"
- dependency-name: "*"
update-types:
@@ -97,8 +96,7 @@ updates:
commit-message:
prefix: "chore(deps)"
ignore:
- # NOTE: The builder stage is pinned to the MSRV toolchain on purpose; a major
- # NOTE: or minor Rust bump is a deliberate change, not a dependency update.
+ # NOTE: The builder stage is pinned to the MSRV toolchain on purpose; a major or minor Rust bump is a deliberate change, not a dependency update.
- dependency-name: rust
update-types:
- version-update:semver-major
diff --git a/.github/rulesets/README.md b/.github/rulesets/README.md
index 6923979..a4a6269 100644
--- a/.github/rulesets/README.md
+++ b/.github/rulesets/README.md
@@ -1,17 +1,12 @@
# Repository rulesets
-These JSON files mirror the active GitHub repository rulesets and can be
-imported from the repository rules settings page or sent to the repository
-rulesets REST endpoint.
+These JSON files mirror the active GitHub repository rulesets and can be imported from the repository rules settings page or sent to the repository rulesets REST endpoint.
- `main.json` requires pull requests, immutable linear history, signed commits,
- resolved review threads, every portable CI job, and every advanced CodeQL
- language analysis. CodeQL errors and high-or-higher security alerts block
- merges.
-- `release-tags.json` makes version tags immutable and requires their target
- commits to be signed.
+ resolved review threads, every portable CI job, and every advanced CodeQL language analysis.
+ CodeQL errors and high-or-higher security alerts block merges.
+- `release-tags.json` makes version tags immutable and requires their target commits to be signed.
-The fixed-runner benchmark is intentionally not a required check because the
-runner may be offline. It is enabled separately with the
-`OCOMMENT_BENCHMARK_ENABLED` repository variable. Update the checked-in JSON in
-the same pull request as any live ruleset change.
+The fixed-runner benchmark is intentionally not a required check because the runner may be offline.
+It is enabled separately with the `OCOMMENT_BENCHMARK_ENABLED` repository variable.
+Update the checked-in JSON in the same pull request as any live ruleset change.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 333e9f2..a6266e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,72 +28,50 @@ jobs:
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
with:
components: clippy,rustfmt
- # NOTE: For the formatter-conformance cases. `gofmt` ships with the Go
- # NOTE: toolchain and the runner image already carries one; this is the
- # NOTE: line that says the test depends on it.
+ # NOTE: For the formatter-conformance cases.
+ # NOTE: `gofmt` ships with the Go toolchain and the runner image already carries one; this is the line that says the test depends on it.
- run: gofmt --help 2>/dev/null || command -v gofmt
- run: cargo fmt --all --manifest-path rust/Cargo.toml -- --check
- run: cargo clippy --manifest-path rust/Cargo.toml --workspace --all-targets --locked -- -D warnings
- # NOTE: The tests that name a file with raw non-UTF-8 bytes skip
- # NOTE: themselves on a filesystem that refuses such a name, which is how
- # NOTE: they stop failing on macOS for a reason that is not about
- # NOTE: OComment. ext4 holds one, so here the skip is a failure and the
- # NOTE: property is actually observed rather than merely compiled.
- # NOTE: And the formatter-conformance cases, for the same reason: they
- # NOTE: skip where `gofmt` or `rustfmt` is missing, and this runner has
- # NOTE: both, so a skip here is a test that quietly stopped running.
+ # NOTE: The tests that name a file with raw non-UTF-8 bytes skip themselves on a filesystem that refuses such a name, which is how they stop failing on macOS for a reason that is not about OComment.
+ # NOTE: ext4 holds one, so here the skip is a failure and the property is actually observed rather than merely compiled.
+ # NOTE: And the formatter-conformance cases, for the same reason: they skip where `gofmt` or `rustfmt` is missing, and this runner has both, so a skip here is a test that quietly stopped running.
- run: cargo test --manifest-path rust/Cargo.toml --workspace --all-targets --locked
env:
OCOMMENT_REQUIRE_NON_UTF8_PATHS: "1"
OCOMMENT_REQUIRE_FORMATTERS: "1"
- # NOTE: `--all-targets` above builds every target but silently drops the
- # NOTE: doctests, so the examples in the library rustdoc are only ever
- # NOTE: compiled and run by this step.
+ # NOTE: `--all-targets` above builds every target but silently drops the doctests, so the examples in the library rustdoc are only ever compiled and run by this step.
- run: cargo test --manifest-path rust/Cargo.toml --doc --workspace --locked
- # NOTE: docs/library.md is hand-written prose and the step above never reads
- # NOTE: it: `--doc` compiles what is in the crate sources and nothing else. The
- # NOTE: page says every example on it is compiled and run, so it is handed to
- # NOTE: `rustdoc` as its own doctest file, linked against the library it
- # NOTE: documents.
+ # NOTE: docs/library.md is hand-written prose and the step above never reads it: `--doc` compiles what is in the crate sources and nothing else.
+ # NOTE: The page says every example on it is compiled and run, so it is handed to `rustdoc` as its own doctest file, linked against the library it documents.
- name: The examples on the library page still compile
run: |
cargo build --manifest-path rust/Cargo.toml --locked -p ocomment-core
rustdoc --test docs/library.md --edition 2024 \
--extern ocomment_core=rust/target/debug/libocomment_core.rlib \
-L rust/target/debug/deps
- # NOTE: The binary crate is in here for its links alone: nothing publishes its
- # NOTE: rustdoc, but its modules document each other, and a link that names a
- # NOTE: function somebody has since renamed is a wrong sentence wherever it is
- # NOTE: written. `missing_docs` stays off for it — a `clap` derive has no
- # NOTE: documentation to give.
+ # NOTE: The binary crate is in here for its links alone: nothing publishes its rustdoc, but its modules document each other, and a link that names a function somebody has since renamed is a wrong sentence wherever it is written.
+ # NOTE: `missing_docs` stays off for it — a `clap` derive has no documentation to give.
- name: The documentation builds with no broken links
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --manifest-path rust/Cargo.toml --no-deps -p ocomment-core -p ocomment-plugin-sdk -p ocomment --locked
- run: python3 tools/check_embedded_specs.py
- # NOTE: Half a gate is a gate that would go on passing if the thing it
- # NOTE: tests stopped refusing anything; see the file for the run that
- # NOTE: did exactly that here.
+ # NOTE: Half a gate is a gate that would go on passing if the thing it tests stopped refusing anything; see the file for the run that did exactly that here.
- run: python3 tools/check_gate_symmetry.py
- run: python3 tools/gen_selftest_corpus.py --check
- run: python3 tools/check_hooks.py
- run: python3 tools/check_editor_ids.py
- run: python3 tools/check_ci_contracts.py
- # NOTE: The only check here that asks somebody else. The table beside it
- # NOTE: settles everything a file in this repository can be wrong about
- # NOTE: and cannot settle whether a digest really is the version it is
- # NOTE: labelled with, which lives upstream. It runs here and not in
- # NOTE: `preflight` because a laptop is allowed to be offline and a gate
- # NOTE: is not.
+ # NOTE: The only check here that asks somebody else.
+ # NOTE: The table beside it settles everything a file in this repository can be wrong about and cannot settle whether a digest really is the version it is labelled with, which lives upstream.
+ # NOTE: It runs here and not in `preflight` because a laptop is allowed to be offline and a gate is not.
- name: The reviewed action pins are what upstream says they are
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 tools/check_action_pins.py
- # NOTE: Dependabot raises alerts on this repository and they are worth
- # NOTE: having, but an alert arrives after a merge and can be triaged
- # NOTE: away -- both `qs` advisories here had been auto-dismissed, so
- # NOTE: asking for open ones returned none while the lockfile still
- # NOTE: carried them. This runs before the merge and answers to a ledger.
+ # NOTE: Dependabot raises alerts on this repository and they are worth having, but an alert arrives after a merge and can be triaged away -- both `qs` advisories here had been auto-dismissed, so asking for open ones returned none while the lockfile still carried them.
+ # NOTE: This runs before the merge and answers to a ledger.
- name: Both lockfiles answer to the advisory ledger
run: python3 tools/check_advisories.py
- run: python3 tools/sync_release_docs.py --check
@@ -167,8 +145,7 @@ jobs:
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
- run: cargo build --manifest-path rust/Cargo.toml --locked -p ocomment
- run: python3 -m pip install --disable-pip-version-check pyyaml==6.0.2
- # NOTE: Why this runs, and why only the random set is cut here: see "The
- # NOTE: YAML round trip" in docs/ci.md.
+ # NOTE: Why this runs, and why only the random set is cut here: see "The YAML round trip" in docs/ci.md.
- name: Removing YAML comments never changes what the document parses to
run: python3 tools/yaml_roundtrip.py --cases 200
- name: Report the environment and the configuration OComment resolved
@@ -177,22 +154,16 @@ jobs:
./rust/target/debug/ocomment doctor
./rust/target/debug/ocomment config explain
# NOTE: The same corpus the library test and the differential run use,
- # NOTE: asked of the executable instead. It is not a third copy of that
- # NOTE: check: it is the one that runs where `spec/` is not on disk, which
- # NOTE: is every machine an artefact is installed on. Running it here is
- # NOTE: what keeps it working, because a self-test nobody runs is a
- # NOTE: self-test that quietly stopped reaching the corpus.
+ # NOTE: asked of the executable instead.
+ # NOTE: It is not a third copy of that check: it is the one that runs where `spec/` is not on disk, which is every machine an artefact is installed on.
+ # NOTE: Running it here is what keeps it working, because a self-test nobody runs is a self-test that quietly stopped reaching the corpus.
- name: The binary re-runs the shared corpus against itself
run: ./rust/target/debug/ocomment selftest
- # NOTE: `coverage` and not `check`, because this step is about the files
- # NOTE: nothing read rather than about what was found in the ones that
- # NOTE: were: its exit code answers for skips alone.
+ # NOTE: `coverage` and not `check`, because this step is about the files nothing read rather than about what was found in the ones that were: its exit code answers for skips alone.
- name: Every file was read
run: ./rust/target/debug/ocomment coverage --deny-skipped --quiet
- # NOTE: The gate. A bare run walks the repository under the ordinary
- # NOTE: limits and under `.ocomment.toml`, so a comment that carries no
- # NOTE: tag, runs past the length rule, or sits beside code fails the
- # NOTE: build -- and so does a promise whose deadline has passed.
+ # NOTE: The gate.
+ # NOTE: A bare run walks the repository under the ordinary limits and under `.ocomment.toml`, so a comment that carries no tag, runs past the length rule, or sits beside code fails the build -- and so does a promise whose deadline has passed.
- name: OComment checks its own repository
run: ./rust/target/debug/ocomment --format github
- name: Strip every comment out of a copy of the workspace
@@ -239,6 +210,18 @@ jobs:
if: runner.os == 'Windows'
shell: pwsh
run: '& rust/target/release/ocomment.exe --version'
+ # NOTE: The suite, on the systems this repository ships a binary for.
+ # NOTE: Until now `cargo test` ran on Linux alone while `release.yml` shipped x86_64-pc-windows-msvc: what Windows measured was that it builds and prints its version, and because this job went green the whole run did, reading as "Windows passes".
+ # NOTE: Skipped on Linux,
+ # NOTE: where the `rust` job runs it with the switches that turn a skip into a failure -- which must not be set here, because they are read with `is_some` and a "0" would demand rather than excuse.
+ # NOTE:
+ # NOTE: Non-blocking on Windows until #65 is closed.
+ # NOTE: Turning this on found eleven real Windows defects in the suite -- a path separator asserted as `/`, `canonicalize` returning a `\\?\` prefix and an 8.3 name, a fixture filename Windows will not hold, CRLF in a generated artifact, a `curl` message worded differently.
+ # NOTE: Every one is the suite's, not the binary's, and fixing them is a port rather than a change to what OComment does; a red job that stays red teaches nobody, and hiding it again would put back exactly what this step was added to expose.
+ - name: The suite runs where the binary ships
+ if: runner.os != 'Linux'
+ continue-on-error: ${{ runner.os == 'Windows' }}
+ run: cargo test --manifest-path rust/Cargo.toml --workspace --locked
action-smoke:
strategy:
@@ -314,8 +297,7 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run compile
- # NOTE: The manifest suite checks the independently versioned extension's
- # NOTE: packaging, activation, commands, and language selector before build.
+ # NOTE: The manifest suite checks the independently versioned extension's packaging, activation, commands, and language selector before build.
- run: npm run unit
- name: Build the ocomment the extension launches
working-directory: ${{ github.workspace }}
@@ -323,8 +305,7 @@ jobs:
- name: Put that ocomment first on PATH
working-directory: ${{ github.workspace }}
run: echo "${GITHUB_WORKSPACE}/rust/target/debug" >>"$GITHUB_PATH"
- # NOTE: `npm test` downloads a real VS Code and drives it, so it needs a
- # NOTE: display; the runner has no X server of its own.
+ # NOTE: `npm test` downloads a real VS Code and drives it, so it needs a display; the runner has no X server of its own.
- run: xvfb-run -a npm test
- name: Package the source-only extension
run: npm run package -- --out ocomment.vsix
@@ -341,10 +322,8 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- # NOTE: A source build on one platform, which is the path a release never
- # NOTE: takes, so the Dockerfile's own builder stage cannot rot between
- # NOTE: releases. The step after the smoke test takes the release path over the
- # NOTE: same file.
+ # NOTE: A source build on one platform, which is the path a release never takes, so the Dockerfile's own builder stage cannot rot between releases.
+ # NOTE: The step after the smoke test takes the release path over the same file.
- name: Build the image from source
shell: bash
run: docker build -t ocomment:ci .
@@ -361,13 +340,9 @@ jobs:
exit 1
fi
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' container-report.json
- # NOTE: The release image is not compiled: the workflow replaces the `builder`
- # NOTE: stage with a buildx named context holding the musl binaries the release
- # NOTE: matrix already built. Handing the image its own binary back through
- # NOTE: that context exercises the second path over the same Dockerfile, so a
- # NOTE: release build is never the first to find the layout broken. The hosted
- # NOTE: runner's default buildx builder supplies `--build-context`; this step
- # NOTE: uses that same builder.
+ # NOTE: The release image is not compiled: the workflow replaces the `builder` stage with a buildx named context holding the musl binaries the release matrix already built.
+ # NOTE: Handing the image its own binary back through that context exercises the second path over the same Dockerfile, so a release build is never the first to find the layout broken.
+ # NOTE: The hosted runner's default buildx builder supplies `--build-context`; this step uses that same builder.
- name: Build the image again through the release path
shell: bash
run: |
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 6e7a28e..651aed0 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -3,16 +3,13 @@ name: Docs
on:
push:
branches: [main]
- # NOTE: The generated pages under docs/ are what a CLI change moves, and the
- # NOTE: `rust` job of CI fails until they are regenerated in the same commit, so
- # NOTE: a change that alters `--help` reaches this filter as a docs/ change.
+ # NOTE: The generated pages under docs/ are what a CLI change moves, and the `rust` job of CI fails until they are regenerated in the same commit, so a change that alters `--help` reaches this filter as a docs/ change.
paths:
- docs/**
- spec/**
- tools/gen_docs.py
- .github/workflows/docs.yml
- # NOTE: No path filter here: `docs` is a required status check, so it has to run on
- # NOTE: every pull request rather than only on the ones that touch the book.
+ # NOTE: No path filter here: `docs` is a required status check, so it has to run on every pull request rather than only on the ones that touch the book.
pull_request:
workflow_dispatch:
@@ -36,10 +33,8 @@ jobs:
persist-credentials: false
# NOTE: stable toolchain action
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
- # NOTE: Pinned: mdBook decides the rendered HTML, so an unpinned tool would
- # NOTE: let the published site change under a commit that touched nothing.
- # NOTE: The archive is fetched by hand because the repository's action policy
- # NOTE: does not allow third-party actions outside its allowlist.
+ # NOTE: Pinned: mdBook decides the rendered HTML, so an unpinned tool would let the published site change under a commit that touched nothing.
+ # NOTE: The archive is fetched by hand because the repository's action policy does not allow third-party actions outside its allowlist.
- name: Install mdBook 0.5.4
shell: bash
run: |
@@ -51,9 +46,7 @@ jobs:
# NOTE: The site may not restate anything the binary or spec/ no longer says.
- run: python3 tools/gen_docs.py --check
- run: mdbook build docs
- # NOTE: `create-missing = false` in docs/book.toml makes the build above fail on
- # NOTE: a SUMMARY entry with no file behind it, so this only has to catch the
- # NOTE: opposite: a chapter that was written and never linked from SUMMARY.md.
+ # NOTE: `create-missing = false` in docs/book.toml makes the build above fail on a SUMMARY entry with no file behind it, so this only has to catch the opposite: a chapter that was written and never linked from SUMMARY.md.
- name: Every page under docs/ is in the book
run: |
set -euo pipefail
@@ -72,8 +65,7 @@ jobs:
path: target/book
deploy-pages:
- # NOTE: Pages serves one site, so a deploy is never cancelled halfway and never
- # NOTE: races another: this group is deliberately separate from the workflow's.
+ # NOTE: Pages serves one site, so a deploy is never cancelled halfway and never races another: this group is deliberately separate from the workflow's.
concurrency:
group: pages
cancel-in-progress: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5eec322..5124a47 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -261,9 +261,8 @@ jobs:
path: musl
merge-multiple: true
# NOTE: The image ships the binaries this release already built, smoke tested,
- # NOTE: signed, and published as archives rather than a second compilation of
- # NOTE: the same tag. `builder` below is the buildx named context the Dockerfile
- # NOTE: copies from, so this layout is the whole contract between them.
+ # NOTE: signed, and published as archives rather than a second compilation of the same tag.
+ # NOTE: `builder` below is the buildx named context the Dockerfile copies from, so this layout is the whole contract between them.
- name: Lay the released musl binaries out as the `builder` context
shell: bash
run: |
diff --git a/.gitignore b/.gitignore
index 47286ff..219d9e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,10 +21,7 @@ __pycache__/
*.vsix
/rust/target*/
-# NOTE: A global ignore commonly hides an agent instruction file, because in
-# NOTE: most repositories it is somebody's private scratch. Here it is a
-# NOTE: published page: AGENTS.md is the entry point for an agent working on
-# NOTE: OComment, docs/agents.md is a chapter of the book, and the docs build
-# NOTE: fails without it.
+# NOTE: A global ignore commonly hides an agent instruction file, because in most repositories it is somebody's private scratch.
+# NOTE: Here it is a published page: AGENTS.md is the entry point for an agent working on OComment, docs/agents.md is a chapter of the book, and the docs build fails without it.
!AGENTS.md
!docs/agents.md
diff --git a/.ocomment.toml b/.ocomment.toml
index e3e91e5..a1c7122 100644
--- a/.ocomment.toml
+++ b/.ocomment.toml
@@ -1,33 +1,26 @@
-# NOTE: OComment checks its own repository. `ocomment` from the root is the
-# NOTE: gate the `dogfood` CI job runs, and Lefthook runs `ocomment check
-# NOTE: --staged` before every commit; see CONTRIBUTING.md for the tag
-# NOTE: convention this configuration enforces. TOML is a built-in language, so
-# NOTE: this file is now one of the files that convention applies to.
+# NOTE: OComment checks its own repository.
+# NOTE: `ocomment` from the root is the gate the `dogfood` CI job runs, and Lefthook runs `ocomment fix --tidy --staged` before every commit -- which reflows what the style axis below decides and leaves every removal to whoever is committing.
+# NOTE: See CONTRIBUTING.md for the tag convention this configuration enforces.
+# NOTE: TOML is a built-in language, so this file is now one of the files that convention applies to.
version = 1
[files]
hidden = true
exclude = [
- # NOTE: Upstream-derived runtime modules, fixture bytes, and packaging or
- # NOTE: benchmark scratch are not ours to rewrite: fixture comments are the
- # NOTE: test input itself.
+ # NOTE: Upstream-derived runtime modules, fixture bytes, and packaging or benchmark scratch are not ours to rewrite: fixture comments are the test input itself.
"rust/ocomment/src/runtime/**",
"spec/fixtures/**",
"editors/vscode/test-fixtures/**",
"release-extras/**",
"benchmarks/**",
# NOTE: This generated page contains deliberate before/after source bytes;
- # NOTE: removing their example comments would change the documented policy
- # NOTE: output rather than clean up generator prose.
+ # NOTE: removing their example comments would change the documented policy output rather than clean up generator prose.
"docs/policies.md",
- # NOTE: The starter file `ocomment init` writes. Its comments are addressed to
- # NOTE: whoever runs that command, not to a reader of this repository, so the
- # NOTE: tag convention below does not apply to them.
+ # NOTE: The starter file `ocomment init` writes.
+ # NOTE: Its comments are addressed to whoever runs that command, not to a reader of this repository, so the tag convention below does not apply to them.
"spec/default-config.toml",
"rust/ocomment/assets/default-config.toml",
- # NOTE: Licence texts and the rendered manual page carry no comments to find
- # NOTE: and are not ours to reformat, so they are excluded rather than left
- # NOTE: to be reported as an unknown language every run.
+ # NOTE: Licence texts and the rendered manual page carry no comments to find and are not ours to reformat, so they are excluded rather than left to be reported as an unknown language every run.
"LICENSE*",
"editors/vscode/LICENSE",
"docs/ocomment.1",
@@ -38,44 +31,48 @@ mode = "conservative"
layout = "lines"
keep_regex = [
# NOTE: The version beside a SHA-pinned action, now that YAML is scanned.
- # NOTE: CONTRIBUTING.md requires every `uses:` to carry one and Dependabot
- # NOTE: rewrites it when it moves the pin, so it is read by a machine rather
- # NOTE: than by a reader and has no rationale to tag. The pattern is the whole
- # NOTE: comment, so prose that merely opens with a version is still prose.
+ # NOTE: CONTRIBUTING.md requires every `uses:` to carry one and Dependabot rewrites it when it moves the pin, so it is read by a machine rather than by a reader and has no rationale to tag.
+ # NOTE: The pattern is the whole comment, so prose that merely opens with a version is still prose.
'^#\s*v[0-9]+(\.[0-9]+)*$',
]
-# NOTE: The tag convention, as a tag rule rather than as a pattern. It was
-# NOTE: written against the raw comment token, which meant naming four comment
-# NOTE: openers and protecting only the languages that use them: the identical
-# NOTE: rule written in a Lua or SQL file was not protected at all. A tag is
-# NOTE: read from the comment's text, so one line holds in every language.
+# NOTE: The tag convention, as a tag rule rather than as a pattern.
+# NOTE: It was written against the raw comment token, which meant naming four comment openers and protecting only the languages that use them: the identical rule written in a Lua or SQL file was not protected at all.
+# NOTE: A tag is read from the comment's text, so one line holds in every language.
[policy.allow]
tags = ["NOTE", "SAFETY", "INVARIANT", "PERF"]
-# NOTE: A comment is at most a paragraph. Reasoning that needs more than that
-# NOTE: is documentation, and documentation is exempt from this rule because it
-# NOTE: is documentation: a `///`, a `(**`, a module docstring, a page under
-# NOTE: docs/. The rule is not "explain less", it is "explain where a reader
-# NOTE: will find it". This repository holds zero comments above the line.
+# NOTE: A comment is at most a paragraph.
+# NOTE: Reasoning that needs more than that is documentation, and documentation is exempt from this rule because it is documentation: a `///`, a `(**`, a module docstring, a page under docs/.
+# NOTE: The rule is not "explain less", it is "explain where a reader will find it".
+# NOTE: This repository holds zero comments above the line.
max_lines = 8
# NOTE: Beside the code is the obvious way around a rule about comments above it.
trailing = false
# NOTE: The tags that are promises rather than remarks, and what each one has.
-# NOTE: Counted from the commit that adds the line, so writing one costs
-# NOTE: nothing and the clock starts when the repository takes it on.
+# NOTE: Counted from the commit that adds the line, so writing one costs nothing and the clock starts when the repository takes it on.
[policy.allow.expiry]
TODO = "30d"
FIXME = "14d"
HACK = "14d"
-# NOTE: A comment in a documentation sample is the sample. These files quote
-# NOTE: shell sessions and configuration files, and the annotations in them
-# NOTE: belong to the quoted material rather than to this repository, so the
-# NOTE: rules about where this project puts its own comments do not reach them.
+# NOTE: A comment in a documentation sample is the sample.
+# NOTE: These files quote shell sessions and configuration files, and the annotations in them belong to the quoted material rather than to this repository, so the rules about where this project puts its own comments do not reach them.
# NOTE: The tag rule still does: a sample is still read by somebody.
+# NOTE: The changelog is release-plz's file.
+# NOTE: Its comments and its prose come back on the next release, so the style rules have nothing to offer it: a paragraph rewritten here is a paragraph rewritten again by something else.
+[[overrides]]
+paths = ["CHANGELOG.md"]
+[overrides.style]
+
[[overrides]]
paths = ["**/*.md"]
[overrides.allow]
tags = ["NOTE", "SAFETY", "INVARIANT", "PERF"]
max_lines = 8
+
+# NOTE: The other axis.
+# NOTE: A comment is prose, and prose breaks at the end of a sentence rather than at a column: a diff then reviews one sentence at a time, and a line break means something.
+# NOTE: A break after a clause is left where its writer put it.
+[style]
+wrap = "sentence"
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index bc8227a..8523a14 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -1,5 +1,5 @@
-# NOTE: Hook definitions pre-commit reads when this repository is a `repo:`
-# NOTE: entry. Why they take every text file, and why `language: system`:
+# NOTE: Hook definitions pre-commit reads when this repository is a `repo:` entry.
+# NOTE: Why they take every text file, and why `language: system`:
# NOTE: see "The published pre-commit hooks" in docs/ci.md.
- id: ocomment-check
name: ocomment check
@@ -7,6 +7,13 @@
entry: ocomment check
language: system
types: [text]
+- id: ocomment-tidy
+ name: ocomment tidy
+ description: 'Reflow comment prose in pre-commit-selected text files and remove nothing; every removal is still reported, and pre-commit blocks the commit when a file changes so the rewrite can be reviewed and staged.'
+ entry: ocomment fix --tidy
+ language: system
+ types: [text]
+ require_serial: true
- id: ocomment-fix
name: ocomment fix
description: 'Remove comments from pre-commit-selected text files in place; pre-commit blocks the commit when a file changes so the result can be reviewed and staged.'
diff --git a/AGENTS.md b/AGENTS.md
index 2174741..1fc7480 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,12 +1,11 @@
# Working on OComment
-For an agent *using* OComment, see [`docs/agents.md`](docs/agents.md). This
-file is for an agent changing it.
+For an agent *using* OComment, see [`docs/agents.md`](docs/agents.md).
+This file is for an agent changing it.
Read [`CONTRIBUTING.md`](CONTRIBUTING.md) — it is the long form of all of this,
-and the synchronisation checklists in it are exhaustive where this page is
-short. What follows is the shape of the repository and the handful of rules
-that are easy to break without noticing.
+and the synchronisation checklists in it are exhaustive where this page is short.
+What follows is the shape of the repository and the handful of rules that are easy to break without noticing.
## The shape
@@ -22,43 +21,26 @@ that are easy to break without noticing.
## Five rules
-**1. `spec/` is the source of truth, and the copies are checked.**
-`rust/ocomment/assets/` holds embedded copies of several `spec/` files.
-`tools/check_embedded_specs.py` fails when they drift. Change the canonical
-file, copy it across, and run the tool.
-
-**2. The Rust and OCaml implementations do not share code.**
-That is the point: the cross-check is worth something only because the two were
-written separately. `cargo xtask differential` runs every fixture through both and
-requires byte-identical normalised output. A change to a scanning rule is a
-change to both, in the same commit.
-
-**3. `ocomment-core` performs no I/O.**
-Not files, not processes, not the clock. A rule that needs any of those is
-decided in the CLI — see `rust/ocomment/src/deadline.rs`, which measures how
-old a line is and reaches a verdict the core *owns the vocabulary for*
-(`ShapeRule::Expired`) and never produces. Keeping the words in one place is
-what stops the two halves from explaining the same verdict differently.
-
-**4. Standard output carries the product; standard error carries everything
-else.**
-`check` writes findings to stdout, the summary to stderr, and `-q` drops the
-second. This is a mechanism rather than a convention: `output::Verbosity` is
-opaque and has no `PartialEq`, so nothing can ask whether a run is quiet — a
-caller says whether a line is `Detail::Normal` or `Detail::Verbose` and
-`output::note` decides. `rust/ocomment/tests/source_guards.rs` reads the crate's
-own source to keep both halves true, and its file list checks itself against
-`src/`.
-
-**5. Exhaustive matches, and lists that check themselves.**
-`Policy::keeps`, `CommentKind::protection`, `subject_to_shape` and every
-`DispositionExplanation` match are written out in full so that adding a variant
-fails to compile until somebody classifies it. Where a test has to hold a list —
-the fixture option sweep in `rust/ocomment-core/tests/explain.rs`, the source
-list in `source_guards.rs` — the list is checked against the thing it is a list
-of, in both directions. A list that only grows by hand is a gate that quietly
-stops covering what it was written for; that has happened here, and it is what
-`every_option_is_classified` exists to prevent.
+**1. `spec/` is the source of truth, and the copies are checked.** `rust/ocomment/assets/` holds embedded copies of several `spec/` files.
+`tools/check_embedded_specs.py` fails when they drift.
+Change the canonical file, copy it across, and run the tool.
+
+**2. The Rust and OCaml implementations do not share code.** That is the point: the cross-check is worth something only because the two were written separately.
+`cargo xtask differential` runs every fixture through both and requires byte-identical normalised output.
+A change to a scanning rule is a change to both, in the same commit.
+
+**3. `ocomment-core` performs no I/O.** Not files, not processes, not the clock.
+A rule that needs any of those is decided in the CLI — see `rust/ocomment/src/deadline.rs`, which measures how old a line is and reaches a verdict the core *owns the vocabulary for* (`ShapeRule::Expired`) and never produces.
+Keeping the words in one place is what stops the two halves from explaining the same verdict differently.
+
+**4. Standard output carries the product; standard error carries everything else.** `check` writes findings to stdout, the summary to stderr, and `-q` drops the second.
+This is a mechanism rather than a convention: `output::Verbosity` is opaque and has no `PartialEq`, so nothing can ask whether a run is quiet — a caller says whether a line is `Detail::Normal` or `Detail::Verbose` and `output::note` decides.
+`rust/ocomment/tests/source_guards.rs` reads the crate's own source to keep both halves true, and its file list checks itself against `src/`.
+
+**5. Exhaustive matches, and lists that check themselves.** `Policy::keeps`, `CommentKind::protection`, `subject_to_shape` and every `DispositionExplanation` match are written out in full so that adding a variant fails to compile until somebody classifies it.
+Where a test has to hold a list —
+the fixture option sweep in `rust/ocomment-core/tests/explain.rs`, the source list in `source_guards.rs` — the list is checked against the thing it is a list of, in both directions.
+A list that only grows by hand is a gate that quietly stops covering what it was written for; that has happened here, and it is what `every_option_is_classified` exists to prevent.
## Before you open a change
@@ -74,12 +56,9 @@ python3 tools/gen_docs.py --binary rust/target/debug/ocomment --check
ocomment # NOTE: this repository under its own gate
```
-`lefthook install` wires the last one into `pre-commit`, built from this
-workspace rather than taken from `PATH` — a tool that gates its own repository
-has to be the version in that repository.
+`lefthook install` wires the last one into `pre-commit`, built from this workspace rather than taken from `PATH` — a tool that gates its own repository has to be the version in that repository.
-Changing `--help` text makes the checked-in manual page and the shell
-completions stale:
+Changing `--help` text makes the checked-in manual page and the shell completions stale:
```sh
python3 tools/release_extras.py --binary rust/target/debug/ocomment
@@ -89,26 +68,22 @@ python3 tools/gen_docs.py --binary rust/target/debug/ocomment
## This repository is under its own gate, at zero
-`.ocomment.toml` sets a tag list, `max_lines = 8`, `trailing = false`, and
-deadlines on `TODO`, `FIXME` and `HACK`. A comment you add has to carry a tag,
-fit in a paragraph, and sit above the code it is about; a promise you leave has
-a fortnight or a month before it becomes a finding. A bare `ocomment` over this
-tree exits 0, and the CI job that runs it is a gate rather than a report.
+`.ocomment.toml` sets a tag list, `max_lines = 8`, `trailing = false`, and deadlines on `TODO`, `FIXME` and `HACK`.
+A comment you add has to carry a tag,
+fit in a paragraph, and sit above the code it is about; a promise you leave has a fortnight or a month before it becomes a finding.
+A bare `ocomment` over this tree exits 0, and the CI job that runs it is a gate rather than a report.
-There is no ledger here and no exemption for the tool's own source. Both would
-be the same dodge: a tool whose own repository cannot pass its own rules is
-arguing that the rules are unreasonable.
+There is no ledger here and no exemption for the tool's own source.
+Both would be the same dodge: a tool whose own repository cannot pass its own rules is arguing that the rules are unreasonable.
-The length rule is not "explain less". Documentation is exempt from it because
-it is documentation — a `///`, an OCaml `(**`, a Python module docstring, a
-page under `docs/`. Reaching zero here meant moving long rationale into those,
-which is where a reader finds it anyway, and compressing the rest. If your
-change needs more than a paragraph of prose, that is where it goes.
+The length rule is not "explain less".
+Documentation is exempt from it because it is documentation — a `///`, an OCaml `(**`, a Python module docstring, a page under `docs/`.
+Reaching zero here meant moving long rationale into those,
+which is where a reader finds it anyway, and compressing the rest.
+If your change needs more than a paragraph of prose, that is where it goes.
## Adding a language
-The single most synchronisation-heavy change in the repository, and
-`CONTRIBUTING.md` lists every place it touches — a dozen files, several of them
-counting languages in prose. Read that list before starting rather than
-discovering it one failing test at a time. The tests are written to fail rather
-than to let a half-added language ship, so the build is on your side here.
+The single most synchronisation-heavy change in the repository, and `CONTRIBUTING.md` lists every place it touches — a dozen files, several of them counting languages in prose.
+Read that list before starting rather than discovering it one failing test at a time.
+The tests are written to fail rather than to let a half-added language ship, so the build is on your side here.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdce713..6dd644b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,77 @@ All notable changes to OComment will be documented here. The project follows
## Unreleased
+### Added
+
+- A second axis: what a comment *says*, as well as whether it stays.
+ `[style]` is a table beside `[policy.allow]` rather than inside it, because
+ the two have different consequences — a comment that fails a condition of
+ survival is removed, and a comment that fails a style rule is rewritten — and
+ a reader adding a rule to a table whose entries meant two different things
+ would have to guess which they were adding.
+
+ `wrap = "sentence"` is the rule this was built for: one sentence per line.
+ A break that only exists to keep a line short is undone, a break after a
+ sentence is put back, and a break after a clause is left where its writer put
+ it — the checker accepts one, so the fixer may not remove it, or its output
+ would not be its own checker's fixed point.
+ `space_after_marker` and `trailing_whitespace` are the two cheap rules beside
+ it. Every default is "do nothing": a formatter that starts formatting because
+ it was installed is a rude one.
+
+ A verdict is three-valued now. `Action` is `Keep`, `Rewrite` or `Remove`, and
+ `Disposition::Rewrite` carries the bytes it would write, so the rule and the
+ replacement cannot disagree. Where the answer is about a paragraph rather than
+ a comment it is not on any comment at all: `ScanReport::runs` holds it, because
+ joining two comment lines moves the newline and the indentation between them
+ and those belong to neither.
+
+ What a rewrite may touch is unchanged from what a removal may touch. The crate
+ promises that the only bytes that move are the ones a comment occupied, and a
+ reflow keeps that promise literally — the code around a paragraph, the
+ indentation in front of it and the line ending after it are the same bytes
+ afterwards. A fenced block, a table, a list item and its indentation, a
+ rustdoc section heading, a link reference definition and a documentation tag
+ are passed through byte for byte, and a paragraph ends at each of them.
+
+- `[policy] mode = "none"`, which removes nothing.
+ The way to say "tidy, do not delete" was to list every comment kind under
+ `keep_kind`, which is a setting that reads as a list of exceptions to a
+ decision nobody made.
+
+- Markdown pages are prose too. `ProseOrigin::Document` is the same answer about
+ a page's own paragraphs, which are not comments, and `docs/` is where the rule
+ was first proved on something other than a comment.
+
+- Haskell and Gleam, as declarative profiles rather than as hand-written
+ scanners. Haskell needed the rule that a run of dashes opens a comment only
+ when what follows it is not an operator character (Haskell 2010 §2.2) and the
+ rule that keeps the rest of a Haddock page from being read as a remark; both
+ are now things a profile can state, so the next language costs a table entry.
+
+### Fixed
+
+- The test suite runs on the systems this repository publishes a binary for.
+ `cargo test` ran on Linux alone while `release.yml` shipped
+ `x86_64-pc-windows-msvc`; what Windows CI measured was that the crate builds
+ and prints its version. Because that job went green the whole run went green,
+ and a reader takes a green run for *Windows passes* — which is worse than
+ claiming nothing, because the ground for it is nowhere in the output.
+
+ The first thing it found was already known to one person who had run it by
+ hand: `a_first_segment_that_reads_as_a_drive_letter_is_disambiguated` asked a
+ question with two right answers. `c:/a.rs` names a directory called `c:` in a
+ POSIX checkout and the root of a drive on Windows, `std::path` says so, and
+ the SARIF location follows — under `%SRCROOT%` with a `./` on one system,
+ under no base on the other. The implementation was right on both; the test
+ held one system's answer and nothing had ever asked the other. It now asks
+ each, and a second case pins `under_source_root` itself, because both halves
+ would pass if that function simply stopped answering.
+
+- `sync_parent` is split by system instead of guarding its body, so the Windows
+ build no longer warns about a parameter the arm that does nothing cannot use.
+ Taken from an abandoned branch.
+
### Fixed
- A Go comment that opens with the word `go:` or `line ` after a space is
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 4d37f11..d90cd5a 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -1,7 +1,6 @@
# Code of Conduct
-OComment contributors, maintainers, and participants are expected to make the
-project a respectful, harassment-free place for everyone.
+OComment contributors, maintainers, and participants are expected to make the project a respectful, harassment-free place for everyone.
## Expected behavior
@@ -11,22 +10,15 @@ project a respectful, harassment-free place for everyone.
- Accept correction, take responsibility, and de-escalate disagreements.
- Keep security reports and other sensitive information confidential.
-Harassment, discrimination, threats, sexualized attention, deliberate
-intimidation, doxxing, sustained disruption, and publishing private information
-without permission are unacceptable.
+Harassment, discrimination, threats, sexualized attention, deliberate intimidation, doxxing, sustained disruption, and publishing private information without permission are unacceptable.
## Enforcement
-Maintainers may edit or remove contributions, lock conversations, issue a
-warning, or temporarily or permanently restrict participation when behavior
-harms the project or its community. Enforcement decisions should be
-proportionate, documented privately, and applied consistently.
+Maintainers may edit or remove contributions, lock conversations, issue a warning, or temporarily or permanently restrict participation when behavior harms the project or its community.
+Enforcement decisions should be proportionate, documented privately, and applied consistently.
-For a confidential project-specific report, use the
-[private report form](https://github.com/P4suta/OComment/security/advisories/new)
-and begin the title with `Conduct:`. For behavior governed by GitHub itself, use
-GitHub's abuse-reporting tools. Retaliation against a good-faith reporter is not
-tolerated.
+For a confidential project-specific report, use the [private report form](https://github.com/P4suta/OComment/security/advisories/new) and begin the title with `Conduct:`.
+For behavior governed by GitHub itself, use GitHub's abuse-reporting tools.
+Retaliation against a good-faith reporter is not tolerated.
-This policy is informed by the Contributor Covenant 2.1 and the GitHub Community
-Guidelines.
+This policy is informed by the Contributor Covenant 2.1 and the GitHub Community Guidelines.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c0404a0..78f0fb0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,24 +1,22 @@
# Contributing to OComment
-Thank you for helping improve OComment. Bug reports, language fixtures,
+Thank you for helping improve OComment.
+Bug reports, language fixtures,
documentation, performance data, and code changes are all welcome.
## Before opening a change
-- Use [GitHub Discussions](https://github.com/P4suta/OComment/discussions) for
- design questions and support.
+- Use [GitHub Discussions](https://github.com/P4suta/OComment/discussions) for design questions and support.
- Use an issue for confirmed bugs and scoped feature requests.
- Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
-Substantial scanner, policy, public API, Git, LSP, or plugin-contract changes
-should have an agreed design before implementation. Small fixes can go directly
-to a pull request.
+Substantial scanner, policy, public API, Git, LSP, or plugin-contract changes should have an agreed design before implementation.
+Small fixes can go directly to a pull request.
## Development setup
-The production workspace requires Rust 1.88 or newer. Differential verification
-also requires OCaml 5.5, opam, Dune 3.24.2, Python 3, and the dependencies from
-`ocaml/ocomment-ref.opam`.
+The production workspace requires Rust 1.88 or newer.
+Differential verification also requires OCaml 5.5, opam, Dune 3.24.2, Python 3, and the dependencies from `ocaml/ocomment-ref.opam`.
```sh
opam install ./ocaml/ocomment-ref.opam --deps-only --with-test
@@ -26,13 +24,12 @@ cargo build --manifest-path rust/Cargo.toml --workspace --locked
lefthook install
```
-`lefthook install` wires up `lefthook.yml`, whose `pre-commit` hook runs
-`ocomment check --staged` and `cargo fmt --check`. The hook reads the staged
-blobs rather than the working tree, so a partially staged file is judged by the
-bytes the commit will carry, and it reports rather than rewrites: `fix --staged`
-under Lefthook would need `stage_fixed`, which stages the whole working-tree
-file and destroys partial staging. It prefers an `ocomment` on `PATH` and falls
-back to the workspace copy, so a fresh clone needs no `cargo install` first.
+`lefthook install` wires up `lefthook.yml`, whose `pre-commit` hook runs `ocomment fix --tidy --staged` and `cargo fmt --check`.
+The hook reads the staged blobs rather than the working tree, so a partially staged file is judged by the bytes the commit will carry.
+`--tidy` writes the half a machine can settle — a comment paragraph reflowed to one sentence per line — and leaves every removal reported and unapplied, so the gate is no weaker for writing.
+Lefthook's `stage_fixed` is deliberately not set: `fix --staged` writes the index itself, and that setting would stage the whole working-tree file and destroy the partial staging.
+A run that rewrote the index exits 1, so the rewrite is reviewed before it is committed rather than after.
+It prefers an `ocomment` on `PATH` and falls back to the workspace copy, so a fresh clone needs no `cargo install` first.
The repository is intentionally split into independent implementations:
@@ -40,24 +37,15 @@ The repository is intentionally split into independent implementations:
- `rust/` contains the product, public library, LSP server, and plugin host.
- `ocaml/` contains the independent reference implementation.
-Do not share scanner code between Rust and OCaml. Matching normalized outputs
-are the cross-check.
-
-**It cannot see a mistake both implementations make.** The corpus asks whether
-the two agree, and two readers written from the same wrong understanding agree
-perfectly. A `#` in a `.gitignore` opens a comment only as the first byte of
-its line; the shipped profile said it opened one anywhere, both implementations
-were told so, and 508 fixtures passed while `ocomment fix` shortened patterns
-and the file quietly stopped ignoring what they named.
-
-So a rule that belongs to something outside this repository — what git does
-with a `#`, what the kernel does with `#!`, what `go mod tidy` puts back — is
-not settled by the two implementations agreeing about it. It is settled by
-finding out, and then written into `spec/fixtures/v1/` as a case, which is
-where an external fact becomes something neither implementation can drift away
-from. A fixture recording *what another tool does* is worth more than one
-recording what this one does, because only the first can fail for a reason
-worth knowing.
+Do not share scanner code between Rust and OCaml.
+Matching normalized outputs are the cross-check.
+
+**It cannot see a mistake both implementations make.** The corpus asks whether the two agree, and two readers written from the same wrong understanding agree perfectly.
+A `#` in a `.gitignore` opens a comment only as the first byte of its line; the shipped profile said it opened one anywhere, both implementations were told so, and 508 fixtures passed while `ocomment fix` shortened patterns and the file quietly stopped ignoring what they named.
+
+So a rule that belongs to something outside this repository — what git does with a `#`, what the kernel does with `#!`, what `go mod tidy` puts back — is not settled by the two implementations agreeing about it.
+It is settled by finding out, and then written into `spec/fixtures/v1/` as a case, which is where an external fact becomes something neither implementation can drift away from.
+A fixture recording *what another tool does* is worth more than one recording what this one does, because only the first can fail for a reason worth knowing.
## Before you push
@@ -66,27 +54,22 @@ cargo xtask preflight # NOTE: everything CI checks that a laptop can
cargo xtask preflight --quick # NOTE: everything but the slowest three
```
-Waiting eight minutes to be told about a stale manual page is not a review
-cycle. Every gate below that a laptop can run, runs there, in the order that
-fails soonest for the least money — and `tools/check_ci_contracts.py` holds the task
-against `.github/workflows/ci.yml`, so a gate added to CI cannot quietly stop
-running locally.
+Waiting eight minutes to be told about a stale manual page is not a review cycle.
+Every gate below that a laptop can run, runs there, in the order that fails soonest for the least money — and `tools/check_ci_contracts.py` holds the task against `.github/workflows/ci.yml`, so a gate added to CI cannot quietly stop running locally.
-`lefthook install` wires it into `pre-push`. What is deliberately left to CI:
-the three-operating-system matrices, the Docker image, CodeQL, and the VS Code
-extension's npm build. Each needs something a laptop is not.
+`lefthook install` wires it into `pre-push`.
+What is deliberately left to CI:
+the three-operating-system matrices, the Docker image, CodeQL, and the VS Code extension's npm build.
+Each needs something a laptop is not.
-Two steps run in both and mean different things in each. `Action pins` and
-`Advisories` are the only gates that ask somebody else — GitHub for what a
-version tag names, OSV for what is known about a pinned version — and they run
-here with `--best-effort`, which names what it could not read and passes. CI
-runs them without it. So a green `preflight` on a train is a weaker claim than a
-green CI, and the line it printed says which of the two you got.
+Two steps run in both and mean different things in each.
+`Action pins` and `Advisories` are the only gates that ask somebody else — GitHub for what a version tag names, OSV for what is known about a pinned version — and they run here with `--best-effort`, which names what it could not read and passes.
+CI runs them without it.
+So a green `preflight` on a train is a weaker claim than a green CI, and the line it printed says which of the two you got.
## Required checks
-Run the checks relevant to your change; scanner or policy changes should run all
-of them.
+Run the checks relevant to your change; scanner or policy changes should run all of them.
```sh
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
@@ -108,35 +91,24 @@ actionlint
lefthook validate
```
-A bare `ocomment` from the repository root is the gate CI runs; see
-[comments carry a tag](#comments-carry-a-tag).
+A bare `ocomment` from the repository root is the gate CI runs; see [comments carry a tag](#comments-carry-a-tag).
-When behavior changes, add the smallest fixture that proves the lexical edge
-case. Keep byte spans half-open, edits sorted and non-overlapping, and output
-deterministic. Update both implementations and their differential expectations
-when the shared contract changes.
+When behavior changes, add the smallest fixture that proves the lexical edge case.
+Keep byte spans half-open, edits sorted and non-overlapping, and output deterministic.
+Update both implementations and their differential expectations when the shared contract changes.
### The YAML round trip
Every other language lets a removal be judged by the bytes it leaves behind.
-YAML does not: a block scalar decides where its body ends from the lines
-*below* it, so the hole a removal leaves on a comment line can be read back as
-content of the scalar above it. That is a property of the *parsed value*, and
-no byte-level fixture can state it.
-
-`tools/yaml_roundtrip.py` states it. Its documents come from four places —
-every YAML case in `spec/fixtures/v1`; a systematic sweep of every block scalar
-header crossed with every short arrangement of blank, comment, and directive
-lines under one; a second sweep of the same headers over trails whose comments
-sit *below* the body's own indentation, where a surviving comment is what the
-body would swallow and the comment above it is the only thing holding it out;
-and a few thousand generated documents of nested mappings, sequences, and block
-scalars with comments in every position, in LF and in CRLF. It strips every one
-of them under all three layouts and all three policies — `conservative`, `standard` and
-`all`, because each keeps a different comment and only a survivor makes the
-hazard reachable — and asserts that PyYAML reads the same value out of it
-afterwards. A document PyYAML rejects *before* the removal is skipped: YAML has
-shapes a lexer cannot rule out and a parser will not take.
+YAML does not: a block scalar decides where its body ends from the lines *below* it, so the hole a removal leaves on a comment line can be read back as content of the scalar above it.
+That is a property of the *parsed value*, and no byte-level fixture can state it.
+
+`tools/yaml_roundtrip.py` states it.
+Its documents come from four places —
+every YAML case in `spec/fixtures/v1`; a systematic sweep of every block scalar header crossed with every short arrangement of blank, comment, and directive lines under one; a second sweep of the same headers over trails whose comments sit *below* the body's own indentation, where a surviving comment is what the body would swallow and the comment above it is the only thing holding it out;
+and a few thousand generated documents of nested mappings, sequences, and block scalars with comments in every position, in LF and in CRLF.
+It strips every one of them under all three layouts and all three policies — `conservative`, `standard` and `all`, because each keeps a different comment and only a survivor makes the hazard reachable — and asserts that PyYAML reads the same value out of it afterwards.
+A document PyYAML rejects *before* the removal is skipped: YAML has shapes a lexer cannot rule out and a parser will not take.
```sh
python3 -m pip install pyyaml
@@ -146,24 +118,16 @@ python3 tools/yaml_roundtrip.py --cases 200 # NOTE: what CI runs
python3 tools/yaml_roundtrip.py --cases 20000 --seed 7 # NOTE: a longer sweep
```
-CI runs `python3 tools/yaml_roundtrip.py --cases 200` in the `dogfood` job: the
-corpus and both enumerated sweeps run in full there — they are where the hazard
-lives and they are the same documents on every run — and only the pseudo-random
-set is cut, because its cost is linear and its value is not. The bare run above
-is the fuller one — around 5,900 documents against CI's 3,700 — and `--seed`
-moves the generated set. Unlike the fuzz below it is deterministic: the seed is
-fixed, so a red run reproduces. Anything it finds belongs in
-`spec/fixtures/v1/hazards.json` as a named case per layout, the same as a fuzz
-finding.
+CI runs `python3 tools/yaml_roundtrip.py --cases 200` in the `dogfood` job: the corpus and both enumerated sweeps run in full there — they are where the hazard lives and they are the same documents on every run — and only the pseudo-random set is cut, because its cost is linear and its value is not.
+The bare run above is the fuller one — around 5,900 documents against CI's 3,700 — and `--seed` moves the generated set.
+Unlike the fuzz below it is deterministic: the seed is fixed, so a red run reproduces.
+Anything it finds belongs in `spec/fixtures/v1/hazards.json` as a named case per layout, the same as a fuzz finding.
### On demand: the differential fuzz
-`tools/differential.py` asks the two implementations the questions
-`spec/fixtures/v1` already knows to ask. `tools/fuzz_differential.py` asks them
-questions nobody thought of — random sources built from the delimiters,
-escapes, quotes and directive words the built-in scanners care about, across
-every language, dialect, policy and layout — and reports each way the answers
-differed once, with a shrunken source that still shows it.
+`tools/differential.py` asks the two implementations the questions `spec/fixtures/v1` already knows to ask.
+`tools/fuzz_differential.py` asks them questions nobody thought of — random sources built from the delimiters,
+escapes, quotes and directive words the built-in scanners care about, across every language, dialect, policy and layout — and reports each way the answers differed once, with a shrunken source that still shows it.
```sh
cargo build --manifest-path rust/Cargo.toml -p ocomment-core --example ref_driver --locked
@@ -172,25 +136,17 @@ python3 tools/fuzz_differential.py --seed 1 --seed 2 # NOTE: ~2 minutes
python3 tools/fuzz_differential.py --cases 200 # NOTE: a quicker sweep
```
-The pool it draws from is one pool for every language, so a scanner meets the
-delimiters it does not own — but it is a pool of *tokens*, and a lexical state
-that only a whole word opens is never reached by a per-byte draw. That is why
-the pool carries a named group for each language whose states are spelled that
-way: a YAML block scalar header, a ` fix.patch` and `--format json | jq` stay clean. A
-machine format writes nothing to standard error but errors and diagnostics.
-Every write to standard output goes through `output::wrote(...)`, which tags a
-lost reader as `OutputPipeClosed` so the run ends quietly instead of reporting
-an unexplained broken pipe; `rust/ocomment/tests/source_guards.rs` enforces
-that. Name a language, dialect, comment kind, policy, layout, or disposition
-through its `as_str()` and never through `Debug`: the canonical spellings are
-kebab-case (`doc-block`, `html-comment`) and are shared with the human, JSON,
-JSONL, SARIF, and GitHub output. All user-facing text is English.
+Findings, patches, generated files, and every machine format are written to standard output; run summaries, progress, and notes are written to standard error, so `ocomment diff > fix.patch` and `--format json | jq` stay clean.
+A machine format writes nothing to standard error but errors and diagnostics.
+Every write to standard output goes through `output::wrote(...)`, which tags a lost reader as `OutputPipeClosed` so the run ends quietly instead of reporting an unexplained broken pipe; `rust/ocomment/tests/source_guards.rs` enforces that.
+Name a language, dialect, comment kind, policy, layout, or disposition through its `as_str()` and never through `Debug`: the canonical spellings are kebab-case (`doc-block`, `html-comment`) and are shared with the human, JSON,
+JSONL, SARIF, and GitHub output.
+All user-facing text is English.
## Pull requests
- Keep each pull request focused and explain compatibility or safety effects.
- Use a Conventional Commit subject for the pull request's squash commit:
- `fix:`, `feat:`, and a `!` or `BREAKING CHANGE:` footer are the release-plz
- signals for the next SemVer version and changelog. Use a scope when it makes
- the subject clearer, such as `feat(languages): ...`.
+ `fix:`, `feat:`, and a `!` or `BREAKING CHANGE:` footer are the release-plz signals for the next SemVer version and changelog.
+ Use a scope when it makes the subject clearer, such as `feat(languages): ...`.
- Add tests for observable behavior and update user-facing documentation.
-- Regenerate checked-in schemas, WIT, man pages, or completions when their source
- changes; `tools/check_embedded_specs.py` checks shared embedded assets.
-- Adding a language to `spec/languages.toml` does not require a second
- pre-commit extension list: `.pre-commit-hooks.yaml` sends every text file to
- the CLI detector, and `tools/check_hooks.py` rejects a filter that would hide
- reserved names or extensionless shebang scripts. It changes what this
- repository checks about itself as well: a file the new scanner now reads is a
- file whose comments have to carry a tag, so run a bare `ocomment` before
- opening the change. Two more things count the languages rather than reading
- the table: `spec/fixtures/v1/floor.txt`, the floors that stop a later change
- from quietly dropping the fixtures the language brings — `cases` and
- `expectations`, both read by `tools/differential.py` and by
- `rust/ocomment-core/tests/spec_fixtures.rs`, and both raised by the number of
- fixtures the language adds in the same commit that adds them — and the
- editor clients: `editors/vscode/package.json` lists the identifiers the
- extension attaches to in both `activationEvents` and the `ocomment.languages`
- default, and `docs/editors.md` names and counts them. An editor identifier is
- written in the editor's vocabulary rather than in this one, so it also has to
- *reach* the language: `language_from_lsp` in `rust/ocomment/src/lsp.rs` parses
- it as a `Language` and carries an arm for each one that does not agree —
- `objective-c`, `cuda-cpp`, `javascriptreact`, `shellscript`. Nothing else
- notices when a new identifier agrees with nothing: the extension activates,
- the server opens the document, and the scan comes back with the
- `unknown-language` diagnostic and no comments, which reads like a language
- that was never added at all.
- `every_editor_language_identifier_reaches_a_built_in_language` in that file
- reads the selector and fails instead. The two
- published JSON schemas carry the vocabulary rather than deriving it:
- `spec/config.schema.json` enumerates the languages a configuration may name
- and `spec/result.schema.json` the ones a report may carry, which is the same
- list plus `unknown`. `the_schemas_enumerate_the_same_vocabulary` compares both
- against the table, so a language added to one file alone fails the build. Every
- written-out count of languages or of editor language identifiers is checked
- against `Language::ALL` and against that selector by
- `every_written_language_count_matches_what_it_counts` in
- `rust/ocomment/tests/spec_languages.rs`, so the sentences fail the build
- rather than drifting. It reads six files, and the whole set is worth having in
- front of you rather than discovering one failure at a time: the coverage row
- of `docs/comparison.md`, the `description` of
- `editors/vscode/package.json`, `editors/vscode/README.md` and
- `editors/vscode/CHANGELOG.md` — the extension carries its own two counts
- besides the ones in `docs/` — `docs/editors.md`, and `CHANGELOG.md`, which is
- counted twice, once for the languages and once for the editor identifiers.
+- Regenerate checked-in schemas, WIT, man pages, or completions when their source changes; `tools/check_embedded_specs.py` checks shared embedded assets.
+- Adding a language to `spec/languages.toml` does not require a second pre-commit extension list: `.pre-commit-hooks.yaml` sends every text file to the CLI detector, and `tools/check_hooks.py` rejects a filter that would hide reserved names or extensionless shebang scripts.
+ It changes what this repository checks about itself as well: a file the new scanner now reads is a file whose comments have to carry a tag, so run a bare `ocomment` before opening the change.
+ Two more things count the languages rather than reading the table: `spec/fixtures/v1/floor.txt`, the floors that stop a later change from quietly dropping the fixtures the language brings — `cases` and `expectations`, both read by `tools/differential.py` and by `rust/ocomment-core/tests/spec_fixtures.rs`, and both raised by the number of fixtures the language adds in the same commit that adds them — and the editor clients: `editors/vscode/package.json` lists the identifiers the extension attaches to in both `activationEvents` and the `ocomment.languages` default, and `docs/editors.md` names and counts them.
+ An editor identifier is written in the editor's vocabulary rather than in this one, so it also has to *reach* the language: `language_from_lsp` in `rust/ocomment/src/lsp.rs` parses it as a `Language` and carries an arm for each one that does not agree —
+ `objective-c`, `cuda-cpp`, `javascriptreact`, `shellscript`.
+ Nothing else notices when a new identifier agrees with nothing: the extension activates,
+ the server opens the document, and the scan comes back with the `unknown-language` diagnostic and no comments, which reads like a language that was never added at all.
+ `every_editor_language_identifier_reaches_a_built_in_language` in that file reads the selector and fails instead.
+ The two published JSON schemas carry the vocabulary rather than deriving it:
+ `spec/config.schema.json` enumerates the languages a configuration may name and `spec/result.schema.json` the ones a report may carry, which is the same list plus `unknown`.
+ `the_schemas_enumerate_the_same_vocabulary` compares both against the table, so a language added to one file alone fails the build.
+ Every written-out count of languages or of editor language identifiers is checked against `Language::ALL` and against that selector by `every_written_language_count_matches_what_it_counts` in `rust/ocomment/tests/spec_languages.rs`, so the sentences fail the build rather than drifting.
+ It reads six files, and the whole set is worth having in front of you rather than discovering one failure at a time: the coverage row of `docs/comparison.md`, the `description` of `editors/vscode/package.json`, `editors/vscode/README.md` and `editors/vscode/CHANGELOG.md` — the extension carries its own two counts besides the ones in `docs/` — `docs/editors.md`, and `CHANGELOG.md`, which is counted twice, once for the languages and once for the editor identifiers.
The *names* beside those counts are still yours to extend: `docs/editors.md`,
- the language lists in `README.md` and `editors/vscode/README.md`, and the
- `Added` entry in `CHANGELOG.md`. Two more
- places name the language rather than counting it: `Language::ALL.len()` is
- asserted outright by `language_names_are_stable` in
- `rust/ocomment-core/tests/names.rs`, and every language carries one line of
- per-value help in `rust/ocomment/src/values.rs`. Every spelling the language
- answers to goes in that same test: `language_aliases_are_pinned` holds a row
- for the canonical name and for each entry of `Language::aliases`, and it
- checks that table *against* `Language::aliases` in both directions, so an
- alias added to the one and not the other fails there rather than shipping
- unpinned. That help is `--help` text,
- so adding it makes the checked-in manual page and the shell completions
- stale: regenerate them with `python3 tools/release_extras.py --binary
- rust/target/debug/ocomment`, copy `release-extras/ocomment.1` to `docs/`, and
- run `python3 tools/gen_docs.py --binary rust/target/debug/ocomment` for the
- generated pages.
-- An interpreter name a `#!` line is read for is searched for as a *substring*
- of that line, because an interpreter arrives written a dozen ways: as a path,
- with a version, or behind `env` with options. The order of `SHEBANGS` in
- `rust/ocomment-core/src/detect.rs` is therefore part of the rule and not an
- accident of listing — a name another name *contains* has to be met first, or
- every Bash script on disk would be read as POSIX `sh`. A name too short to be
- looked for that way is what the table carries a `Spelling` for: `r`, the front
- end littler installs, is one letter, and `/usr/` alone carries one, so it is
- compared against the whole words of the line and is listed last. Publish every
- name in `spec/languages.toml` in the same change:
- `the_detector_knows_no_unrecorded_shebang` compares that list against
- `ocomment_core::shebang_interpreters` in both directions, and
- `every_listed_shebang_detects_its_language` runs the detector over
- `#!/usr/bin/env ` for each one.
+ the language lists in `README.md` and `editors/vscode/README.md`, and the `Added` entry in `CHANGELOG.md`.
+ Two more places name the language rather than counting it: `Language::ALL.len()` is asserted outright by `language_names_are_stable` in `rust/ocomment-core/tests/names.rs`, and every language carries one line of per-value help in `rust/ocomment/src/values.rs`.
+ Every spelling the language answers to goes in that same test: `language_aliases_are_pinned` holds a row for the canonical name and for each entry of `Language::aliases`, and it checks that table *against* `Language::aliases` in both directions, so an alias added to the one and not the other fails there rather than shipping unpinned.
+ That help is `--help` text,
+ so adding it makes the checked-in manual page and the shell completions stale: regenerate them with `python3 tools/release_extras.py --binary rust/target/debug/ocomment`, copy `release-extras/ocomment.1` to `docs/`, and run `python3 tools/gen_docs.py --binary rust/target/debug/ocomment` for the generated pages.
+- An interpreter name a `#!` line is read for is searched for as a *substring* of that line, because an interpreter arrives written a dozen ways: as a path,
+ with a version, or behind `env` with options.
+ The order of `SHEBANGS` in `rust/ocomment-core/src/detect.rs` is therefore part of the rule and not an accident of listing — a name another name *contains* has to be met first, or every Bash script on disk would be read as POSIX `sh`.
+ A name too short to be looked for that way is what the table carries a `Spelling` for: `r`, the front end littler installs, is one letter, and `/usr/` alone carries one, so it is compared against the whole words of the line and is listed last.
+ Publish every name in `spec/languages.toml` in the same change:
+ `the_detector_knows_no_unrecorded_shebang` compares that list against `ocomment_core::shebang_interpreters` in both directions, and `every_listed_shebang_detects_its_language` runs the detector over `#!/usr/bin/env ` for each one.
- A language whose lexical mode is document state rather than line state — PHP,
- where the same line means one thing under an unclosed `` checks whatever was mounted.
+# NOTE: The default `check` target is the working directory, so a bare `docker run -v "$PWD:/src" ` checks whatever was mounted.
WORKDIR /src
ENTRYPOINT ["/ocomment"]
CMD ["check"]
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index d6e32a6..4da6f94 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -1,19 +1,16 @@
# Governance
-OComment is currently maintained by [@P4suta](https://github.com/P4suta), who is
-responsible for repository administration, final technical decisions, and
-release authorization.
+OComment is currently maintained by [@P4suta](https://github.com/P4suta), who is responsible for repository administration, final technical decisions, and release authorization.
Design work is discussed in public issues or Discussions whenever possible.
-Decisions prioritize, in order, byte and file safety, compatibility with the
-documented lexical contracts, deterministic Rust/OCaml agreement, correctness,
-and measured performance. Significant changes should record the alternatives
-and compatibility consequences before implementation.
+Decisions prioritize, in order, byte and file safety, compatibility with the documented lexical contracts, deterministic Rust/OCaml agreement, correctness,
+and measured performance.
+Significant changes should record the alternatives and compatibility consequences before implementation.
Contributors can earn broader maintenance responsibility through sustained,
-high-quality review and implementation work. Changes to governance will be made
-through a pull request so the history remains public.
+high-quality review and implementation work.
+Changes to governance will be made through a pull request so the history remains public.
-The active default-branch and release-tag protections are mirrored under
-`.github/rulesets/`. `main` requires the portable CI matrix and squash merging;
+The active default-branch and release-tag protections are mirrored under `.github/rulesets/`.
+`main` requires the portable CI matrix and squash merging;
the fixed-runner performance gate remains opt-in while that runner is offline.
diff --git a/README.md b/README.md
index 4cd649d..0e31d9d 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,9 @@
# OComment
-[](https://github.com/P4suta/OComment/actions/workflows/ci.yml)
-[](https://github.com/P4suta/OComment/actions/workflows/codeql.yml)
-[](rust/Cargo.toml)
-[](#license)
+[](https://github.com/P4suta/OComment/actions/workflows/ci.yml) [](https://github.com/P4suta/OComment/actions/workflows/codeql.yml) [](rust/Cargo.toml) [](#license)
-OComment is a fast, byte-preserving comment checker and remover. The production
-tool is the Rust `ocomment` binary and the public `ocomment-core` library.
+OComment is a fast, byte-preserving comment checker, formatter and remover.
+The production tool is the Rust `ocomment` binary and the public `ocomment-core` library.
`ocomment-ref` is an independent OCaml implementation used to check the scanner,
classification, diagnostics, edits, transformed bytes, and source maps.
@@ -15,14 +12,11 @@ Shell, HTML, CSS, JSONC, SQL, Kotlin, TOML, Lua, YAML, PHP, Ruby, Zig, R, Dart,
Swift, C#, Scala, Vue, Svelte, Markdown, and Perl.
JSX/TSX,
Objective-C/C++,
-GNU C/C++, CUDA, POSIX sh, Bash 5.3, zsh, PostgreSQL, MySQL, SQLite, T-SQL, and
-Oracle are explicit dialects. HTML `"#;
assert!(
scan(template, Language::Html, ScanOptions::default())
@@ -668,8 +637,7 @@ fn html_comments_are_explicit_only_and_embedded_languages_recurse() {
}
/* NOTE: `\n\n";
@@ -6748,10 +6132,7 @@ fn vue_script_and_style_blocks_are_embedded() {
);
}
-/// A `lang` this scanner has no rules for makes the block opaque: a
-/// `\n\n\n// not a comment\n\n";
@@ -6760,11 +6141,9 @@ fn vue_unknown_embedded_languages_are_opaque() {
assert!(report.comments.is_empty(), "{:?}", report.comments);
}
-/// The `v-pre` directive makes an element's content raw text, so the mustache
-/// it holds is not code and the `//` in it is not a comment.
+/// The `v-pre` directive makes an element's content raw text, so the mustache it holds is not code and the `//` in it is not a comment.
///
-/// Ground truth, `@vue/compiler-sfc` 3.5: `
{{ x // c }}
`
-/// parses with the whole content as one text node.
+/// Ground truth, `@vue/compiler-sfc` 3.5: `
{{ x // c }}
` parses with the whole content as one text node.
#[test]
fn vue_v_pre_elements_are_opaque() {
let source = b"
{{ x // not }}
\n\n\n\n";
@@ -6784,13 +6163,9 @@ fn vue_is_detected_from_its_extension() {
assert_eq!(found.reason, "extension");
}
-/// A Svelte component's template is HTML with code in its braces: every
-/// `{ ... }` opens an expression whose comments are comments — a line one runs
-/// to the end of its line — and `` is an HTML comment.
+/// A Svelte component's template is HTML with code in its braces: every `{ ... }` opens an expression whose comments are comments — a line one runs to the end of its line — and `` is an HTML comment.
///
-/// Ground truth, `svelte/compiler` 5.56: the source below parses with the
-/// `/* c */` and `// d` as comments of their expressions and the HTML comment
-/// as a comment node.
+/// Ground truth, `svelte/compiler` 5.56: the source below parses with the `/* c */` and `// d` as comments of their expressions and the HTML comment as a comment node.
#[test]
fn svelte_expressions_and_comments_in_the_template() {
let source = b"
\r\n"}},{"id":"php-unterminated-heredoc","language":"php","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"() {} // remove\n","expect":{"valid":true,"comments":[{"start":15,"end":24,"kind":"line","action":"remove"}]}},{"id":"rust-unicode-loop-label","language":"rust","operation":"scan","options":{"policy":"standard","layout":"lines"},"source_utf8":"'ä: loop { break 'ä } // remove\n","expect":{"valid":true,"comments":[{"start":24,"end":33,"kind":"line","action":"remove"}]}},{"id":"ocaml-char-literal-across-newline","language":"ocaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = '\n' (* remove *)\nlet b = '\\\n' (* remove *)\n","expect":{"valid":true,"comments":[{"start":12,"end":24,"kind":"block","action":"remove"},{"start":38,"end":50,"kind":"block","action":"remove"}],"output_utf8":"let a = '\n' \nlet b = '\\\n' \n"}},{"id":"ruby-alias-percent-s","language":"ruby","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"alias%s(baz # x) %s(bar)\nputs 1 # remove\n","expect":{"valid":true,"comments":[{"start":32,"end":40,"kind":"line","action":"remove"}],"output_utf8":"alias%s(baz # x) %s(bar)\nputs 1 \n"}},{"id":"bom-shebang-dart","language":"dart","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"77u/IyEvdXNyL2Jpbi9lbnYgZGFydAp2b2lkIG1haW4oKSB7fSAvLyByZW1vdmUK","expect":{"valid":true,"comments":[{"start":38,"end":47,"kind":"line","action":"remove"}],"output_base64":"77u/IyEvdXNyL2Jpbi9lbnYgZGFydAp2b2lkIG1haW4oKSB7fSAK"}},{"id":"swift-nested-block-comment","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* outer /* inner */ still outer */\nlet a = 1 // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":35,"kind":"block","action":"remove"},{"start":46,"end":55,"kind":"line","action":"remove"}],"output_utf8":"\nlet a = 1 \n"}},{"id":"swift-doc-forms","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/// doc\n//// four\n//! not swift\n/** doc */\n/*! bang */\n/**/\n/***/\n// line\nlet a = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":7,"kind":"doc-line","action":"remove"},{"start":8,"end":17,"kind":"doc-line","action":"remove"},{"start":18,"end":31,"kind":"line","action":"remove"},{"start":32,"end":42,"kind":"doc-block","action":"remove"},{"start":43,"end":54,"kind":"block","action":"remove"},{"start":55,"end":59,"kind":"block","action":"remove"},{"start":60,"end":65,"kind":"doc-block","action":"remove"},{"start":66,"end":73,"kind":"line","action":"remove"}],"output_utf8":"\n\n\n\n\n\n\n\nlet a = 1\n"}},{"id":"swift-interpolation-comment","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"v: \\( 1 /* c */ + 2 )\" // remove\n","expect":{"valid":true,"comments":[{"start":17,"end":24,"kind":"block","action":"remove"},{"start":33,"end":42,"kind":"line","action":"remove"}],"output_utf8":"let a = \"v: \\( 1 + 2 )\" \n"}},{"id":"swift-multiline-string","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"\"\"\n// not\n\"\"\"\n// remove\n","expect":{"valid":true,"comments":[{"start":23,"end":32,"kind":"line","action":"remove"}],"output_utf8":"let a = \"\"\"\n// not\n\"\"\"\n\n"}},{"id":"swift-raw-string-hashes","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = ##\"a \"# // not\"##\n// remove\n","expect":{"valid":true,"comments":[{"start":26,"end":35,"kind":"line","action":"remove"}],"output_utf8":"let a = ##\"a \"# // not\"##\n\n"}},{"id":"swift-raw-multiline","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #\"\"\"\n// not \\(1)\n\"\"\"#\n// remove\n","expect":{"valid":true,"comments":[{"start":30,"end":39,"kind":"line","action":"remove"}],"output_utf8":"let a = #\"\"\"\n// not \\(1)\n\"\"\"#\n\n"}},{"id":"swift-raw-interpolation","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #\"v: \\#( 1 /* c */ ) and \\(1)\"# // remove\n","expect":{"valid":true,"comments":[{"start":19,"end":26,"kind":"block","action":"remove"},{"start":41,"end":50,"kind":"line","action":"remove"}],"output_utf8":"let a = #\"v: \\#( 1 ) and \\(1)\"# \n"}},{"id":"swift-raw-quote-only","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #\"\"\"#\n// remove\n","expect":{"valid":true,"comments":[{"start":14,"end":23,"kind":"line","action":"remove"}],"output_utf8":"let a = #\"\"\"#\n\n"}},{"id":"swift-string-pound-boundary","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"x\"#/y // z/#\nlet b = 1 // remove\n","expect":{"valid":true,"comments":[{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"let a = \"x\"#/y // z/#\nlet b = 1 \n"}},{"id":"swift-extended-regex-literal","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #/https://x/# // remove\n","expect":{"valid":true,"comments":[{"start":23,"end":32,"kind":"line","action":"remove"}],"output_utf8":"let a = #/https://x/# \n"}},{"id":"swift-extended-regex-multiline","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #/\n x y\n/#\n// remove\n","expect":{"valid":true,"comments":[{"start":20,"end":29,"kind":"line","action":"remove"}],"output_utf8":"let a = #/\n x y\n/#\n\n"}},{"id":"swift-bare-regex-literal","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = /a\\//;print(1) // remove\n","expect":{"valid":true,"comments":[{"start":23,"end":32,"kind":"line","action":"remove"}],"output_utf8":"let a = /a\\//;print(1) \n"}},{"id":"swift-bare-regex-limitation","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = / b\\//\nlet c = 1\n","expect":{"valid":true,"comments":[{"start":12,"end":14,"kind":"line","action":"remove"}],"output_utf8":"let a = / b\\\nlet c = 1\n"}},{"id":"swift-division-not-regex","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = 1 / 2 // remove\nlet b = a/a/a // remove\n","expect":{"valid":true,"comments":[{"start":14,"end":23,"kind":"line","action":"remove"},{"start":38,"end":47,"kind":"line","action":"remove"}],"output_utf8":"let a = 1 / 2 \nlet b = a/a/a \n"}},{"id":"swift-regex-comment-wins","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = /x//y/\nlet b = 1\n","expect":{"valid":true,"comments":[{"start":10,"end":14,"kind":"line","action":"remove"}],"output_utf8":"let a = /x\nlet b = 1\n"}},{"id":"swift-compiler-directive-not-comment","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if DEBUG\nlet a = 1 // remove\n#endif\n#warning(\"x // y\")\n","expect":{"valid":true,"comments":[{"start":20,"end":29,"kind":"line","action":"remove"}],"output_utf8":"#if DEBUG\nlet a = 1 \n#endif\n#warning(\"x // y\")\n"}},{"id":"swift-tools-version-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// swift-tools-version:5.9\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":26,"kind":"load-bearing","action":"keep"},{"start":27,"end":37,"kind":"line","action":"remove"}],"output_utf8":"// swift-tools-version:5.9\n\n"}},{"id":"swift-swiftlint-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// swiftlint:disable force_cast\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":31,"kind":"directive","action":"keep"},{"start":32,"end":42,"kind":"line","action":"remove"}],"output_utf8":"// swiftlint:disable force_cast\n\n"}},{"id":"swift-format-ignore-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// swift-format-ignore-file\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":27,"kind":"directive","action":"keep"},{"start":28,"end":38,"kind":"line","action":"remove"}],"output_utf8":"// swift-format-ignore-file\n\n"}},{"id":"swift-mark-is-not-a-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// MARK: - Section\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"line","action":"remove"},{"start":19,"end":29,"kind":"line","action":"remove"}],"output_utf8":"\n\n"}},{"id":"swift-unterminated-nested","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* open /* inner */\nlet a = 1\n","expect":{"valid":false,"comments":[{"start":0,"end":30,"kind":"block","action":"remove"}],"output_utf8":"/* open /* inner */\nlet a = 1\n"}},{"id":"swift-unterminated-multiline","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"\"\"\nopen\nlet b = 2\n","expect":{"valid":false,"comments":[],"output_utf8":"let a = \"\"\"\nopen\nlet b = 2\n"}},{"id":"swift-unterminated-extended-regex","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #/\nopen\nlet b = 2 // remove\n","expect":{"valid":false,"comments":[{"start":26,"end":35,"kind":"line","action":"remove"}],"output_utf8":"let a = #/\nopen\nlet b = 2 // remove\n"}},{"id":"swift-single-quoted-recovery","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = 'x // not'\n// remove\n","expect":{"valid":true,"comments":[{"start":19,"end":28,"kind":"line","action":"remove"}],"output_utf8":"let a = 'x // not'\n\n"}},{"id":"swift-shebang","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#!/usr/bin/env swift\n// remove\nlet a = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":20,"kind":"shebang","action":"keep"},{"start":21,"end":30,"kind":"line","action":"remove"}],"output_utf8":"#!/usr/bin/env swift\n\nlet a = 1\n"}},{"id":"swift-crlf","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* block\r\nstill */\r\nlet a = \"\"\"\r\nx\r\n\"\"\"\r\nlet b = #/\r\n x\r\n/#\r\n// remove\r\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"block","action":"remove"},{"start":62,"end":71,"kind":"line","action":"remove"}],"output_utf8":"\r\n\r\nlet a = \"\"\"\r\nx\r\n\"\"\"\r\nlet b = #/\r\n x\r\n/#\r\n\r\n"}},{"id":"swift-columns","language":"swift","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"// alone\nlet x = 1 // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":19,"end":30,"kind":"line","action":"remove"}],"output_utf8":" \nlet x = 1 \n"}},{"id":"swift-compact","language":"swift","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"// alone\nlet x = 1 // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":19,"end":30,"kind":"line","action":"remove"}],"output_utf8":"let x = 1\n"}},{"id":"bom-shebang-javascript","language":"javascript","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"77u/IyEvdXNyL2Jpbi9lbnYgbm9kZQpsZXQgeCA9IDE7IC8vIHJlbW92ZQo=","expect":{"valid":true,"comments":[{"start":34,"end":43,"kind":"line","action":"remove"}],"output_base64":"77u/IyEvdXNyL2Jpbi9lbnYgbm9kZQpsZXQgeCA9IDE7IAo="}},{"id":"csharp-doc-forms","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/// doc\n//// four\n//! not csharp\n/** doc */\n/*! bang */\n/**/\n/***/\n/*** three */\n// line\nclass C { }\n","expect":{"valid":true,"comments":[{"start":0,"end":7,"kind":"doc-line","action":"remove"},{"start":8,"end":17,"kind":"line","action":"remove"},{"start":18,"end":32,"kind":"line","action":"remove"},{"start":33,"end":43,"kind":"doc-block","action":"remove"},{"start":44,"end":55,"kind":"block","action":"remove"},{"start":56,"end":60,"kind":"block","action":"remove"},{"start":61,"end":66,"kind":"block","action":"remove"},{"start":67,"end":80,"kind":"block","action":"remove"},{"start":81,"end":88,"kind":"line","action":"remove"}],"output_utf8":"\n\n\n\n\n\n\n\n\nclass C { }\n"}},{"id":"csharp-non-nested-block","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* outer /* inner */ still outer */\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":20,"kind":"block","action":"remove"},{"start":47,"end":56,"kind":"line","action":"remove"}],"output_utf8":" still outer */\nvar a = 1; \n"}},{"id":"csharp-verbatim-string-quotes","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = @\"quote \"\" inside // no\"; // remove\n","expect":{"valid":true,"comments":[{"start":34,"end":43,"kind":"line","action":"remove"}],"output_utf8":"var s = @\"quote \"\" inside // no\"; \n"}},{"id":"csharp-verbatim-multiline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = @\"first // no\nsecond */ no\"; // remove\n","expect":{"valid":true,"comments":[{"start":37,"end":46,"kind":"line","action":"remove"}],"output_utf8":"var s = @\"first // no\nsecond */ no\"; \n"}},{"id":"csharp-verbatim-identifier","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var @class = 1; // remove\n","expect":{"valid":true,"comments":[{"start":16,"end":25,"kind":"line","action":"remove"}],"output_utf8":"var @class = 1; \n"}},{"id":"csharp-interpolated-braces-escape","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"{{literal}} // no {x} tail\"; // remove\n","expect":{"valid":true,"comments":[{"start":39,"end":48,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"{{literal}} // no {x} tail\"; \n"}},{"id":"csharp-interpolated-hole-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"v={x /* hole */} // no\"; // remove\n","expect":{"valid":true,"comments":[{"start":15,"end":25,"kind":"block","action":"remove"},{"start":35,"end":44,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"v={x } // no\"; \n"}},{"id":"csharp-interpolated-hole-newline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"v={x // hole\n}\"; // remove\n","expect":{"valid":true,"comments":[{"start":15,"end":22,"kind":"line","action":"remove"},{"start":27,"end":36,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"v={x \n}\"; \n"}},{"id":"csharp-interpolated-format-clause","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"{x:D4 // no}\"; // remove\n","expect":{"valid":true,"comments":[{"start":25,"end":34,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"{x:D4 // no}\"; \n"}},{"id":"csharp-verbatim-interpolated","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $@\"a {x} // no\nb\"; var t = @$\"c\"; // remove\n","expect":{"valid":true,"comments":[{"start":42,"end":51,"kind":"line","action":"remove"}],"output_utf8":"var s = $@\"a {x} // no\nb\"; var t = @$\"c\"; \n"}},{"id":"csharp-raw-string-quotes","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"\"\"\"three \"\"\" inside // no\"\"\"\"; // remove\n","expect":{"valid":true,"comments":[{"start":40,"end":49,"kind":"line","action":"remove"}],"output_utf8":"var s = \"\"\"\"three \"\"\" inside // no\"\"\"\"; \n"}},{"id":"csharp-raw-multiline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"\"\"\n body // no\n \"\"\"; // remove\n","expect":{"valid":true,"comments":[{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"var s = \"\"\"\n body // no\n \"\"\"; \n"}},{"id":"csharp-raw-interpolated-dollar","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $$\"\"\"{not a hole} {{x /* hole */}} // no\"\"\"; // remove\n","expect":{"valid":true,"comments":[{"start":30,"end":40,"kind":"block","action":"remove"},{"start":53,"end":62,"kind":"line","action":"remove"}],"output_utf8":"var s = $$\"\"\"{not a hole} {{x }} // no\"\"\"; \n"}},{"id":"csharp-utf8-literal","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"bytes // no\"u8; // remove\n","expect":{"valid":true,"comments":[{"start":25,"end":34,"kind":"line","action":"remove"}],"output_utf8":"var s = \"bytes // no\"u8; \n"}},{"id":"csharp-string-escape-carries-a-newline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"a\\\nb // no\"; // remove\n","expect":{"valid":true,"comments":[{"start":22,"end":31,"kind":"line","action":"remove"}],"output_utf8":"var s = \"a\\\nb // no\"; \n"}},{"id":"csharp-character-literals","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"char a = '/'; char b = '\\''; char c = '\"'; // remove\n","expect":{"valid":true,"comments":[{"start":43,"end":52,"kind":"line","action":"remove"}],"output_utf8":"char a = '/'; char b = '\\''; char c = '\"'; \n"}},{"id":"csharp-preprocessor-if-with-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if DEBUG // kept\nvar a = 1; // remove\n#endif // tail\n","expect":{"valid":true,"comments":[{"start":10,"end":17,"kind":"line","action":"remove"},{"start":29,"end":38,"kind":"line","action":"remove"},{"start":46,"end":53,"kind":"line","action":"remove"}],"output_utf8":"#if DEBUG \nvar a = 1; \n#endif \n"}},{"id":"csharp-region-text-not-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#region Name // not a comment\n#endregion // a comment\n","expect":{"valid":true,"comments":[{"start":41,"end":53,"kind":"line","action":"remove"}],"output_utf8":"#region Name // not a comment\n#endregion \n"}},{"id":"csharp-pragma-text","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#pragma warning disable 1591 // a comment\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":29,"end":41,"kind":"line","action":"remove"},{"start":53,"end":62,"kind":"line","action":"remove"}],"output_utf8":"#pragma warning disable 1591 \nvar a = 1; \n"}},{"id":"csharp-line-directive-string","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#line 1 \"a//b.cs\" // tail\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":18,"end":25,"kind":"line","action":"remove"},{"start":37,"end":46,"kind":"line","action":"remove"}],"output_utf8":"#line 1 \"a//b.cs\" \nvar a = 1; \n"}},{"id":"csharp-error-message-not-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#error boom // no\n","expect":{"valid":true,"comments":[],"output_utf8":"#error boom // no\n"}},{"id":"csharp-directive-block-comment-is-not-one","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if A /* no */ && B\n#endif\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":38,"end":47,"kind":"line","action":"remove"}],"output_utf8":"#if A /* no */ && B\n#endif\nvar a = 1; \n"}},{"id":"csharp-hash-after-code-is-not-a-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var a = 1; #if X // no\n#endif\n","expect":{"valid":true,"comments":[],"output_utf8":"var a = 1; #if X // no\n#endif\n"}},{"id":"csharp-unicode-line-terminator","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"dmFyIGEgPSAxOyAvLyBj4oCodmFyIGIgPSAyOyAvLyByZW1vdmUK","expect":{"valid":true,"comments":[{"start":11,"end":15,"kind":"line","action":"remove"},{"start":29,"end":38,"kind":"line","action":"remove"}],"output_base64":"dmFyIGEgPSAxOyDigKh2YXIgYiA9IDI7IAo="}},{"id":"csharp-auto-generated-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// \nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":20,"kind":"directive","action":"keep"},{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"// \nvar a = 1; \n"}},{"id":"csharp-resharper-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// ReSharper disable once UnusedMember.Local\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":44,"kind":"directive","action":"keep"},{"start":56,"end":65,"kind":"line","action":"remove"}],"output_utf8":"// ReSharper disable once UnusedMember.Local\nvar a = 1; \n"}},{"id":"csharp-csharpier-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// csharpier-ignore\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":19,"kind":"directive","action":"keep"},{"start":34,"end":43,"kind":"line","action":"remove"}],"output_utf8":"// csharpier-ignore\nvar a = 1; \n"}},{"id":"csharp-csx-shebang","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#!/usr/bin/env dotnet-script\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":28,"kind":"shebang","action":"keep"},{"start":40,"end":49,"kind":"line","action":"remove"}],"output_utf8":"#!/usr/bin/env dotnet-script\nvar a = 1; \n"}},{"id":"csharp-unterminated-verbatim","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = @\"open\nvar b = 2;\n","expect":{"valid":false,"comments":[],"output_utf8":"var s = @\"open\nvar b = 2;\n"}},{"id":"csharp-unterminated-raw","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"\"\"\nopen\nvar b = 2;\n","expect":{"valid":false,"comments":[],"output_utf8":"var s = \"\"\"\nopen\nvar b = 2;\n"}},{"id":"csharp-unterminated-block","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* open\nvar a = 1;\n","expect":{"valid":false,"comments":[{"start":0,"end":19,"kind":"block","action":"remove"}],"output_utf8":"/* open\nvar a = 1;\n"}},{"id":"csharp-crlf","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* block\r\nstill */\r\nvar a = @\"x\r\ny\";\r\nvar b = \"\"\"\r\nz\r\n\"\"\";\r\n#if A // kept\r\n#endif\r\n// remove\r\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"block","action":"remove"},{"start":66,"end":73,"kind":"line","action":"remove"},{"start":83,"end":92,"kind":"line","action":"remove"}],"output_utf8":"\r\n\r\nvar a = @\"x\r\ny\";\r\nvar b = \"\"\"\r\nz\r\n\"\"\";\r\n#if A \r\n#endif\r\n\r\n"}},{"id":"csharp-columns","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"// alone\nvar x = 1; // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":20,"end":31,"kind":"line","action":"remove"}],"output_utf8":" \nvar x = 1; \n"}},{"id":"csharp-compact","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"// alone\nvar x = 1; // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":20,"end":31,"kind":"line","action":"remove"}],"output_utf8":"var x = 1;\n"}},{"id":"csharp-byte-order-mark-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"77u/I3ByYWdtYSB3YXJuaW5nIGRpc2FibGUgMTU5MSAvLyBhIGNvbW1lbnQKdmFyIGEgPSAxOyAvLyByZW1vdmUK","expect":{"valid":true,"comments":[{"start":32,"end":44,"kind":"line","action":"remove"},{"start":56,"end":65,"kind":"line","action":"remove"}],"output_base64":"77u/I3ByYWdtYSB3YXJuaW5nIGRpc2FibGUgMTU5MSAKdmFyIGEgPSAxOyAK"}},{"id":"csharp-conditional-section-limitation","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if false\n' not C# at all\n#endif\nvar a = 1; // remove\n","expect":{"valid":false,"comments":[{"start":44,"end":53,"kind":"line","action":"remove"}],"output_utf8":"#if false\n' not C# at all\n#endif\nvar a = 1; // remove\n"}},{"id":"python-prefixed-string-in-fstring-expression","language":"python","operation":"scan","options":{"policy":"standard","layout":"lines"},"source_utf8":"f\"{r\"x\n","expect":{"valid":false,"comments":[]}},{"id":"scala-triple-quote-run","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val a = \"\"\"a\"\"\"\"\nval b = \"\"\"\"\"\"\n// remove\n","expect":{"valid":true,"comments":[{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"val a = \"\"\"a\"\"\"\"\nval b = \"\"\"\"\"\"\n\n"}},{"id":"scala-backquoted-identifier","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val `a//b` = 1\nval c = `x /* y */`\n// remove\n","expect":{"valid":true,"comments":[{"start":35,"end":44,"kind":"line","action":"remove"}],"output_utf8":"val `a//b` = 1\nval c = `x /* y */`\n\n"}},{"id":"scala-xml-literal-text","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val a = // text\nval b = \nval c = {x // code\n}\n// remove\n","expect":{"valid":true,"comments":[{"start":34,"end":47,"kind":"html-comment","action":"keep"},{"start":66,"end":73,"kind":"line","action":"remove"},{"start":80,"end":89,"kind":"line","action":"remove"}],"output_utf8":"val a = // text\nval b = \nval c = {x \n}\n\n"}},{"id":"scala-keyword-and-number-strings","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"def f = return\"ok ${1 // not}\"\nval g = 1\"x // not\"\n// remove\n","expect":{"valid":true,"comments":[{"start":51,"end":60,"kind":"line","action":"remove"}],"output_utf8":"def f = return\"ok ${1 // not}\"\nval g = 1\"x // not\"\n\n"}},{"id":"scala-dollar-escape-in-interpolated-string","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val a = s\"x$\"y\"\nval b = s\"$$lit\"\n// remove\n","expect":{"valid":true,"comments":[{"start":33,"end":42,"kind":"line","action":"remove"}],"output_utf8":"val a = s\"x$\"y\"\nval b = s\"$$lit\"\n\n"}},{"id":"scss-protocol-relative-url","language":"css","dialect":"scss","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":".b { background: url(//cdn/x.png) no-repeat }\n// yes\n","expect":{"valid":true,"comments":[{"start":46,"end":52,"kind":"line","action":"remove"}],"output_utf8":".b { background: url(//cdn/x.png) no-repeat }\n\n"}},{"id":"vue-v-pre-raw-text","language":"vue","operation":"scan","options":{"policy":"standard","layout":"lines"},"source_utf8":"
\n"}},{"id":"compact-javascript-line-separator","language":"javascript","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_base64":"bGV0IGEgPSAxO+KAqC8vIG5vdGXigKhsZXQgYiA9IDI7Cg==","expect":{"valid":true,"comments":[{"start":13,"end":20,"kind":"line","action":"remove"}],"output_base64":"bGV0IGEgPSAxO+KAqGxldCBiID0gMjsK"}},{"id":"compact-kept-comment-holds-its-line","language":"rust","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"// rustfmt::skip\n// note\nfn main() {}\n","expect":{"valid":true,"comments":[{"start":0,"end":16,"kind":"directive","action":"keep"},{"start":17,"end":24,"kind":"line","action":"remove"}],"output_utf8":"// rustfmt::skip\nfn main() {}\n"}},{"id":"invalid-cpp-raw","language":"cpp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"R\"tag(unterminated /* opaque */","expect":{"valid":false,"comments":[],"output_utf8":"R\"tag(unterminated /* opaque */"}},{"id":"invalid-shell-quote","language":"shell","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"echo 'unterminated","expect":{"valid":false,"comments":[],"output_utf8":"echo 'unterminated"}},{"id":"invalid-shell-heredoc","language":"shell","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"cat <out\ndata\nEOF\n# remove\n","expect":{"valid":true,"comments":[{"start":23,"end":31,"kind":"line","action":"remove"}],"output_utf8":"cat <out\ndata\nEOF\n\n"}},{"id":"parity-html-tag-name-ends-at-ascii-whitespace","language":"html","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"PHNjcmlwdAs+eC8veTwvc2NyaXB0Pgo=","expect":{"valid":true,"comments":[],"output_base64":"PHNjcmlwdAs+eC8veTwvc2NyaXB0Pgo="}},{"id":"parity-profile-boundary-is-ascii-whitespace","language":"c","operation":"transform-profile","options":{"policy":"standard","layout":"lines"},"profile":{"name":"boundary","extensions":["boundary"],"line_comments":[{"start":"REM","kind":"line","requires_boundary":true}],"block_comments":[],"strings":[]},"source_base64":"eAtSRU0gbm90IGEgY29tbWVudApSRU0gcmVtb3ZlCg==","expect":{"valid":true,"comments":[{"start":20,"end":30,"kind":"line","action":"remove"}],"output_base64":"eAtSRU0gbm90IGEgY29tbWVudAoK"}},{"id":"parity-html-script-hashbang-is-not-a-preamble","language":"html","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"\n\n","expect":{"valid":true,"comments":[{"start":21,"end":36,"kind":"html-comment","action":"keep"}],"output_utf8":"\n\n"}},{"id":"yaml-hash-in-plain-scalar","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"url: http://example.test/page#fragment\nname: a#b\ndone: 1 # remove\n","expect":{"valid":true,"comments":[{"start":57,"end":65,"kind":"line","action":"remove"}],"output_utf8":"url: http://example.test/page#fragment\nname: a#b\ndone: 1 \n"}},{"id":"yaml-hash-after-space","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: value # remove\nother: 2\t# remove too\n# a whole line\n","expect":{"valid":true,"comments":[{"start":11,"end":19,"kind":"line","action":"remove"},{"start":29,"end":41,"kind":"line","action":"remove"},{"start":42,"end":56,"kind":"line","action":"remove"}],"output_utf8":"key: value \nother: 2\t\n\n"}},{"id":"yaml-double-quoted-multiline-hash","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: \"first # not a comment\n second # still not\"\ndone: 1 # remove\n","expect":{"valid":true,"comments":[{"start":58,"end":66,"kind":"line","action":"remove"}],"output_utf8":"key: \"first # not a comment\n second # still not\"\ndone: 1 \n"}},{"id":"yaml-single-quoted-escape","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: 'it''s # not a comment'\nplain: it's fine # remove\n","expect":{"valid":true,"comments":[{"start":46,"end":54,"kind":"line","action":"remove"}],"output_utf8":"key: 'it''s # not a comment'\nplain: it's fine \n"}},{"id":"yaml-block-literal-body-hash","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"script: |\n # not a comment\n echo hi\ndone: 1 # remove\n","expect":{"valid":true,"comments":[{"start":46,"end":54,"kind":"line","action":"remove"}],"output_utf8":"script: |\n # not a comment\n echo hi\ndone: 1 \n"}},{"id":"yaml-block-folded-indent-indicator","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"text: >2\n # not a comment\n still folded\ndone: 1 # remove\n","expect":{"valid":true,"comments":[{"start":51,"end":59,"kind":"line","action":"remove"}],"output_utf8":"text: >2\n # not a comment\n still folded\ndone: 1 \n"}},{"id":"yaml-block-header-comment","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"script: |- # remove\n # not a comment\ndone: 1\n","expect":{"valid":true,"comments":[{"start":11,"end":19,"kind":"line","action":"remove"}],"output_utf8":"script: |- \n # not a comment\ndone: 1\n"}},{"id":"yaml-sequence-item-block-scalar","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"steps:\n - run: |\n echo hi # not a comment\n - run: echo bye # remove\n","expect":{"valid":true,"comments":[{"start":66,"end":74,"kind":"line","action":"remove"}],"output_utf8":"steps:\n - run: |\n echo hi # not a comment\n - run: echo bye \n"}},{"id":"yaml-block-ends-at-document-marker","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"|\n a # not a comment\n---\n# remove\n","expect":{"valid":true,"comments":[{"start":26,"end":34,"kind":"line","action":"remove"}],"output_utf8":"|\n a # not a comment\n---\n\n"}},{"id":"yaml-empty-lines-in-body","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"script: |\n first\n\n # not a comment\n\ndone: 1 # remove\n","expect":{"valid":true,"comments":[{"start":46,"end":54,"kind":"line","action":"remove"}],"output_utf8":"script: |\n first\n\n # not a comment\n\ndone: 1 \n"}},{"id":"yaml-flow-collection-comment","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"flow: [a,\"b # no\", 'c # no'] # remove\nmap: {x: 1} # remove too\n","expect":{"valid":true,"comments":[{"start":29,"end":37,"kind":"line","action":"remove"},{"start":50,"end":62,"kind":"line","action":"remove"}],"output_utf8":"flow: [a,\"b # no\", 'c # no'] \nmap: {x: 1} \n"}},{"id":"yaml-directive-lines","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"%YAML 1.2\n%TAG !e! tag:example.test,2000:app/\n---\nkey: 1 # remove\n","expect":{"valid":true,"comments":[{"start":57,"end":65,"kind":"line","action":"remove"}],"output_utf8":"%YAML 1.2\n%TAG !e! tag:example.test,2000:app/\n---\nkey: 1 \n"}},{"id":"yaml-language-server-directive","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"# yaml-language-server: $schema=https://example.test/schema.json\n# renovate: datasource=docker depName=alpine\nkey: 1 # remove\n","expect":{"valid":true,"comments":[{"start":0,"end":64,"kind":"directive","action":"keep"},{"start":65,"end":109,"kind":"directive","action":"keep"},{"start":117,"end":125,"kind":"line","action":"remove"}],"output_utf8":"# yaml-language-server: $schema=https://example.test/schema.json\n# renovate: datasource=docker depName=alpine\nkey: 1 \n"}},{"id":"yaml-yamllint-directive","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"# yamllint disable-line rule:line-length\n# checkov:skip=CKV_AWS_20:public by design\n# @schema type: string\nkey: 1 # remove\n","expect":{"valid":true,"comments":[{"start":0,"end":40,"kind":"directive","action":"keep"},{"start":41,"end":83,"kind":"directive","action":"keep"},{"start":84,"end":106,"kind":"directive","action":"keep"},{"start":114,"end":122,"kind":"line","action":"remove"}],"output_utf8":"# yamllint disable-line rule:line-length\n# checkov:skip=CKV_AWS_20:public by design\n# @schema type: string\nkey: 1 \n"}},{"id":"yaml-crlf","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: \"first # no\r\n second\"\r\nscript: |\r\n # no\r\ndone: 1 # remove\r\n","expect":{"valid":true,"comments":[{"start":56,"end":64,"kind":"line","action":"remove"}],"output_utf8":"key: \"first # no\r\n second\"\r\nscript: |\r\n # no\r\ndone: 1 \r\n"}},{"id":"yaml-tabs","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"script: |\n \t# not a comment\n text\ndone: 1\t# remove\n","expect":{"valid":true,"comments":[{"start":44,"end":52,"kind":"line","action":"remove"}],"output_utf8":"script: |\n \t# not a comment\n text\ndone: 1\t\n"}},{"id":"yaml-unterminated-double-quote","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: \"unclosed # not a comment\nother: 1 # not one either\n","expect":{"valid":false,"comments":[],"output_utf8":"key: \"unclosed # not a comment\nother: 1 # not one either\n"}},{"id":"yaml-columns-layout","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"key: 1 # remove\nnext: 2\n","expect":{"valid":true,"comments":[{"start":7,"end":15,"kind":"line","action":"remove"}],"output_utf8":"key: 1 \nnext: 2\n"}},{"id":"yaml-compact-layout","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"# alone\nkey: 1 # trailing\nnext: 2\n","expect":{"valid":true,"comments":[{"start":0,"end":7,"kind":"line","action":"remove"},{"start":15,"end":25,"kind":"line","action":"remove"}],"output_utf8":"key: 1\nnext: 2\n"}},{"id":"yaml-block-scalar-sequence-entry","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"- |\n # a\n b\n","expect":{"valid":true,"comments":[],"output_utf8":"- |\n # a\n b\n"}},{"id":"yaml-block-scalar-tag","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: !!str |\n # a\n","expect":{"valid":true,"comments":[],"output_utf8":"key: !!str |\n # a\n"}},{"id":"yaml-block-scalar-anchor","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: &x |\n # a\n","expect":{"valid":true,"comments":[],"output_utf8":"key: &x |\n # a\n"}},{"id":"yaml-block-scalar-explicit-key","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"? |\n # a\n: v\n","expect":{"valid":true,"comments":[],"output_utf8":"? |\n # a\n: v\n"}},{"id":"yaml-block-scalar-nested-sequence","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"- - |\n # a\n","expect":{"valid":true,"comments":[],"output_utf8":"- - |\n # a\n"}},{"id":"yaml-block-scalar-owner-depth","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"k:\n - |\n # a\n # still body\n # end\n","expect":{"valid":true,"comments":[{"start":35,"end":40,"kind":"line","action":"remove"}],"output_utf8":"k:\n - |\n # a\n # still body\n"}},{"id":"yaml-block-scalar-indentation-indicator","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"k: |2\n # body\n","expect":{"valid":true,"comments":[],"output_utf8":"k: |2\n # body\n"}},{"id":"yaml-block-scalar-document-root","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"|\n # body\n","expect":{"valid":true,"comments":[],"output_utf8":"|\n # body\n"}},{"id":"yaml-block-scalar-header-own-line","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key:\n |\n # a\n","expect":{"valid":true,"comments":[],"output_utf8":"key:\n |\n # a\n"}},{"id":"yaml-block-scalar-properties-previous-line","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"key: !!str\n |\n # a\n","expect":{"valid":true,"comments":[],"output_utf8":"key: !!str\n |\n # a\n"}},{"id":"yaml-block-scalar-root-properties","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"!!str |\n # a\n","expect":{"valid":true,"comments":[],"output_utf8":"!!str |\n # a\n"}},{"id":"yaml-keep-chomp-comment-after-body-lines","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"k: |+\n body\n\n# after\nnext: 1 # yes\n","expect":{"valid":true,"comments":[{"start":14,"end":21,"kind":"line","action":"remove"},{"start":30,"end":35,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n body\n\nnext: 1 \n"}},{"id":"yaml-keep-chomp-comment-after-body-columns","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"k: |+\n body\n\n# after\nnext: 1 # yes\n","expect":{"valid":true,"comments":[{"start":14,"end":21,"kind":"line","action":"remove"},{"start":30,"end":35,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n body\n\nnext: 1 \n"}},{"id":"yaml-keep-chomp-comment-after-body-compact","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"k: |+\n body\n\n# after\nnext: 1 # yes\n","expect":{"valid":true,"comments":[{"start":14,"end":21,"kind":"line","action":"remove"},{"start":30,"end":35,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n body\n\nnext: 1\n"}},{"id":"yaml-keep-chomp-trail-swallows-sheltered-blanks-lines","language":"yaml","operation":"transform","options":{"policy":"all","layout":"lines"},"source_utf8":"k: |+\n a\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":10,"end":13,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n a\nz: 1\n"}},{"id":"yaml-keep-chomp-trail-swallows-sheltered-blanks-columns","language":"yaml","operation":"transform","options":{"policy":"all","layout":"columns"},"source_utf8":"k: |+\n a\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":10,"end":13,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n a\nz: 1\n"}},{"id":"yaml-keep-chomp-trail-swallows-sheltered-blanks-compact","language":"yaml","operation":"transform","options":{"policy":"all","layout":"compact"},"source_utf8":"k: |+\n a\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":10,"end":13,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n a\nz: 1\n"}},{"id":"yaml-keep-chomp-blank-above-the-trail-stays-lines","language":"yaml","operation":"transform","options":{"policy":"all","layout":"lines"},"source_utf8":"k: |+\n a\n\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":11,"end":14,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n a\n\nz: 1\n"}},{"id":"yaml-keep-chomp-blank-above-the-trail-stays-columns","language":"yaml","operation":"transform","options":{"policy":"all","layout":"columns"},"source_utf8":"k: |+\n a\n\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":11,"end":14,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n a\n\nz: 1\n"}},{"id":"yaml-keep-chomp-blank-above-the-trail-stays-compact","language":"yaml","operation":"transform","options":{"policy":"all","layout":"compact"},"source_utf8":"k: |+\n a\n\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":11,"end":14,"kind":"line","action":"remove"}],"output_utf8":"k: |+\n a\n\nz: 1\n"}},{"id":"yaml-clip-chomp-trail-comment-takes-its-line-lines","language":"yaml","operation":"transform","options":{"policy":"all","layout":"lines"},"source_utf8":"k: |\n a\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":9,"end":12,"kind":"line","action":"remove"}],"output_utf8":"k: |\n a\n\nz: 1\n"}},{"id":"yaml-clip-chomp-trail-comment-takes-its-line-columns","language":"yaml","operation":"transform","options":{"policy":"all","layout":"columns"},"source_utf8":"k: |\n a\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":9,"end":12,"kind":"line","action":"remove"}],"output_utf8":"k: |\n a\n\nz: 1\n"}},{"id":"yaml-clip-chomp-trail-comment-takes-its-line-compact","language":"yaml","operation":"transform","options":{"policy":"all","layout":"compact"},"source_utf8":"k: |\n a\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":9,"end":12,"kind":"line","action":"remove"}],"output_utf8":"k: |\n a\n\nz: 1\n"}},{"id":"yaml-plain-scalar-pipe-opens-no-trail-lines","language":"yaml","operation":"transform","options":{"policy":"all","layout":"lines"},"source_utf8":"k: a |+\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":8,"end":11,"kind":"line","action":"remove"}],"output_utf8":"k: a |+\n\n\nz: 1\n"}},{"id":"yaml-plain-scalar-pipe-opens-no-trail-columns","language":"yaml","operation":"transform","options":{"policy":"all","layout":"columns"},"source_utf8":"k: a |+\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":8,"end":11,"kind":"line","action":"remove"}],"output_utf8":"k: a |+\n \n\nz: 1\n"}},{"id":"yaml-plain-scalar-pipe-opens-no-trail-compact","language":"yaml","operation":"transform","options":{"policy":"all","layout":"compact"},"source_utf8":"k: a |+\n# c\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":8,"end":11,"kind":"line","action":"remove"}],"output_utf8":"k: a |+\n\nz: 1\n"}},{"id":"yaml-keep-chomp-surviving-comment-shelters-the-rest-lines","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"k: |+\n a\n# c\n\n# yamllint disable\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":10,"end":13,"kind":"line","action":"remove"},{"start":15,"end":33,"kind":"directive","action":"keep"}],"output_utf8":"k: |+\n a\n# yamllint disable\n\nz: 1\n"}},{"id":"yaml-keep-chomp-surviving-comment-shelters-the-rest-columns","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"k: |+\n a\n# c\n\n# yamllint disable\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":10,"end":13,"kind":"line","action":"remove"},{"start":15,"end":33,"kind":"directive","action":"keep"}],"output_utf8":"k: |+\n a\n# yamllint disable\n\nz: 1\n"}},{"id":"yaml-keep-chomp-surviving-comment-shelters-the-rest-compact","language":"yaml","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"k: |+\n a\n# c\n\n# yamllint disable\n\nz: 1\n","expect":{"valid":true,"comments":[{"start":10,"end":13,"kind":"line","action":"remove"},{"start":15,"end":33,"kind":"directive","action":"keep"}],"output_utf8":"k: |+\n a\n# yamllint disable\n\nz: 1\n"}},{"id":"parity-js-html-close-behind-a-byte-order-mark","language":"javascript","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"Cu+7vy0tPiBjb21tZW50CnggLS0+IG5vdCBvbmUK","expect":{"valid":true,"comments":[{"start":4,"end":15,"kind":"line","action":"remove"}],"output_base64":"Cu+7vwp4IC0tPiBub3Qgb25lCg=="}},{"id":"parity-js-html-close-behind-a-mark-that-is-not-the-first-byte","language":"javascript","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"CiDvu78tLT4gY29tbWVudAo=","expect":{"valid":true,"comments":[{"start":5,"end":16,"kind":"line","action":"remove"}],"output_base64":"CiDvu78K"}},{"id":"parity-ocaml-comment-character-literal-shape","language":"ocaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"(*'\\cr#\"]'*)\n","expect":{"valid":false,"comments":[{"start":0,"end":19,"kind":"block","action":"remove"}],"output_utf8":"(*'\\cr#\"]'*)\n"}},{"id":"php-html-then-php","language":"php","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"
#not a comment
\n#not a comment\n\n","expect":{"valid":true,"comments":[{"start":10,"end":19,"kind":"line","action":"remove"}],"output_utf8":"= $name \n?>\n"}},{"id":"php-xml-decl-not-open-tag","language":"php","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"\n\n
\r\n"}},{"id":"php-unterminated-heredoc","language":"php","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"() {} // remove\n","expect":{"valid":true,"comments":[{"start":15,"end":24,"kind":"line","action":"remove"}]}},{"id":"rust-unicode-loop-label","language":"rust","operation":"scan","options":{"policy":"standard","layout":"lines"},"source_utf8":"'ä: loop { break 'ä } // remove\n","expect":{"valid":true,"comments":[{"start":24,"end":33,"kind":"line","action":"remove"}]}},{"id":"ocaml-char-literal-across-newline","language":"ocaml","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = '\n' (* remove *)\nlet b = '\\\n' (* remove *)\n","expect":{"valid":true,"comments":[{"start":12,"end":24,"kind":"block","action":"remove"},{"start":38,"end":50,"kind":"block","action":"remove"}],"output_utf8":"let a = '\n' \nlet b = '\\\n' \n"}},{"id":"ruby-alias-percent-s","language":"ruby","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"alias%s(baz # x) %s(bar)\nputs 1 # remove\n","expect":{"valid":true,"comments":[{"start":32,"end":40,"kind":"line","action":"remove"}],"output_utf8":"alias%s(baz # x) %s(bar)\nputs 1 \n"}},{"id":"bom-shebang-dart","language":"dart","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"77u/IyEvdXNyL2Jpbi9lbnYgZGFydAp2b2lkIG1haW4oKSB7fSAvLyByZW1vdmUK","expect":{"valid":true,"comments":[{"start":38,"end":47,"kind":"line","action":"remove"}],"output_base64":"77u/IyEvdXNyL2Jpbi9lbnYgZGFydAp2b2lkIG1haW4oKSB7fSAK"}},{"id":"swift-nested-block-comment","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* outer /* inner */ still outer */\nlet a = 1 // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":35,"kind":"block","action":"remove"},{"start":46,"end":55,"kind":"line","action":"remove"}],"output_utf8":"\nlet a = 1 \n"}},{"id":"swift-doc-forms","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/// doc\n//// four\n//! not swift\n/** doc */\n/*! bang */\n/**/\n/***/\n// line\nlet a = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":7,"kind":"doc-line","action":"remove"},{"start":8,"end":17,"kind":"doc-line","action":"remove"},{"start":18,"end":31,"kind":"line","action":"remove"},{"start":32,"end":42,"kind":"doc-block","action":"remove"},{"start":43,"end":54,"kind":"block","action":"remove"},{"start":55,"end":59,"kind":"block","action":"remove"},{"start":60,"end":65,"kind":"doc-block","action":"remove"},{"start":66,"end":73,"kind":"line","action":"remove"}],"output_utf8":"\n\n\n\n\n\n\n\nlet a = 1\n"}},{"id":"swift-interpolation-comment","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"v: \\( 1 /* c */ + 2 )\" // remove\n","expect":{"valid":true,"comments":[{"start":17,"end":24,"kind":"block","action":"remove"},{"start":33,"end":42,"kind":"line","action":"remove"}],"output_utf8":"let a = \"v: \\( 1 + 2 )\" \n"}},{"id":"swift-multiline-string","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"\"\"\n// not\n\"\"\"\n// remove\n","expect":{"valid":true,"comments":[{"start":23,"end":32,"kind":"line","action":"remove"}],"output_utf8":"let a = \"\"\"\n// not\n\"\"\"\n\n"}},{"id":"swift-raw-string-hashes","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = ##\"a \"# // not\"##\n// remove\n","expect":{"valid":true,"comments":[{"start":26,"end":35,"kind":"line","action":"remove"}],"output_utf8":"let a = ##\"a \"# // not\"##\n\n"}},{"id":"swift-raw-multiline","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #\"\"\"\n// not \\(1)\n\"\"\"#\n// remove\n","expect":{"valid":true,"comments":[{"start":30,"end":39,"kind":"line","action":"remove"}],"output_utf8":"let a = #\"\"\"\n// not \\(1)\n\"\"\"#\n\n"}},{"id":"swift-raw-interpolation","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #\"v: \\#( 1 /* c */ ) and \\(1)\"# // remove\n","expect":{"valid":true,"comments":[{"start":19,"end":26,"kind":"block","action":"remove"},{"start":41,"end":50,"kind":"line","action":"remove"}],"output_utf8":"let a = #\"v: \\#( 1 ) and \\(1)\"# \n"}},{"id":"swift-raw-quote-only","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #\"\"\"#\n// remove\n","expect":{"valid":true,"comments":[{"start":14,"end":23,"kind":"line","action":"remove"}],"output_utf8":"let a = #\"\"\"#\n\n"}},{"id":"swift-string-pound-boundary","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"x\"#/y // z/#\nlet b = 1 // remove\n","expect":{"valid":true,"comments":[{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"let a = \"x\"#/y // z/#\nlet b = 1 \n"}},{"id":"swift-extended-regex-literal","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #/https://x/# // remove\n","expect":{"valid":true,"comments":[{"start":23,"end":32,"kind":"line","action":"remove"}],"output_utf8":"let a = #/https://x/# \n"}},{"id":"swift-extended-regex-multiline","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #/\n x y\n/#\n// remove\n","expect":{"valid":true,"comments":[{"start":20,"end":29,"kind":"line","action":"remove"}],"output_utf8":"let a = #/\n x y\n/#\n\n"}},{"id":"swift-bare-regex-literal","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = /a\\//;print(1) // remove\n","expect":{"valid":true,"comments":[{"start":23,"end":32,"kind":"line","action":"remove"}],"output_utf8":"let a = /a\\//;print(1) \n"}},{"id":"swift-bare-regex-limitation","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = / b\\//\nlet c = 1\n","expect":{"valid":true,"comments":[{"start":12,"end":14,"kind":"line","action":"remove"}],"output_utf8":"let a = / b\\\nlet c = 1\n"}},{"id":"swift-division-not-regex","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = 1 / 2 // remove\nlet b = a/a/a // remove\n","expect":{"valid":true,"comments":[{"start":14,"end":23,"kind":"line","action":"remove"},{"start":38,"end":47,"kind":"line","action":"remove"}],"output_utf8":"let a = 1 / 2 \nlet b = a/a/a \n"}},{"id":"swift-regex-comment-wins","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = /x//y/\nlet b = 1\n","expect":{"valid":true,"comments":[{"start":10,"end":14,"kind":"line","action":"remove"}],"output_utf8":"let a = /x\nlet b = 1\n"}},{"id":"swift-compiler-directive-not-comment","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if DEBUG\nlet a = 1 // remove\n#endif\n#warning(\"x // y\")\n","expect":{"valid":true,"comments":[{"start":20,"end":29,"kind":"line","action":"remove"}],"output_utf8":"#if DEBUG\nlet a = 1 \n#endif\n#warning(\"x // y\")\n"}},{"id":"swift-tools-version-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// swift-tools-version:5.9\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":26,"kind":"load-bearing","action":"keep"},{"start":27,"end":37,"kind":"line","action":"remove"}],"output_utf8":"// swift-tools-version:5.9\n\n"}},{"id":"swift-swiftlint-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// swiftlint:disable force_cast\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":31,"kind":"directive","action":"keep"},{"start":32,"end":42,"kind":"line","action":"remove"}],"output_utf8":"// swiftlint:disable force_cast\n\n"}},{"id":"swift-format-ignore-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// swift-format-ignore-file\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":27,"kind":"directive","action":"keep"},{"start":28,"end":38,"kind":"line","action":"remove"}],"output_utf8":"// swift-format-ignore-file\n\n"}},{"id":"swift-mark-is-not-a-directive","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// MARK: - Section\n// control\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"line","action":"remove"},{"start":19,"end":29,"kind":"line","action":"remove"}],"output_utf8":"\n\n"}},{"id":"swift-unterminated-nested","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* open /* inner */\nlet a = 1\n","expect":{"valid":false,"comments":[{"start":0,"end":30,"kind":"block","action":"remove"}],"output_utf8":"/* open /* inner */\nlet a = 1\n"}},{"id":"swift-unterminated-multiline","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = \"\"\"\nopen\nlet b = 2\n","expect":{"valid":false,"comments":[],"output_utf8":"let a = \"\"\"\nopen\nlet b = 2\n"}},{"id":"swift-unterminated-extended-regex","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = #/\nopen\nlet b = 2 // remove\n","expect":{"valid":false,"comments":[{"start":26,"end":35,"kind":"line","action":"remove"}],"output_utf8":"let a = #/\nopen\nlet b = 2 // remove\n"}},{"id":"swift-single-quoted-recovery","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"let a = 'x // not'\n// remove\n","expect":{"valid":true,"comments":[{"start":19,"end":28,"kind":"line","action":"remove"}],"output_utf8":"let a = 'x // not'\n\n"}},{"id":"swift-shebang","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#!/usr/bin/env swift\n// remove\nlet a = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":20,"kind":"shebang","action":"keep"},{"start":21,"end":30,"kind":"line","action":"remove"}],"output_utf8":"#!/usr/bin/env swift\n\nlet a = 1\n"}},{"id":"swift-crlf","language":"swift","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* block\r\nstill */\r\nlet a = \"\"\"\r\nx\r\n\"\"\"\r\nlet b = #/\r\n x\r\n/#\r\n// remove\r\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"block","action":"remove"},{"start":62,"end":71,"kind":"line","action":"remove"}],"output_utf8":"\r\n\r\nlet a = \"\"\"\r\nx\r\n\"\"\"\r\nlet b = #/\r\n x\r\n/#\r\n\r\n"}},{"id":"swift-columns","language":"swift","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"// alone\nlet x = 1 // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":19,"end":30,"kind":"line","action":"remove"}],"output_utf8":" \nlet x = 1 \n"}},{"id":"swift-compact","language":"swift","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"// alone\nlet x = 1 // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":19,"end":30,"kind":"line","action":"remove"}],"output_utf8":"let x = 1\n"}},{"id":"bom-shebang-javascript","language":"javascript","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"77u/IyEvdXNyL2Jpbi9lbnYgbm9kZQpsZXQgeCA9IDE7IC8vIHJlbW92ZQo=","expect":{"valid":true,"comments":[{"start":34,"end":43,"kind":"line","action":"remove"}],"output_base64":"77u/IyEvdXNyL2Jpbi9lbnYgbm9kZQpsZXQgeCA9IDE7IAo="}},{"id":"csharp-doc-forms","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/// doc\n//// four\n//! not csharp\n/** doc */\n/*! bang */\n/**/\n/***/\n/*** three */\n// line\nclass C { }\n","expect":{"valid":true,"comments":[{"start":0,"end":7,"kind":"doc-line","action":"remove"},{"start":8,"end":17,"kind":"line","action":"remove"},{"start":18,"end":32,"kind":"line","action":"remove"},{"start":33,"end":43,"kind":"doc-block","action":"remove"},{"start":44,"end":55,"kind":"block","action":"remove"},{"start":56,"end":60,"kind":"block","action":"remove"},{"start":61,"end":66,"kind":"block","action":"remove"},{"start":67,"end":80,"kind":"block","action":"remove"},{"start":81,"end":88,"kind":"line","action":"remove"}],"output_utf8":"\n\n\n\n\n\n\n\n\nclass C { }\n"}},{"id":"csharp-non-nested-block","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* outer /* inner */ still outer */\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":20,"kind":"block","action":"remove"},{"start":47,"end":56,"kind":"line","action":"remove"}],"output_utf8":" still outer */\nvar a = 1; \n"}},{"id":"csharp-verbatim-string-quotes","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = @\"quote \"\" inside // no\"; // remove\n","expect":{"valid":true,"comments":[{"start":34,"end":43,"kind":"line","action":"remove"}],"output_utf8":"var s = @\"quote \"\" inside // no\"; \n"}},{"id":"csharp-verbatim-multiline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = @\"first // no\nsecond */ no\"; // remove\n","expect":{"valid":true,"comments":[{"start":37,"end":46,"kind":"line","action":"remove"}],"output_utf8":"var s = @\"first // no\nsecond */ no\"; \n"}},{"id":"csharp-verbatim-identifier","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var @class = 1; // remove\n","expect":{"valid":true,"comments":[{"start":16,"end":25,"kind":"line","action":"remove"}],"output_utf8":"var @class = 1; \n"}},{"id":"csharp-interpolated-braces-escape","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"{{literal}} // no {x} tail\"; // remove\n","expect":{"valid":true,"comments":[{"start":39,"end":48,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"{{literal}} // no {x} tail\"; \n"}},{"id":"csharp-interpolated-hole-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"v={x /* hole */} // no\"; // remove\n","expect":{"valid":true,"comments":[{"start":15,"end":25,"kind":"block","action":"remove"},{"start":35,"end":44,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"v={x } // no\"; \n"}},{"id":"csharp-interpolated-hole-newline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"v={x // hole\n}\"; // remove\n","expect":{"valid":true,"comments":[{"start":15,"end":22,"kind":"line","action":"remove"},{"start":27,"end":36,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"v={x \n}\"; \n"}},{"id":"csharp-interpolated-format-clause","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $\"{x:D4 // no}\"; // remove\n","expect":{"valid":true,"comments":[{"start":25,"end":34,"kind":"line","action":"remove"}],"output_utf8":"var s = $\"{x:D4 // no}\"; \n"}},{"id":"csharp-verbatim-interpolated","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $@\"a {x} // no\nb\"; var t = @$\"c\"; // remove\n","expect":{"valid":true,"comments":[{"start":42,"end":51,"kind":"line","action":"remove"}],"output_utf8":"var s = $@\"a {x} // no\nb\"; var t = @$\"c\"; \n"}},{"id":"csharp-raw-string-quotes","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"\"\"\"three \"\"\" inside // no\"\"\"\"; // remove\n","expect":{"valid":true,"comments":[{"start":40,"end":49,"kind":"line","action":"remove"}],"output_utf8":"var s = \"\"\"\"three \"\"\" inside // no\"\"\"\"; \n"}},{"id":"csharp-raw-multiline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"\"\"\n body // no\n \"\"\"; // remove\n","expect":{"valid":true,"comments":[{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"var s = \"\"\"\n body // no\n \"\"\"; \n"}},{"id":"csharp-raw-interpolated-dollar","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = $$\"\"\"{not a hole} {{x /* hole */}} // no\"\"\"; // remove\n","expect":{"valid":true,"comments":[{"start":30,"end":40,"kind":"block","action":"remove"},{"start":53,"end":62,"kind":"line","action":"remove"}],"output_utf8":"var s = $$\"\"\"{not a hole} {{x }} // no\"\"\"; \n"}},{"id":"csharp-utf8-literal","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"bytes // no\"u8; // remove\n","expect":{"valid":true,"comments":[{"start":25,"end":34,"kind":"line","action":"remove"}],"output_utf8":"var s = \"bytes // no\"u8; \n"}},{"id":"csharp-string-escape-carries-a-newline","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"a\\\nb // no\"; // remove\n","expect":{"valid":true,"comments":[{"start":22,"end":31,"kind":"line","action":"remove"}],"output_utf8":"var s = \"a\\\nb // no\"; \n"}},{"id":"csharp-character-literals","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"char a = '/'; char b = '\\''; char c = '\"'; // remove\n","expect":{"valid":true,"comments":[{"start":43,"end":52,"kind":"line","action":"remove"}],"output_utf8":"char a = '/'; char b = '\\''; char c = '\"'; \n"}},{"id":"csharp-preprocessor-if-with-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if DEBUG // kept\nvar a = 1; // remove\n#endif // tail\n","expect":{"valid":true,"comments":[{"start":10,"end":17,"kind":"line","action":"remove"},{"start":29,"end":38,"kind":"line","action":"remove"},{"start":46,"end":53,"kind":"line","action":"remove"}],"output_utf8":"#if DEBUG \nvar a = 1; \n#endif \n"}},{"id":"csharp-region-text-not-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#region Name // not a comment\n#endregion // a comment\n","expect":{"valid":true,"comments":[{"start":41,"end":53,"kind":"line","action":"remove"}],"output_utf8":"#region Name // not a comment\n#endregion \n"}},{"id":"csharp-pragma-text","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#pragma warning disable 1591 // a comment\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":29,"end":41,"kind":"line","action":"remove"},{"start":53,"end":62,"kind":"line","action":"remove"}],"output_utf8":"#pragma warning disable 1591 \nvar a = 1; \n"}},{"id":"csharp-line-directive-string","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#line 1 \"a//b.cs\" // tail\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":18,"end":25,"kind":"line","action":"remove"},{"start":37,"end":46,"kind":"line","action":"remove"}],"output_utf8":"#line 1 \"a//b.cs\" \nvar a = 1; \n"}},{"id":"csharp-error-message-not-comment","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#error boom // no\n","expect":{"valid":true,"comments":[],"output_utf8":"#error boom // no\n"}},{"id":"csharp-directive-block-comment-is-not-one","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if A /* no */ && B\n#endif\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":38,"end":47,"kind":"line","action":"remove"}],"output_utf8":"#if A /* no */ && B\n#endif\nvar a = 1; \n"}},{"id":"csharp-hash-after-code-is-not-a-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var a = 1; #if X // no\n#endif\n","expect":{"valid":true,"comments":[],"output_utf8":"var a = 1; #if X // no\n#endif\n"}},{"id":"csharp-unicode-line-terminator","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"dmFyIGEgPSAxOyAvLyBj4oCodmFyIGIgPSAyOyAvLyByZW1vdmUK","expect":{"valid":true,"comments":[{"start":11,"end":15,"kind":"line","action":"remove"},{"start":29,"end":38,"kind":"line","action":"remove"}],"output_base64":"dmFyIGEgPSAxOyDigKh2YXIgYiA9IDI7IAo="}},{"id":"csharp-auto-generated-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// \nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":20,"kind":"directive","action":"keep"},{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"// \nvar a = 1; \n"}},{"id":"csharp-resharper-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// ReSharper disable once UnusedMember.Local\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":44,"kind":"directive","action":"keep"},{"start":56,"end":65,"kind":"line","action":"remove"}],"output_utf8":"// ReSharper disable once UnusedMember.Local\nvar a = 1; \n"}},{"id":"csharp-csharpier-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"// csharpier-ignore\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":19,"kind":"directive","action":"keep"},{"start":34,"end":43,"kind":"line","action":"remove"}],"output_utf8":"// csharpier-ignore\nvar a = 1; \n"}},{"id":"csharp-csx-shebang","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#!/usr/bin/env dotnet-script\nvar a = 1; // remove\n","expect":{"valid":true,"comments":[{"start":0,"end":28,"kind":"shebang","action":"keep"},{"start":40,"end":49,"kind":"line","action":"remove"}],"output_utf8":"#!/usr/bin/env dotnet-script\nvar a = 1; \n"}},{"id":"csharp-unterminated-verbatim","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = @\"open\nvar b = 2;\n","expect":{"valid":false,"comments":[],"output_utf8":"var s = @\"open\nvar b = 2;\n"}},{"id":"csharp-unterminated-raw","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"var s = \"\"\"\nopen\nvar b = 2;\n","expect":{"valid":false,"comments":[],"output_utf8":"var s = \"\"\"\nopen\nvar b = 2;\n"}},{"id":"csharp-unterminated-block","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* open\nvar a = 1;\n","expect":{"valid":false,"comments":[{"start":0,"end":19,"kind":"block","action":"remove"}],"output_utf8":"/* open\nvar a = 1;\n"}},{"id":"csharp-crlf","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"/* block\r\nstill */\r\nvar a = @\"x\r\ny\";\r\nvar b = \"\"\"\r\nz\r\n\"\"\";\r\n#if A // kept\r\n#endif\r\n// remove\r\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"block","action":"remove"},{"start":66,"end":73,"kind":"line","action":"remove"},{"start":83,"end":92,"kind":"line","action":"remove"}],"output_utf8":"\r\n\r\nvar a = @\"x\r\ny\";\r\nvar b = \"\"\"\r\nz\r\n\"\"\";\r\n#if A \r\n#endif\r\n\r\n"}},{"id":"csharp-columns","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"columns"},"source_utf8":"// alone\nvar x = 1; // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":20,"end":31,"kind":"line","action":"remove"}],"output_utf8":" \nvar x = 1; \n"}},{"id":"csharp-compact","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"compact"},"source_utf8":"// alone\nvar x = 1; // trailing\n","expect":{"valid":true,"comments":[{"start":0,"end":8,"kind":"line","action":"remove"},{"start":20,"end":31,"kind":"line","action":"remove"}],"output_utf8":"var x = 1;\n"}},{"id":"csharp-byte-order-mark-directive","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_base64":"77u/I3ByYWdtYSB3YXJuaW5nIGRpc2FibGUgMTU5MSAvLyBhIGNvbW1lbnQKdmFyIGEgPSAxOyAvLyByZW1vdmUK","expect":{"valid":true,"comments":[{"start":32,"end":44,"kind":"line","action":"remove"},{"start":56,"end":65,"kind":"line","action":"remove"}],"output_base64":"77u/I3ByYWdtYSB3YXJuaW5nIGRpc2FibGUgMTU5MSAKdmFyIGEgPSAxOyAK"}},{"id":"csharp-conditional-section-limitation","language":"csharp","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"#if false\n' not C# at all\n#endif\nvar a = 1; // remove\n","expect":{"valid":false,"comments":[{"start":44,"end":53,"kind":"line","action":"remove"}],"output_utf8":"#if false\n' not C# at all\n#endif\nvar a = 1; // remove\n"}},{"id":"python-prefixed-string-in-fstring-expression","language":"python","operation":"scan","options":{"policy":"standard","layout":"lines"},"source_utf8":"f\"{r\"x\n","expect":{"valid":false,"comments":[]}},{"id":"scala-triple-quote-run","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val a = \"\"\"a\"\"\"\"\nval b = \"\"\"\"\"\"\n// remove\n","expect":{"valid":true,"comments":[{"start":32,"end":41,"kind":"line","action":"remove"}],"output_utf8":"val a = \"\"\"a\"\"\"\"\nval b = \"\"\"\"\"\"\n\n"}},{"id":"scala-backquoted-identifier","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val `a//b` = 1\nval c = `x /* y */`\n// remove\n","expect":{"valid":true,"comments":[{"start":35,"end":44,"kind":"line","action":"remove"}],"output_utf8":"val `a//b` = 1\nval c = `x /* y */`\n\n"}},{"id":"scala-xml-literal-text","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val a = // text\nval b = \nval c = {x // code\n}\n// remove\n","expect":{"valid":true,"comments":[{"start":34,"end":47,"kind":"html-comment","action":"keep"},{"start":66,"end":73,"kind":"line","action":"remove"},{"start":80,"end":89,"kind":"line","action":"remove"}],"output_utf8":"val a = // text\nval b = \nval c = {x \n}\n\n"}},{"id":"scala-keyword-and-number-strings","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"def f = return\"ok ${1 // not}\"\nval g = 1\"x // not\"\n// remove\n","expect":{"valid":true,"comments":[{"start":51,"end":60,"kind":"line","action":"remove"}],"output_utf8":"def f = return\"ok ${1 // not}\"\nval g = 1\"x // not\"\n\n"}},{"id":"scala-dollar-escape-in-interpolated-string","language":"scala","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":"val a = s\"x$\"y\"\nval b = s\"$$lit\"\n// remove\n","expect":{"valid":true,"comments":[{"start":33,"end":42,"kind":"line","action":"remove"}],"output_utf8":"val a = s\"x$\"y\"\nval b = s\"$$lit\"\n\n"}},{"id":"scss-protocol-relative-url","language":"css","dialect":"scss","operation":"transform","options":{"policy":"standard","layout":"lines"},"source_utf8":".b { background: url(//cdn/x.png) no-repeat }\n// yes\n","expect":{"valid":true,"comments":[{"start":46,"end":52,"kind":"line","action":"remove"}],"output_utf8":".b { background: url(//cdn/x.png) no-repeat }\n\n"}},{"id":"vue-v-pre-raw-text","language":"vue","operation":"scan","options":{"policy":"standard","layout":"lines"},"source_utf8":"
\n"}},{"id":"profile-longest-token-wins-over-declaration-order","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"gleam","extensions":["gleam"],"line_comments":[{"start":"////","kind":"doc-line"},{"start":"///","kind":"doc-line"},{"start":"//","kind":"line"}],"block_comments":[],"strings":[{"start":"\"","end":"\"","escape":"\\","multiline":true}],"protected_patterns":[]},"source_utf8":"//// module\n/// item\n// remark\n","expect":{"valid":true,"comments":[{"start":0,"end":11,"kind":"doc-line","action":"keep"},{"start":12,"end":20,"kind":"doc-line","action":"keep"},{"start":21,"end":30,"kind":"line","action":"remove"}]}},{"id":"profile-prefix-delimiters-are-not-ambiguous","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"gleam","extensions":["gleam"],"line_comments":[{"start":"////","kind":"doc-line"},{"start":"///","kind":"doc-line"},{"start":"//","kind":"line"}],"block_comments":[],"strings":[{"start":"\"","end":"\"","escape":"\\","multiline":true}],"protected_patterns":[]},"source_utf8":"///doc\n//remark\n","expect":{"valid":true,"comments":[{"start":0,"end":6,"kind":"doc-line","action":"keep"},{"start":7,"end":15,"kind":"line","action":"remove"}]}},{"id":"profile-a-string-still-hides-a-comment-token","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"gleam","extensions":["gleam"],"line_comments":[{"start":"////","kind":"doc-line"},{"start":"///","kind":"doc-line"},{"start":"//","kind":"line"}],"block_comments":[],"strings":[{"start":"\"","end":"\"","escape":"\\","multiline":true}],"protected_patterns":[]},"source_utf8":"pub const s = \"// not a comment\"\n// a comment\n","expect":{"valid":true,"comments":[{"start":33,"end":45,"kind":"line","action":"remove"}]}},{"id":"profile-haskell-dashes-open-a-comment","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"-- a remark\nx = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":11,"kind":"line","action":"remove"}]}},{"id":"profile-haskell-an-operator-is-not-a-comment","language":"c","operation":"transform-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"a --> b\nc <-- d\n","expect":{"valid":true,"comments":[{"start":11,"end":15,"kind":"line","action":"remove"}],"output_utf8":"a --> b\nc <\n"}},{"id":"profile-haskell-a-longer-run-of-dashes-is-still-a-comment","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"---x is a comment\ny = 2\n","expect":{"valid":true,"comments":[{"start":0,"end":17,"kind":"line","action":"remove"}]}},{"id":"profile-haskell-a-longer-run-before-a-symbol-is-an-operator","language":"c","operation":"transform-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"a ----> b\n","expect":{"valid":true,"comments":[],"output_utf8":"a ----> b\n"}},{"id":"profile-haskell-haddock-continues-with-the-plain-opener","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"-- | The first line is marked.\n-- The rest is not.\nadd = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":30,"kind":"doc-line","action":"keep"},{"start":31,"end":52,"kind":"doc-line","action":"keep"}]}},{"id":"profile-haskell-a-blank-line-ends-the-continuation","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"-- | Documentation.\n\n-- an unrelated remark\nadd = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":19,"kind":"doc-line","action":"keep"},{"start":21,"end":43,"kind":"line","action":"remove"}]}},{"id":"profile-haskell-a-remark-below-code-is-not-documentation","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"-- | Documentation.\nadd = 1\n-- an unrelated remark\n","expect":{"valid":true,"comments":[{"start":0,"end":19,"kind":"doc-line","action":"keep"},{"start":28,"end":50,"kind":"line","action":"remove"}]}},{"id":"profile-haskell-nesting-counts-the-pairing","language":"c","operation":"transform-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"{-| Documentation.\n It nests {- like this -} properly.\n-}\ndata T = T\n","expect":{"valid":true,"comments":[{"start":0,"end":58,"kind":"doc-block","action":"keep"}],"output_utf8":"{-| Documentation.\n It nests {- like this -} properly.\n-}\ndata T = T\n"}},{"id":"profile-haskell-a-string-hides-both-comment-forms","language":"c","operation":"scan-profile","options":{"policy":"conservative"},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"-- ^","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-|","end":"-}","nested":true,"kind":"doc-block"},{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"s = \"-- not a comment, {- nor this -}\"\n-- a comment\n","expect":{"valid":true,"comments":[{"start":39,"end":51,"kind":"line","action":"remove"}]}},{"id":"profile-style-reads-the-profiles-own-marker","language":"c","operation":"transform-profile","options":{"policy":"none","style":{"space_after_marker":true}},"profile":{"name":"haskell","extensions":["hs"],"doc_continuation":true,"line_comments":[{"start":"-- |","kind":"doc-line"},{"start":"--","forbidden_after":"!#$%&*+./<=>?@\\^|~:-","kind":"line"}],"block_comments":[{"start":"{-","end":"-}","nested":true,"kind":"block"}],"strings":[{"start":"\"","end":"\"","escape":"\\"}],"protected_patterns":[]},"source_utf8":"-- |Documentation written against its marker.\nadd = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":45,"kind":"doc-line","action":"rewrite"}],"output_utf8":"-- | Documentation written against its marker.\nadd = 1\n"}},{"id":"wrap-joins-a-break-nobody-meant","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// A sentence that was broken\n/// to keep the line short.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":56,"kind":"doc-line","action":"keep"},{"start":57,"end":84,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// A sentence that was broken to keep the line short.\nfn a() {}\n"}},{"id":"wrap-breaks-after-every-sentence","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// One sentence. And a second on the same line.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":74,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// One sentence.\n/// And a second on the same line.\nfn a() {}\n"}},{"id":"wrap-keeps-a-break-after-a-clause","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// A clause ends here,\n/// and the break after it is kept.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":49,"kind":"doc-line","action":"keep"},{"start":50,"end":85,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// A clause ends here,\n/// and the break after it is kept.\nfn a() {}\n"}},{"id":"wrap-unwrap-joins-without-breaking-sentences","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"unwrap"}},"source_utf8":"fn head() {}\nfn also() {}\n/// One sentence. And a second.\n/// A third that was\n/// broken to fit.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":57,"kind":"doc-line","action":"keep"},{"start":58,"end":78,"kind":"doc-line","action":"keep"},{"start":79,"end":97,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// One sentence. And a second.\n/// A third that was broken to fit.\nfn a() {}\n"}},{"id":"wrap-leaves-a-fenced-code-block","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// Prose that wraps\n/// here.\n///\n/// ```\n/// let x = 1;\n/// let y = 2. Not prose.\n/// ```\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":46,"kind":"doc-line","action":"keep"},{"start":47,"end":56,"kind":"doc-line","action":"keep"},{"start":57,"end":60,"kind":"doc-line","action":"keep"},{"start":61,"end":68,"kind":"doc-line","action":"keep"},{"start":69,"end":83,"kind":"doc-line","action":"keep"},{"start":84,"end":109,"kind":"doc-line","action":"keep"},{"start":110,"end":117,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// Prose that wraps here.\n///\n/// ```\n/// let x = 1;\n/// let y = 2. Not prose.\n/// ```\nfn a() {}\n"}},{"id":"wrap-leaves-a-section-heading","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// # Errors\n/// The first line under the heading.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":38,"kind":"doc-line","action":"keep"},{"start":39,"end":76,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// # Errors\n/// The first line under the heading.\nfn a() {}\n"}},{"id":"wrap-leaves-a-link-reference-definition","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// [`Thing::fail`]: when it cannot be done.\n/// Ordinary prose.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":70,"kind":"doc-line","action":"keep"},{"start":71,"end":90,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// [`Thing::fail`]: when it cannot be done.\n/// Ordinary prose.\nfn a() {}\n"}},{"id":"wrap-reaches-a-list-item-and-keeps-its-indentation","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// - an item whose text wraps\n/// onto the next line. And a second sentence.\n/// - another\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":56,"kind":"doc-line","action":"keep"},{"start":57,"end":105,"kind":"doc-line","action":"keep"},{"start":106,"end":119,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// - an item whose text wraps onto the next line.\n/// And a second sentence.\n/// - another\nfn a() {}\n"}},{"id":"wrap-leaves-a-table","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// | a | b |\n/// |---|---|\n/// | 1 | 2 |\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":39,"kind":"doc-line","action":"keep"},{"start":40,"end":53,"kind":"doc-line","action":"keep"},{"start":54,"end":67,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// | a | b |\n/// |---|---|\n/// | 1 | 2 |\nfn a() {}\n"}},{"id":"wrap-does-not-break-inside-a-host-name","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// See https://example.com/a.b/c for details. Version 1.5 is fine.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":93,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// See https://example.com/a.b/c for details.\n/// Version 1.5 is fine.\nfn a() {}\n"}},{"id":"wrap-does-not-break-after-an-abbreviation","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// Abbreviations e.g. this one do not end a sentence. J. Smith neither.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":98,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// Abbreviations e.g. this one do not end a sentence.\n/// J. Smith neither.\nfn a() {}\n"}},{"id":"wrap-breaks-a-cjk-sentence-without-a-space","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// 日本語の文です。これは二文目。\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":75,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// 日本語の文です。\n/// これは二文目。\nfn a() {}\n"}},{"id":"wrap-joins-cjk-without-inserting-a-space","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// 日本語の文がここで\n/// 折り返されている。\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":57,"kind":"doc-line","action":"keep"},{"start":58,"end":89,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// 日本語の文がここで折り返されている。\nfn a() {}\n"}},{"id":"wrap-reaches-a-line-comment-run-too","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n// A remark that was broken\n// to keep the line short.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":53,"kind":"line","action":"keep"},{"start":54,"end":80,"kind":"line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n// A remark that was broken to keep the line short.\nfn a() {}\n"}},{"id":"wrap-leaves-a-run-whose-lines-open-differently","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// Documentation that wraps\n//! and an inner doc line under it.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":54,"kind":"doc-line","action":"keep"},{"start":55,"end":90,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// Documentation that wraps\n//! and an inner doc line under it.\nfn a() {}\n"}},{"id":"wrap-reaches-a-block-comment","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/* A block that wraps\n * onto a second line. */\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":73,"kind":"block","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/* A block that wraps onto a second line. */\nfn a() {}\n"}},{"id":"wrap-leaves-the-first-two-lines-alone","language":"python","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"# A remark that was broken\n# to keep the line short.\nx = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":26,"kind":"line","action":"keep"},{"start":27,"end":52,"kind":"line","action":"keep"}],"output_utf8":"# A remark that was broken\n# to keep the line short.\nx = 1\n"}},{"id":"wrap-keeps-crlf-endings","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\r\nfn also() {}\r\n/// A sentence that was broken\r\n/// to keep the line short.\r\nfn a() {}\r\n","expect":{"valid":true,"comments":[{"start":28,"end":58,"kind":"doc-line","action":"keep"},{"start":60,"end":87,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\r\nfn also() {}\r\n/// A sentence that was broken to keep the line short.\r\nfn a() {}\r\n"}},{"id":"wrap-and-removal-in-one-file","language":"rust","operation":"transform","options":{"policy":"conservative","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// Documentation that wraps\n/// onto a second line.\nfn a() {}\n// a remark\nfn b() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":54,"kind":"doc-line","action":"keep"},{"start":55,"end":78,"kind":"doc-line","action":"keep"},{"start":89,"end":100,"kind":"line","action":"remove"}],"output_utf8":"fn head() {}\nfn also() {}\n/// Documentation that wraps onto a second line.\nfn a() {}\n\nfn b() {}\n"}},{"id":"wrap-leaves-a-comment-beside-code","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\nlet x = 1; // a remark that is long\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":37,"end":61,"kind":"line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\nlet x = 1; // a remark that is long\nfn a() {}\n"}},{"id":"wrap-reaches-the-first-line-where-no-preamble-is-read","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"//! Module documentation that was broken\n//! to keep the line short.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":0,"end":40,"kind":"doc-line","action":"keep"},{"start":41,"end":68,"kind":"doc-line","action":"keep"}],"output_utf8":"//! Module documentation that was broken to keep the line short.\nfn a() {}\n"}},{"id":"wrap-keeps-a-block-closer-on-its-own-line","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/* A block that wraps\n * onto a second line.\n */\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":74,"kind":"block","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/* A block that wraps onto a second line.\n */\nfn a() {}\n"}},{"id":"wrap-leaves-a-block-that-fits-on-one-line","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/* One sentence. And another. */\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":58,"kind":"block","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/* One sentence. And another. */\nfn a() {}\n"}},{"id":"wrap-aligns-an-ocaml-block-under-its-text","language":"ocaml","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"let head = 1\nlet also = 2\n(* A block whose continuation lines\n are aligned under the text. And a second sentence. *)\nlet a = 3\n","expect":{"valid":true,"comments":[{"start":26,"end":118,"kind":"block","action":"keep"}],"output_utf8":"let head = 1\nlet also = 2\n(* A block whose continuation lines are aligned under the text.\n And a second sentence. *)\nlet a = 3\n"}},{"id":"wrap-reaches-an-ocaml-documentation-block","language":"ocaml","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"let head = 1\nlet also = 2\n(** Documentation that wraps\n onto a second line. *)\nlet a = 3\n","expect":{"valid":true,"comments":[{"start":26,"end":80,"kind":"doc-block","action":"keep"}],"output_utf8":"let head = 1\nlet also = 2\n(** Documentation that wraps onto a second line. *)\nlet a = 3\n"}},{"id":"wrap-keeps-a-blank-line-inside-a-block","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/* One paragraph that wraps\n * onto a line.\n *\n * A second paragraph. */\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":98,"kind":"block","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/* One paragraph that wraps onto a line.\n *\n * A second paragraph. */\nfn a() {}\n"}},{"id":"wrap-leaves-a-block-whose-interior-is-a-code-example","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/* An example:\n *\n * ```\n * let x = 1;\n * let y = 2. Not prose.\n * ```\n */\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":100,"kind":"block","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/* An example:\n *\n * ```\n * let x = 1;\n * let y = 2. Not prose.\n * ```\n */\nfn a() {}\n"}},{"id":"wrap-leaves-an-example-indented-under-an-item","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// - an item that wraps\n/// onto a line:\n///\n/// let x = 1;\n///\n/// After.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":50,"kind":"doc-line","action":"keep"},{"start":51,"end":69,"kind":"doc-line","action":"keep"},{"start":70,"end":73,"kind":"doc-line","action":"keep"},{"start":74,"end":92,"kind":"doc-line","action":"keep"},{"start":93,"end":96,"kind":"doc-line","action":"keep"},{"start":97,"end":107,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// - an item that wraps onto a line:\n///\n/// let x = 1;\n///\n/// After.\nfn a() {}\n"}},{"id":"wrap-keeps-a-nested-list-nested","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// - outer item that wraps\n/// onto a line\n/// - inner item that wraps\n/// onto a line\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":53,"kind":"doc-line","action":"keep"},{"start":54,"end":71,"kind":"doc-line","action":"keep"},{"start":72,"end":101,"kind":"doc-line","action":"keep"},{"start":102,"end":121,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// - outer item that wraps onto a line\n/// - inner item that wraps onto a line\nfn a() {}\n"}},{"id":"wrap-splits-an-item-into-sentences-under-its-marker","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// 1. One sentence. And a second.\n/// 2. Another.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":60,"kind":"doc-line","action":"keep"},{"start":61,"end":76,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// 1. One sentence.\n/// And a second.\n/// 2. Another.\nfn a() {}\n"}},{"id":"wrap-splits-a-run-at-a-line-a-style-rule-cannot-reach","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// Prose above that wraps\n/// onto a line.\n/// noqa is a word a linter reads.\n/// Prose below that wraps\n/// onto a line.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":52,"kind":"doc-line","action":"keep"},{"start":53,"end":69,"kind":"doc-line","action":"keep"},{"start":70,"end":104,"kind":"directive","action":"keep"},{"start":105,"end":131,"kind":"doc-line","action":"keep"},{"start":132,"end":148,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// Prose above that wraps onto a line.\n/// noqa is a word a linter reads.\n/// Prose below that wraps onto a line.\nfn a() {}\n"}},{"id":"wrap-joins-a-sentence-that-opens-with-an-intra-doc-link","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\nfn also() {}\n/// [`Thing::fail`]: removed with the run of comments it belongs\n/// to, because that run is longer than the limit.\nfn a() {}\n","expect":{"valid":true,"comments":[{"start":26,"end":90,"kind":"doc-line","action":"keep"},{"start":91,"end":141,"kind":"doc-line","action":"keep"}],"output_utf8":"fn head() {}\nfn also() {}\n/// [`Thing::fail`]: removed with the run of comments it belongs to, because that run is longer than the limit.\nfn a() {}\n"}},{"id":"wrap-reaches-a-markdown-paragraph","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"A paragraph that wraps\nacross two lines. And a second sentence.\n","expect":{"valid":true,"comments":[],"output_utf8":"A paragraph that wraps across two lines.\nAnd a second sentence.\n"}},{"id":"wrap-leaves-a-markdown-fence","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"Prose that wraps\nacross lines.\n\n```\ncode that wraps\nshould not join.\n```\n","expect":{"valid":true,"comments":[],"output_utf8":"Prose that wraps across lines.\n\n```\ncode that wraps\nshould not join.\n```\n"}},{"id":"wrap-leaves-markdown-front-matter","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"---\ntitle: a document\nsummary: two lines\n---\n\nProse that wraps\nacross lines.\n","expect":{"valid":true,"comments":[],"output_utf8":"---\ntitle: a document\nsummary: two lines\n---\n\nProse that wraps across lines.\n"}},{"id":"wrap-leaves-a-markdown-heading-and-table","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"# A heading that is long\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nProse that wraps\nacross lines.\n","expect":{"valid":true,"comments":[],"output_utf8":"# A heading that is long\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nProse that wraps across lines.\n"}},{"id":"wrap-leaves-a-markdown-html-comment-to-the-comment-path","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"Prose that wraps\nacross lines.\n\n\n","expect":{"valid":true,"comments":[{"start":32,"end":80,"kind":"html-comment","action":"keep"}],"output_utf8":"Prose that wraps across lines.\n\n\n"}},{"id":"wrap-reaches-a-markdown-list-item","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"- an item that wraps\n onto the next line. And a second sentence.\n- another\n","expect":{"valid":true,"comments":[],"output_utf8":"- an item that wraps onto the next line.\n And a second sentence.\n- another\n"}},{"id":"wrap-keeps-an-item-open-across-a-clause-break","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"- An item whose first line ends at a clause:\n the rest of it wraps\n onto two more lines.\n- another\n","expect":{"valid":true,"comments":[],"output_utf8":"- An item whose first line ends at a clause:\n the rest of it wraps onto two more lines.\n- another\n"}},{"id":"wrap-writes-a-continued-item-under-its-marker","language":"markdown","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"- An item whose first line ends at a clause:\n a second sentence. And a third.\n","expect":{"valid":true,"comments":[],"output_utf8":"- An item whose first line ends at a clause:\n a second sentence.\n And a third.\n"}},{"id":"wrap-keeps-the-indentation-the-source-wrote","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"impl T {\n /// A sentence that was broken\n /// to keep the line short.\n fn a() {}\n}\n","expect":{"valid":true,"comments":[{"start":13,"end":43,"kind":"doc-line","action":"keep"},{"start":48,"end":75,"kind":"doc-line","action":"keep"}],"output_utf8":"impl T {\n /// A sentence that was broken to keep the line short.\n fn a() {}\n}\n"}},{"id":"wrap-indents-the-lines-a-split-opens","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"impl T {\n /// One sentence. Another one.\n fn a() {}\n}\n","expect":{"valid":true,"comments":[{"start":13,"end":43,"kind":"doc-line","action":"keep"}],"output_utf8":"impl T {\n /// One sentence.\n /// Another one.\n fn a() {}\n}\n"}},{"id":"wrap-refuses-a-run-whose-lines-sit-at-different-columns","language":"yaml","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"a: 1\n\n# - script: |\n # echo building the image\n # docker build --rm .\n\nb: 2\n","expect":{"valid":true,"comments":[{"start":6,"end":19,"kind":"line","action":"keep"},{"start":24,"end":49,"kind":"line","action":"keep"},{"start":54,"end":75,"kind":"line","action":"keep"}],"output_utf8":"a: 1\n\n# - script: |\n # echo building the image\n # docker build --rm .\n\nb: 2\n"}},{"id":"declarative-profile-reaches-the-style-axis-too","language":"c","operation":"transform-profile","options":{"policy":"none","style":{"wrap":"sentence"},"layout":"lines"},"profile":{"name":"demo","extensions":["demo"],"line_comments":[{"start":"//","kind":"line"}],"block_comments":[],"strings":[],"protected_patterns":[]},"source_utf8":"call()\n// A remark. Another one.\ncall()\n","expect":{"valid":true,"comments":[{"start":7,"end":32,"kind":"line","action":"keep"}],"output_utf8":"call()\n// A remark.\n// Another one.\ncall()\n"}},{"id":"a-scan-records-the-run-it-rewrote","language":"rust","operation":"scan","options":{"policy":"none","style":{"wrap":"sentence"}},"source_utf8":"fn head() {}\n// A remark. Another one.\nfn also() {}\n","expect":{"valid":true,"comments":[{"start":13,"end":38,"kind":"line","action":"keep"}]}},{"id":"wrap-leaves-a-labelled-divider-alone","language":"shell","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"x=1\n# --- keybindings ------------------------------------\n# Splits, mapped the same way the other machine maps them.\ny=2\n","expect":{"valid":true,"comments":[{"start":4,"end":58,"kind":"line","action":"keep"},{"start":59,"end":117,"kind":"line","action":"keep"}],"output_utf8":"x=1\n# --- keybindings ------------------------------------\n# Splits, mapped the same way the other machine maps them.\ny=2\n"}},{"id":"wrap-reads-a-label-as-a-marker-with-no-tag-list","language":"toml","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"# NOTE: The policy this machine holds every commit to, as a setting\n# NOTE: rather than as a gate's own opinion. It merges under a project's.\nversion = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":67,"kind":"line","action":"keep"},{"start":68,"end":141,"kind":"line","action":"keep"}],"output_utf8":"# NOTE: The policy this machine holds every commit to, as a setting rather than as a gate's own opinion.\n# NOTE: It merges under a project's.\nversion = 1\n"}},{"id":"wrap-does-not-read-an-ordinary-word-as-a-marker","language":"toml","operation":"transform","options":{"policy":"none","layout":"lines","style":{"wrap":"sentence"}},"source_utf8":"# The cat sat on the mat and then\n# the dog ran away. A second sentence.\nversion = 1\n","expect":{"valid":true,"comments":[{"start":0,"end":33,"kind":"line","action":"keep"},{"start":34,"end":72,"kind":"line","action":"keep"}],"output_utf8":"# The cat sat on the mat and then the dog ran away.\n# A second sentence.\nversion = 1\n"}},{"id":"allow-rules-do-not-reach-policy-none","language":"rust","operation":"scan","options":{"policy":"none","allow":{"tags":["NOTE"],"max_lines":1,"trailing":false}},"source_utf8":"// NOTE: one line.\npub fn a() {}\n\n// NOTE: goes on\n// NOTE: and on.\npub fn b() {}\n\npub fn c() {} // NOTE: beside code\n\n// plain\npub fn d() {}\n","expect":{"valid":true,"comments":[{"start":0,"end":18,"kind":"line","action":"keep"},{"start":34,"end":50,"kind":"line","action":"keep"},{"start":51,"end":67,"kind":"line","action":"keep"},{"start":97,"end":117,"kind":"line","action":"keep"},{"start":119,"end":127,"kind":"line","action":"keep"}]}},{"id":"policy-none-restyles-what-it-refuses-to-remove","language":"rust","operation":"transform","options":{"policy":"none","layout":"lines","allow":{"max_lines":1,"trailing":false},"style":{"wrap":"sentence"}},"source_utf8":"// A first sentence wrapped to a\n// column. A second sentence.\npub fn a() {}\n\npub fn b() {} // beside code\n","expect":{"valid":true,"comments":[{"start":0,"end":32,"kind":"line","action":"keep"},{"start":33,"end":62,"kind":"line","action":"keep"},{"start":92,"end":106,"kind":"line","action":"keep"}],"output_utf8":"// A first sentence wrapped to a column.\n// A second sentence.\npub fn a() {}\n\npub fn b() {} // beside code\n"}}]}
diff --git a/rust/ocomment/src/advice.rs b/rust/ocomment/src/advice.rs
index 72b8fde..065f326 100644
--- a/rust/ocomment/src/advice.rs
+++ b/rust/ocomment/src/advice.rs
@@ -1,36 +1,30 @@
//! What to do about a comment, read from where it sits.
//!
-//! A verdict says a comment may go. That is not the question its author has.
-//! Theirs is "and then what" — and the answer is not the same for a line that
-//! explains the function under it, a note wedged beside an assignment, and a
-//! promise nobody kept. The engine cannot tell them apart because the verdict
-//! does not depend on the difference; a reader deciding what to do can tell
-//! them apart at a glance, from the bytes around the comment, and so can this.
+//! A verdict says a comment may go.
+//! That is not the question its author has.
+//! Theirs is "and then what" — and the answer is not the same for a line that explains the function under it, a note wedged beside an assignment, and a promise nobody kept.
+//! The engine cannot tell them apart because the verdict does not depend on the difference; a reader deciding what to do can tell them apart at a glance, from the bytes around the comment, and so can this.
//!
-//! Nothing here changes a verdict. Every decision below is a rendering of a
-//! comment the scanner already reported, and a comment whose situation is not
-//! one this recognises gets the weakest advice rather than a guess.
+//! Nothing here changes a verdict.
+//! Every decision below is a rendering of a comment the scanner already reported, and a comment whose situation is not one this recognises gets the weakest advice rather than a guess.
//!
-//! It lives in the CLI rather than in the engine on purpose. The two
-//! implementations are held to each other over what they *decide*, and that is
-//! where the cross-check earns its keep; advice decides nothing, so mirroring
-//! it in OCaml would double the work and prove nothing.
+//! It lives in the CLI rather than in the engine on purpose.
+//! The two implementations are held to each other over what they *decide*, and that is where the cross-check earns its keep; advice decides nothing, so mirroring it in OCaml would double the work and prove nothing.
use crate::output::{ProcessedFile, sanitize_source_line};
-use ocomment_core::{Age, Comment, CommentKind, Language, Policy, ShapeRule};
+use ocomment_core::{
+ Age, Comment, CommentKind, Disposition, Language, Policy, ShapeRule, StyleRule,
+};
use std::collections::BTreeSet;
use std::path::PathBuf;
/// The decision a removable comment asks its author for.
///
-/// Ordered by which claim wins when a comment is several of these at once: a
-/// `// TODO:` beside an assignment is a promise first, because "do it or delete
-/// it" is a larger question than which line it sits on.
+/// Ordered by which claim wins when a comment is several of these at once: a `// TODO:` beside an assignment is a promise first, because "do it or delete it" is a larger question than which line it sits on.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Decision {
- /// It opens with a tag that promises something. The tag is on the item
- /// rather than here, so that two promises are one decision asked twice
- /// rather than two decisions that happen to rhyme.
+ /// It opens with a tag that promises something.
+ /// The tag is on the item rather than here, so that two promises are one decision asked twice rather than two decisions that happen to rhyme.
Promise,
/// It shares a line with code.
BesideCode,
@@ -42,19 +36,21 @@ pub enum Decision {
AtTheTopOfTheFile,
/// Anything else: it sits among statements.
AmongStatements,
- /// A promise whose deadline has passed. The engine decided this one and
- /// knows by how much, so its words win over anything read from the
- /// surrounding lines.
+ /// A promise whose deadline has passed.
+ /// The engine decided this one and knows by how much, so its words win over anything read from the surrounding lines.
Expired { age: Age, limit: Age },
- /// Longer than the configured paragraph. Also the engine's, and also
- /// answered by an edit to the comment rather than by deleting it.
+ /// Longer than the configured paragraph.
+ /// Also the engine's, and also answered by an edit to the comment rather than by deleting it.
TooLong { limit: usize },
- /// The policy is stricter than the kind of comment this is, and a gentler
- /// one keeps it. Nothing about where it sits enters into that, and reading
- /// the surrounding lines for advice would answer a question nobody asked:
- /// a documentation comment taken out by `--policy all` is not a comment in
- /// the wrong place, it is a run asking for more than the reader meant.
+ /// The policy is stricter than the kind of comment this is, and a gentler one keeps it.
+ /// Nothing about where it sits enters into that, and reading the surrounding lines for advice would answer a question nobody asked:
+ /// a documentation comment taken out by `--policy all` is not a comment in the wrong place, it is a run asking for more than the reader meant.
StricterThanTheKind { kind: CommentKind, keeps: Policy },
+ /// It stays, and a style rule would write it differently.
+ ///
+ /// Last, because it is the only one of these the tool can answer itself.
+ /// Every other decision here is a question put to a reader; this one is an edit already computed, and it is in the list rather than beside it so that a caller parsing the report finds every change in one place — a format that showed removals and silently omitted rewrites would disagree with the exit code.
+ Restyle { rule: StyleRule },
}
impl Decision {
@@ -78,6 +74,11 @@ impl Decision {
Self::StricterThanTheKind { kind, keeps } => {
format!("keep `{kind}` comments with `{keeps}`, or mean to remove them")
}
+ /* NOTE: What to run, and not what to write.
+ * The other instructions here name an edit a reader has to make; this one names the command that makes it, because the edit is in the finding beside it. */
+ Self::Restyle { .. } => {
+ "run `ocomment fix --tidy` and it is written for you".to_owned()
+ }
}
}
@@ -94,18 +95,14 @@ impl Decision {
Self::Expired { .. } => "expired",
Self::TooLong { .. } => "too-long",
Self::StricterThanTheKind { .. } => "stricter-than-the-kind",
+ Self::Restyle { rule } => rule.as_str(),
}
}
- /// The setting that would stop this being reported, and the table it goes
- /// in.
+ /// The setting that would stop this being reported, and the table it goes in.
///
- /// The other half of every decision. A gate that can only say "delete it"
- /// is a gate somebody turns off the first time it is wrong about one
- /// comment, so the way to keep a comment has to be as visible as the way to
- /// remove it — and it has to be a setting rather than a flag, because a
- /// flag makes one run pass and a setting is a decision the repository
- /// keeps.
+ /// The other half of every decision.
+ /// A gate that can only say "delete it" is a gate somebody turns off the first time it is wrong about one comment, so the way to keep a comment has to be as visible as the way to remove it — and it has to be a setting rather than a flag, because a flag makes one run pass and a setting is a decision the repository keeps.
#[must_use]
pub fn keep_route(&self, tags: &BTreeSet, longest: usize) -> Option {
let named = |names: &BTreeSet| {
@@ -118,10 +115,8 @@ impl Decision {
Self::AmongStatements | Self::AtTheTopOfTheFile | Self::ExplainsTheItemBelow => {
Some("[policy.allow]\ntags = [\"NOTE\"]".to_owned())
}
- /* NOTE: A deadline is kept by changing the deadline, so the value
- * is left for the reader to choose. Filling one in would be this
- * offering a number nobody decided, for a rule whose whole point is
- * that somebody did. */
+ /* NOTE: A deadline is kept by changing the deadline, so the value is left for the reader to choose.
+ * Filling one in would be this offering a number nobody decided, for a rule whose whole point is that somebody did. */
Self::Expired { .. } => Some(format!(
"[policy.allow.expiry]\n{} = \"...\" # longer than the oldest above",
tags.iter().next().map_or("TODO", String::as_str)
@@ -130,15 +125,19 @@ impl Decision {
"[policy.allow]\nmax_lines = {longest} # {limit} now, {longest} is the longest above"
)),
/* NOTE: The policy itself, because the policy is what decided it.
- * Offering `[policy.allow] tags` here was the old answer and it was
- * wrong twice over: a `///` carries no tag to allow, and allowing
- * one would not reach a rule that is about kinds. */
+ * Offering `[policy.allow] tags` here was the old answer and it was wrong twice over: a `///` carries no tag to allow, and allowing one would not reach a rule that is about kinds. */
Self::StricterThanTheKind { keeps, .. } => {
Some(format!("[policy]\nmode = \"{keeps}\""))
}
- /* NOTE: None on purpose. Commented-out code is the one situation
- * with nothing worth keeping, and offering a way to keep it would
- * be this file's own advice arguing against itself. */
+ /* NOTE: The style rule that asked, turned off, and written as the value that means "leave it alone" rather than as the table removed.
+ * The other half of a rewrite is the same as the other half of a removal: a reader who disagrees with the edit has to be able to say so as a setting the repository keeps. */
+ Self::Restyle { rule } => Some(match rule {
+ StyleRule::Wrap => "[style]\nwrap = \"preserve\"".to_owned(),
+ StyleRule::SpaceAfterMarker => "[style]\nspace_after_marker = false".to_owned(),
+ StyleRule::TrailingWhitespace => "[style]\ntrailing_whitespace = true".to_owned(),
+ }),
+ /* NOTE: None on purpose.
+ * Commented-out code is the one situation with nothing worth keeping, and offering a way to keep it would be this file's own advice arguing against itself. */
Self::CommentedOutCode => None,
}
}
@@ -148,23 +147,17 @@ impl Decision {
#[derive(Clone, Debug)]
pub struct Item {
pub path: PathBuf,
- /// The bytes the run covers, from its first comment's first byte to its
- /// last comment's last.
+ /// The bytes the run covers, from its first comment's first byte to its last comment's last.
///
- /// The path and the line do not identify a finding. Two removable comments
- /// share a line whenever one of them sits beside code, and a lookup keyed
- /// on the line returned the first of them for both — so `--explain`
- /// printed one comment's verdict under the other, which is the one thing a
- /// reader consults `--explain` to check, and two findings reached a
- /// machine format identical in every field.
+ /// The path and the line do not identify a finding.
+ /// Two removable comments share a line whenever one of them sits beside code, and a lookup keyed on the line returned the first of them for both — so `--explain` printed one comment's verdict under the other, which is the one thing a reader consults `--explain` to check, and two findings reached a machine format identical in every field.
pub start: usize,
pub end: usize,
/// One-based, the column the first comment of the run opens at.
pub column: usize,
/// Something other than whitespace is in front of it on its line.
///
- /// Which is exactly when a path and a line stop naming one finding: the
- /// thing in front may be code, and it may be another comment.
+ /// Which is exactly when a path and a line stop naming one finding: the thing in front may be code, and it may be another comment.
pub beside: bool,
/// One-based, inclusive on both ends.
pub first_line: usize,
@@ -173,7 +166,8 @@ pub struct Item {
pub comments: usize,
/// The lines as they are, sanitised.
pub old: Vec,
- /// What would replace them, sanitised. Empty when the advice is to delete.
+ /// What would replace them, sanitised.
+ /// Empty when the advice is to delete.
pub new: Vec,
/// The line the comment is about, when there is one worth showing.
pub subject: Option,
@@ -215,19 +209,14 @@ impl Group {
/// The tags that promise something, as opposed to the ones that remark.
///
-/// `NOTE`, `SAFETY` and the rest say why the code is as it is and are answered
-/// by reading them. These say the code is not as it should be, which is a debt
-/// somebody took on, and the only two ways to answer a debt are to pay it or to
-/// write it off.
+/// `NOTE`, `SAFETY` and the rest say why the code is as it is and are answered by reading them.
+/// These say the code is not as it should be, which is a debt somebody took on, and the only two ways to answer a debt are to pay it or to write it off.
const PROMISES: [&str; 5] = ["TODO", "FIXME", "HACK", "XXX", "BUG"];
/// How a language spells a documentation comment that a prefix rewrite reaches.
///
-/// Only the languages where the rewrite is the whole edit: `//` becomes `///`
-/// and the comment is documentation. A language whose documentation lives
-/// somewhere else -- Python's inside the item, Java's in a block that has to be
-/// opened and closed -- is absent, and the advice for it says what to do
-/// without pretending the edit is one character.
+/// Only the languages where the rewrite is the whole edit: `//` becomes `///` and the comment is documentation.
+/// A language whose documentation lives somewhere else -- Python's inside the item, Java's in a block that has to be opened and closed -- is absent, and the advice for it says what to do without pretending the edit is one character.
const DOC_PREFIX: [(Language, &str); 8] = [
(Language::Rust, "///"),
(Language::C, "///"),
@@ -241,8 +230,7 @@ const DOC_PREFIX: [(Language, &str); 8] = [
/// Everything the run found, grouped by the decision it asks for.
///
-/// Groups come out in the order the decisions are declared, so two runs over
-/// the same tree print the same report.
+/// Groups come out in the order the decisions are declared, so two runs over the same tree print the same report.
#[must_use]
pub fn plan(files: &[ProcessedFile], policy: Policy) -> Vec {
let mut groups: Vec = Vec::new();
@@ -264,20 +252,16 @@ pub fn plan(files: &[ProcessedFile], policy: Policy) -> Vec {
/// The removable comments of one file, as runs, each with its decision.
///
-/// Runs are formed before anything is decided, and that order is the whole of
-/// it: a paragraph's decision is read from the line *under the paragraph*, so
-/// deciding comment by comment asks the first line what the second line is,
-/// gets "another comment", and files four lines of prose about a struct under
-/// "it sits among statements".
+/// Runs are formed before anything is decided, and that order is the whole of it: a paragraph's decision is read from the line *under the paragraph*, so deciding comment by comment asks the first line what the second line is,
+/// gets "another comment", and files four lines of prose about a struct under "it sits among statements".
fn file_items(file: &ProcessedFile, policy: Policy) -> Vec<(Decision, Item)> {
let lines = source_lines(&file.source);
- /* NOTE: Once per file. Building it per comment turns a report over a large
- * file into a quadratic one, and a file with a thousand comments is exactly
- * the file somebody runs this on first. */
+ /* NOTE: Once per file.
+ * Building it per comment turns a report over a large file into a quadratic one, and a file with a thousand comments is exactly the file somebody runs this on first. */
let index = crate::output::LineIndex::new(&file.source);
let mut runs: Vec = Vec::new();
for comment in &file.result.report.comments {
- if !comment.disposition.is_remove() {
+ if !comment.action().removes() {
runs.push(Run::BREAK);
continue;
}
@@ -286,10 +270,8 @@ fn file_items(file: &ProcessedFile, policy: Policy) -> Vec<(Decision, Item)> {
continue;
};
match runs.last_mut() {
- /* NOTE: A run of adjacent comment lines is one paragraph to a
- * reader and one decision to its author. A promise never joins
- * one: it asks a different question, and merging it would hide
- * that question inside a block of prose. */
+ /* NOTE: A run of adjacent comment lines is one paragraph to a reader and one decision to its author.
+ * A promise never joins one: it asks a different question, and merging it would hide that question inside a block of prose. */
Some(open)
if open.comments > 0
&& open.column == placed.column
@@ -297,17 +279,13 @@ fn file_items(file: &ProcessedFile, policy: Policy) -> Vec<(Decision, Item)> {
&& open.tag.is_none()
&& placed.tag.is_none()
/* NOTE: A comment beside code is never part of a paragraph.
- * Forty trailing notes on forty consecutive assignments
- * share a column and are adjacent, and they are forty
- * decisions: each one is about the statement it sits on,
+ * Forty trailing notes on forty consecutive assignments share a column and are adjacent, and they are forty decisions: each one is about the statement it sits on,
* and the statement is different every time. */
&& !open.beside
&& !placed.beside
- /* NOTE: Equal, not absent. A paragraph that ran over the
- * line limit carries the same `TooLong` on every line it
- * covers, and splitting it back into one finding per line
- * would report a single paragraph as several. A deadline
- * is the other way: each one is its own promise. */
+ /* NOTE: Equal, not absent.
+ * A paragraph that ran over the line limit carries the same `TooLong` on every line it covers, and splitting it back into one finding per line would report a single paragraph as several.
+ * A deadline is the other way: each one is its own promise. */
&& open.shape == placed.shape
&& open.kind == placed.kind =>
{
@@ -329,19 +307,104 @@ fn file_items(file: &ProcessedFile, policy: Policy) -> Vec<(Decision, Item)> {
}),
}
}
- runs.into_iter()
+ let mut items: Vec<(Decision, Item)> = runs
+ .into_iter()
.filter(|run| run.comments > 0)
.filter_map(|run| item_of(file, &lines, run, policy))
- .collect()
+ .collect();
+ /* NOTE: And the rewrites, which are decided over a paragraph rather than over the line under it.
+ * A rewrite has no run to form and no surroundings to read: the engine already reached the verdict and already computed the bytes, so the work here is placing them, not deciding them. */
+ items.extend(restyled(file, &lines, &index));
+ items
+}
+
+/// The rewrites of one file, as items, in source order.
+///
+/// Two sources, and the difference between them is the unit the rule is about.
+/// A reflow is recorded against a run because the bytes it moves belong to no single comment; every other style rule is recorded against the comment it read.
+/// A comment a run already covers is not reported twice — the run wrote its marker back itself.
+fn restyled(
+ file: &ProcessedFile,
+ lines: &[String],
+ index: &crate::output::LineIndex,
+) -> Vec<(Decision, Item)> {
+ let report = &file.result.report;
+ let mut items: Vec<(usize, (Decision, Item))> = Vec::new();
+ for run in &report.runs {
+ if let Some(item) = rewrite_item(file, lines, index, run.span, run.rule, &run.replacement) {
+ items.push((run.span.start, item));
+ }
+ }
+ for comment in &report.comments {
+ let Disposition::Rewrite { rule, replacement } = comment.disposition() else {
+ continue;
+ };
+ if report
+ .runs
+ .iter()
+ .any(|run| run.span.start <= comment.span.start && comment.span.end <= run.span.end)
+ {
+ continue;
+ }
+ if let Some(item) = rewrite_item(file, lines, index, comment.span, *rule, replacement) {
+ items.push((comment.span.start, item));
+ }
+ }
+ items.sort_by_key(|(at, _)| *at);
+ items.into_iter().map(|(_, item)| item).collect()
+}
+
+/// One rewrite, placed: the lines it covers as they are, and the lines it would write.
+fn rewrite_item(
+ file: &ProcessedFile,
+ lines: &[String],
+ index: &crate::output::LineIndex,
+ span: ocomment_core::ByteSpan,
+ rule: StyleRule,
+ replacement: &[u8],
+) -> Option<(Decision, Item)> {
+ let (first, column) = index.line_column(span.start);
+ let (last_line, last_column) = index.line_column(span.end);
+ let last = if last_column == 1 {
+ last_line.saturating_sub(1)
+ } else {
+ last_line
+ };
+ if first == 0 || last < first || last > lines.len() {
+ return None;
+ }
+ let old: Vec = lines.get(first - 1..last)?.to_vec();
+ /* NOTE: The replacement written back where it sits, and not on its own.
+ * The span opens at the comment's first byte rather than at the start of its line, so the bytes in front of it on that line -- the indentation, or the code a trailing comment sits after -- are the file's and have to be shown with it, exactly as `old` shows them. */
+ let head = lines.get(first - 1)?.get(..column.saturating_sub(1))?;
+ let text = String::from_utf8_lossy(replacement);
+ let new: Vec = format!("{head}{text}")
+ .lines()
+ .map(sanitize_source_line)
+ .collect();
+ Some((
+ Decision::Restyle { rule },
+ Item {
+ path: file.path.clone(),
+ start: span.start,
+ end: span.end,
+ column,
+ beside: !head.trim().is_empty(),
+ first_line: first,
+ last_line: last,
+ comments: 1,
+ old,
+ new,
+ subject: None,
+ tag: None,
+ },
+ ))
}
/// A run of adjacent comments, before anything has been decided about it.
struct Run {
- /// From the first comment's first byte to the last comment's last. A run
- /// is reported by path and line, and neither a reader asking the engine
- /// for the verdict behind one finding nor a program applying its edit can
- /// name it by the line: two removable comments share a line whenever one
- /// of them sits beside code.
+ /// From the first comment's first byte to the last comment's last.
+ /// A run is reported by path and line, and neither a reader asking the engine for the verdict behind one finding nor a program applying its edit can name it by the line: two removable comments share a line whenever one of them sits beside code.
start: usize,
end: usize,
first: usize,
@@ -355,8 +418,7 @@ struct Run {
}
impl Run {
- /// A run that nothing joins, which is how a kept comment or a comment this
- /// could not place separates the two paragraphs around it.
+ /// A run that nothing joins, which is how a kept comment or a comment this could not place separates the two paragraphs around it.
const BREAK: Self = Self {
start: 0,
end: 0,
@@ -388,9 +450,8 @@ fn item_of(
.get(..run.column.saturating_sub(1))?
.trim();
let body = body_of(lines.get(run.first - 1)?, run.column);
- /* NOTE: A rule the engine decided over the whole file wins. It knows by
- * how much a deadline was missed and by how many lines a paragraph ran
- * over, and a situation read from the surrounding lines cannot say either.
+ /* NOTE: A rule the engine decided over the whole file wins.
+ * It knows by how much a deadline was missed and by how many lines a paragraph ran over, and a situation read from the surrounding lines cannot say either.
* `Tagged` never reaches here, because a tagged comment was kept. */
let decision = if let Some(shape) = &run.shape {
match shape {
@@ -452,9 +513,8 @@ fn item_of(
/// One comment, placed: which lines it covers and whether it opens a promise.
struct Placed {
- /// The bytes the comment covers. The start is the only thing about a
- /// comment that is unique: two of them share a line whenever one sits
- /// beside code.
+ /// The bytes the comment covers.
+ /// The start is the only thing about a comment that is unique: two of them share a line whenever one sits beside code.
start: usize,
end: usize,
first: usize,
@@ -490,7 +550,7 @@ fn place(lines: &[String], index: &crate::output::LineIndex, comment: &Comment)
last,
column,
tag,
- shape: comment.shape.clone(),
+ shape: comment.shape().cloned(),
beside,
kind: comment.kind,
})
@@ -498,10 +558,7 @@ fn place(lines: &[String], index: &crate::output::LineIndex, comment: &Comment)
/// A policy gentler than this run's that would keep a comment of this kind.
///
-/// `None` when no policy keeps it, which is every ordinary comment: the reader
-/// of one of those has a decision to make about the comment, and the reader of
-/// a documentation comment removed by `--policy all` has a decision to make
-/// about the run.
+/// `None` when no policy keeps it, which is every ordinary comment: the reader of one of those has a decision to make about the comment, and the reader of a documentation comment removed by `--policy all` has a decision to make about the run.
fn gentler_policy(kind: CommentKind, policy: Policy) -> Option {
let keeps = Policy::strongest_keeping(&[kind])?;
(keeps != policy && !policy.keeps(kind)).then_some(keeps)
@@ -516,11 +573,9 @@ fn body_of(line: &str, column: usize) -> String {
.to_owned()
}
-/// The promise a comment opens with, upper-cased, or `None` when it opens with
-/// prose.
+/// The promise a comment opens with, upper-cased, or `None` when it opens with prose.
///
-/// The tag has to end at a boundary: `TODO:` and `TODO(name)` are promises and
-/// `TODOs are tracked elsewhere` is a sentence about them.
+/// The tag has to end at a boundary: `TODO:` and `TODO(name)` are promises and `TODOs are tracked elsewhere` is a sentence about them.
fn promise_tag(body: &str) -> Option {
let word: String = body
.chars()
@@ -535,9 +590,8 @@ fn promise_tag(body: &str) -> Option {
/// Whether a comment's text reads as code rather than as prose.
///
-/// Deliberately timid. Calling prose "code" advises deleting something a reader
-/// wrote on purpose, so this only answers yes for text that ends the way a
-/// statement ends and does not end the way a sentence does.
+/// Deliberately timid.
+/// Calling prose "code" advises deleting something a reader wrote on purpose, so this only answers yes for text that ends the way a statement ends and does not end the way a sentence does.
fn reads_as_code(body: &str) -> bool {
let trimmed = body.trim_end();
trimmed.ends_with([';', '{', '}'])
@@ -547,10 +601,7 @@ fn reads_as_code(body: &str) -> bool {
/// Whether a line opens something a language would document.
///
-/// One list for every language, which is coarse and is the right kind of
-/// coarse: the question is only whether a doc comment would have somewhere to
-/// attach, and a word that opens a definition in one language is not a word
-/// that opens a statement in another.
+/// One list for every language, which is coarse and is the right kind of coarse: the question is only whether a doc comment would have somewhere to attach, and a word that opens a definition in one language is not a word that opens a statement in another.
fn opens_an_item(line: &str) -> bool {
const OPENERS: [&str; 16] = [
"fn ",
@@ -629,14 +680,11 @@ pub fn promote(lines: &[String], prefix: &str) -> Vec {
}
impl Item {
- /// The path and lines as every report here writes them, so a reader
- /// searching one can search the other.
+ /// The path and lines as every report here writes them, so a reader searching one can search the other.
///
- /// The column comes too when something other than whitespace is in front
- /// of the comment. That is exactly when a path and a line stop naming one
- /// finding — a second comment on a line always has the first in front of
- /// it — and the report was printing the two as one location twice. An
- /// indented comment is the only one on its line and does not need it.
+ /// The column comes too when something other than whitespace is in front of the comment.
+ /// That is exactly when a path and a line stop naming one finding — a second comment on a line always has the first in front of it — and the report was printing the two as one location twice.
+ /// An indented comment is the only one on its line and does not need it.
#[must_use]
pub fn where_it_is(&self) -> String {
let path = crate::output::sanitize_path(&self.path.to_string_lossy());
diff --git a/rust/ocomment/src/atomic.rs b/rust/ocomment/src/atomic.rs
index 446f09e..d4be4d9 100644
--- a/rust/ocomment/src/atomic.rs
+++ b/rust/ocomment/src/atomic.rs
@@ -46,8 +46,7 @@ pub fn apply_transaction(plans: Vec>) -> Result<()> {
let permissions = fs::metadata(&plan.path)?.permissions();
temporary.as_file().set_permissions(permissions)?;
temporary.as_file_mut().sync_all()?;
- /* NOTE: Holding every NamedTempFile handle until commit makes the
- * prepare-before-commit guarantee consume one descriptor per file.
+ /* NOTE: Holding every NamedTempFile handle until commit makes the prepare-before-commit guarantee consume one descriptor per file.
* TempPath retains cleanup ownership while closing the descriptor. */
let temporary = temporary.into_temp_path();
let name = plan
@@ -60,9 +59,7 @@ pub fn apply_transaction(plans: Vec>) -> Result<()> {
std::process::id()
));
if backup.symlink_metadata().is_ok() {
- /* INVARIANT: The journal holds the file as it was before the interrupted
- * run, so deleting it unread can be the loss the rollback existed
- * to prevent. */
+ /* INVARIANT: The journal holds the file as it was before the interrupted run, so deleting it unread can be the loss the rollback existed to prevent. */
bail!(
"rollback path {} already exists; a previous ocomment run may have been \
interrupted — inspect and delete it before retrying",
@@ -79,8 +76,7 @@ pub fn apply_transaction(plans: Vec>) -> Result<()> {
for index in 0..prepared.len() {
if let Err(error) = commit_one(&mut prepared[index]) {
let failed_path = prepared[index].plan.path.clone();
- /* INVARIANT: Include the failing item: a rename may have created its backup
- * before installing or syncing the replacement failed. */
+ /* INVARIANT: Include the failing item: a rename may have created its backup before installing or syncing the replacement failed. */
let rollback_error = rollback(&prepared[..=index]);
return Err(match rollback_error {
Ok(()) => anyhow!(
@@ -144,10 +140,8 @@ fn rollback(items: &[Prepared<'_>]) -> Result<()> {
Ok(())
}
-/// Refuse to turn a symbolic link into an ordinary file. Reads may opt into
-/// following links, but an in-place rewrite would replace the directory entry
-/// rather than atomically update its target, which is neither interpretation a
-/// caller can safely assume.
+/// Refuse to turn a symbolic link into an ordinary file.
+/// Reads may opt into following links, but an in-place rewrite would replace the directory entry rather than atomically update its target, which is neither interpretation a caller can safely assume.
fn reject_symlink(path: &Path, phase: &str) -> Result<()> {
let metadata = path
.symlink_metadata()
@@ -161,12 +155,19 @@ fn reject_symlink(path: &Path, phase: &str) -> Result<()> {
Ok(())
}
+/// Flush the directory entry, so a rename survives a power cut.
+///
+/// Split by system rather than guarded inside one body: the parameter is unused on the arm that does nothing, and a warning a platform emits and nobody reads is one more line of noise between a reader and the warning that matters.
+#[cfg(unix)]
fn sync_parent(path: &Path) -> Result<()> {
- #[cfg(unix)]
- {
- let directory = fs::File::open(parent_directory(path))?;
- directory.sync_all()?;
- }
+ let directory = fs::File::open(parent_directory(path))?;
+ directory.sync_all()?;
+ Ok(())
+}
+
+/// Windows has no directory handle to flush; the rename is durable on its own.
+#[cfg(not(unix))]
+fn sync_parent(_: &Path) -> Result<()> {
Ok(())
}
@@ -198,13 +199,10 @@ mod tests {
);
}
- /// A journal left over from an interrupted run is the only thing standing
- /// between the caller and a retry, and it holds the pre-run contents of a
- /// file. The refusal has to say both: what the file is, and that reading
- /// it before deleting it is the point.
+ /// A journal left over from an interrupted run is the only thing standing between the caller and a retry, and it holds the pre-run contents of a file.
+ /// The refusal has to say both: what the file is, and that reading it before deleting it is the point.
///
- /// The name carries this process's own id, so the test can plant exactly
- /// the journal the transaction is about to reach for.
+ /// The name carries this process's own id, so the test can plant exactly the journal the transaction is about to reach for.
#[test]
fn an_existing_rollback_journal_says_what_to_do_about_it() {
let directory = tempfile::tempdir().unwrap();
diff --git a/rust/ocomment/src/cli.rs b/rust/ocomment/src/cli.rs
index 7cec713..337c8c8 100644
--- a/rust/ocomment/src/cli.rs
+++ b/rust/ocomment/src/cli.rs
@@ -3,7 +3,7 @@ use crate::{
config, coverage, deadline, files, git, hook, interactive, lsp,
output::{
self, AnnotationLevel, Detail, Explanations, FileExplanation, Operation, OutputFormat,
- Presentation, ProcessedFile, ProcessedResult, RenderOptions, Verbosity,
+ Presentation, ProcessedFile, ProcessedResult, RenderOptions, Verbosity, Writes,
},
plugin, ratchet, selftest, tags,
trace::{TraceMode, trace_decisions, trace_discovery},
@@ -13,7 +13,7 @@ use anyhow::{Context, Result, bail, ensure};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use ocomment_core::{
- CommentKind, DeclarativeProfile, Dialect, Language, PreparedScanner, transform,
+ Action, CommentKind, DeclarativeProfile, Dialect, Language, PreparedScanner, transform,
};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
@@ -39,7 +39,8 @@ committed as one rollback-backed transaction.";
const AFTER_LONG_HELP: &str = "\
EXIT STATUS
0 Nothing removable was found and every requested change was applied.
- 1 Removable comments were reported, or a diff was printed.
+ 1 Removable comments were reported, a diff was printed, `--tidy` left a
+ removal for you, or a staged fix rewrote the index.
2 Invalid source, configuration, plugin, or I/O failure.
FILES
@@ -54,22 +55,23 @@ EXAMPLES
Check the current directory and report removable comments.
ocomment fix --policy all --layout compact src
Remove every comment under src and close the gaps it leaves.
+ ocomment fix --tidy --staged
+ Reflow what the style rules decide and leave every removal to you.
ocomment strip --language rust < before.rs > after.rs
Strip one file from standard input to standard output.
SEE ALSO
The complete schemas and guides are available in the OComment repository.";
-/// The roff sections `clap_mangen` cannot derive, carrying the same content as
-/// the `--help` epilogue above. A line that would start with `.` is escaped
-/// with `\&` so roff reads a file name as text rather than as a macro.
+/// The roff sections `clap_mangen` cannot derive, carrying the same content as the `--help` epilogue above.
+/// A line that would start with `.` is escaped with `\&` so roff reads a file name as text rather than as a macro.
const MAN_SECTIONS: &str = r#".SH EXIT STATUS
.TP
.B 0
Nothing removable was found and every requested change was applied.
.TP
.B 1
-Removable comments were reported, or a diff was printed.
+Removable comments were reported, a diff was printed, \fB--tidy\fR left a removal for you, or a staged fix rewrote the index.
.TP
.B 2
Invalid source, configuration, plugin, or I/O failure.
@@ -196,34 +198,28 @@ struct PolicyArgs {
/// Scan files another tool writes: lock files, recorded seeds, generated output.
#[arg(long, global = true)]
include_generated: bool,
- /// Fail when a file was passed over for one of these reasons, rather than
- /// noting it. With no reason given, the two that are holes rather than
- /// decisions: unknown-language and unreadable.
+ /// Fail when a file was passed over for one of these reasons, rather than noting it.
+ /// With no reason given, the two that are holes rather than decisions: unknown-language and unreadable.
#[arg(
long,
global = true,
value_name = "REASON",
value_enum,
value_delimiter = ',',
- /* NOTE: One comma-separated argument, and only after an `=`. A flag
- * whose value is optional and unanchored eats the path behind it:
- * `--deny-skipped .` read `.` as a reason, and the run then walked the
- * default target by luck rather than by request. The `=` is what lets
- * the bare flag and a path coexist on one command line, which is how
- * this flag is written in a CI file. */
+ /* NOTE: One comma-separated argument, and only after an `=`.
+ * A flag whose value is optional and unanchored eats the path behind it:
+ * `--deny-skipped .` read `.` as a reason, and the run then walked the default target by luck rather than by request.
+ * The `=` is what lets the bare flag and a path coexist on one command line, which is how this flag is written in a CI file. */
require_equals = true,
num_args = 0..=1,
default_missing_value = "unknown-language,unreadable"
)]
deny_skipped: Option>,
/// Edit a file that failed to scan, outside the bytes the failure covers.
- /// What the scanner calls a comment inside them is a guess: the code under
- /// an unterminated block opener is reported as part of it and is not a
- /// comment.
+ /// What the scanner calls a comment inside them is a guess: the code under an unterminated block opener is reported as part of it and is not a comment.
#[arg(long, global = true)]
force_invalid: bool,
- /// Remove protected comments: shebangs, encoding lines, and the
- /// directives the language or its build reads.
+ /// Remove protected comments: shebangs, encoding lines, and the directives the language or its build reads.
#[arg(long, global = true)]
force_protected: bool,
}
@@ -332,9 +328,7 @@ enum AutoChoice {
/// Whether a run records what it did, and in which spelling.
///
-/// Off by default because the trace is for the run you are investigating
-/// rather than the run you are doing, and a diagnostic nobody asked for is
-/// noise on the stream the summary already uses.
+/// Off by default because the trace is for the run you are investigating rather than the run you are doing, and a diagnostic nobody asked for is noise on the stream the summary already uses.
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum TraceChoice {
/// Record nothing, and collect nothing to record.
@@ -427,9 +421,7 @@ struct GitArgs {
#[derive(Args)]
struct FixArgs {
- /* NOTE: `fix` rewrites files in place and refuses the `-` that stands for
- * standard input, so its PATH list is not the one every other command
- * takes and does not borrow that command's help line. */
+ /* NOTE: `fix` rewrites files in place and refuses the `-` that stands for standard input, so its PATH list is not the one every other command takes and does not borrow that command's help line. */
/// Files or directories to rewrite (default: current directory).
#[arg(value_name = "PATH")]
paths: Vec,
@@ -439,16 +431,30 @@ struct FixArgs {
/// Print the patch `fix` would apply and write nothing.
#[arg(long)]
dry_run: bool,
+ /// Apply what the style rules rewrote and leave every removal to you.
+ ///
+ /// The removals are still reported and the run still exits 1 for them; what changes is that none of them reaches the file.
+ /// This is the half a machine can finish on its own, which is what makes it the half a commit hook may run unattended.
+ #[arg(long, conflicts_with = "interactive")]
+ tidy: bool,
/// Ask about each comment in turn and remove only the accepted ones.
///
- /// The index has no working-tree line to show a hunk from, `--dry-run`
- /// writes nothing whatever the answers were, and `-q` asks for a run with
- /// no commentary at all. None of the three can also be a conversation.
+ /// The index has no working-tree line to show a hunk from, `--dry-run` writes nothing whatever the answers were, and `-q` asks for a run with no commentary at all.
+ /// None of the three can also be a conversation.
#[arg(short = 'i', long, conflicts_with_all = ["staged", "dry_run", "quiet"])]
interactive: bool,
}
impl FixArgs {
+ /// Which half of what the run found it is being asked to write.
+ const fn writes(&self) -> Writes {
+ if self.tidy {
+ Writes::RewritesOnly
+ } else {
+ Writes::Everything
+ }
+ }
+
/// The same targets in the shape every other command hands to the run.
fn target(self) -> TargetArgs {
TargetArgs {
@@ -470,7 +476,15 @@ struct InitArgs {
/// Which starter file to write.
#[arg(value_enum, default_value_t)]
kind: InitKind,
+ /// For the Lefthook hook, run `fix --tidy` instead of `check`.
+ ///
+ /// The hook writes what the style rules settle and leaves every removal reported and unapplied, which is the shape a gate on every commit wants.
+ #[arg(long, conflicts_with = "fix")]
+ tidy: bool,
/// For the Lefthook hook, run `fix` instead of `check`.
+ ///
+ /// The removals too, including the comments above them that were worth keeping.
+ /// `--tidy` is the one that writes nothing a reader would have wanted back.
#[arg(long)]
fix: bool,
/// Replace the file if it already exists.
@@ -551,8 +565,8 @@ enum PluginCommand {
},
}
-/// The `fix` variants that change what a run does with what it found. Every
-/// other command runs with neither.
+/// The `fix` variants that change what a run does with what it found.
+/// Every other command runs with neither.
#[derive(Clone, Copy, Default)]
struct RunFlags {
/// The run produces the patch `fix` would apply and writes nothing.
@@ -579,11 +593,8 @@ impl RunFlags {
pub fn run() -> Result {
let cli = Cli::parse();
let common = cli.common;
- /* NOTE: One knob, set once, and every parallel part of the run reads it
- * from here -- the file walk asks `rayon::current_num_threads()` rather
- * than carrying a count of its own. Thread count was previously settable
- * only through `RAYON_NUM_THREADS`, which is an implementation detail
- * leaking as a user interface and was documented nowhere. */
+ /* NOTE: One knob, set once, and every parallel part of the run reads it from here -- the file walk asks `rayon::current_num_threads()` rather than carrying a count of its own.
+ * Thread count was previously settable only through `RAYON_NUM_THREADS`, which is an implementation detail leaking as a user interface and was documented nowhere. */
if let Some(jobs) = common.output.jobs {
rayon::ThreadPoolBuilder::new()
.num_threads(jobs)
@@ -591,9 +602,7 @@ pub fn run() -> Result {
.context("cannot use that many threads")?;
}
/* NOTE: `human`, `json` and `jsonl` all have somewhere to put a reason.
- * SARIF and the GitHub workflow commands do not -- one is a fixed schema
- * and the other is one line per annotation -- so the combination is
- * refused rather than quietly doing nothing. */
+ * SARIF and the GitHub workflow commands do not -- one is a fixed schema and the other is one line per annotation -- so the combination is refused rather than quietly doing nothing. */
if common.output.explain
&& !matches!(
common.output.format,
@@ -602,13 +611,9 @@ pub fn run() -> Result {
{
bail!("--explain is only available with --format human, review, json or jsonl");
}
- /* NOTE: The flag annotates a report of comments, and only `check`, `scan` and
- * the implicit command write one: `fix` reports the files it rewrote,
- * `diff` writes a patch, `strip` writes the stripped source, and the rest
- * of the commands answer a question that is not about comments at all.
- * `--explain` is global, so it is named as an allow-list — a command added
- * later has to opt in — and everything else is refused rather than
- * quietly doing nothing. */
+ /* NOTE: The flag annotates a report of comments, and only `check`, `scan` and the implicit command write one: `fix` reports the files it rewrote,
+ * `diff` writes a patch, `strip` writes the stripped source, and the rest of the commands answer a question that is not about comments at all.
+ * `--explain` is global, so it is named as an allow-list — a command added later has to opt in — and everything else is refused rather than quietly doing nothing. */
if common.output.explain
&& !matches!(
cli.command,
@@ -628,37 +633,40 @@ pub fn run() -> Result {
RunFlags::NONE,
),
Some(Command::Check(args)) => run_target(Operation::Check, args, &common, RunFlags::NONE),
- /* NOTE: `--dry-run` runs the diff and reports it in fix vocabulary: the two
- * commands must agree on the patch, so only the wording differs. */
+ /* NOTE: `--dry-run` runs the diff and reports it in fix vocabulary: the two commands must agree on the patch, so only the wording differs. */
Some(Command::Fix(args)) if args.dry_run => {
- run_target(Operation::Diff, args.target(), &common, RunFlags::DRY_RUN)
+ let operation = Operation::Diff(args.writes());
+ run_target(operation, args.target(), &common, RunFlags::DRY_RUN)
}
Some(Command::Fix(args)) if args.interactive => {
- /* NOTE: The prompt is prose on a terminal and the answers come back the
- * same way; a machine format has nowhere to put either, so the
- * combination is refused rather than one of the two flags being
- * quietly dropped. It is refused before the terminal is looked at,
+ /* NOTE: The prompt is prose on a terminal and the answers come back the same way; a machine format has nowhere to put either, so the combination is refused rather than one of the two flags being quietly dropped.
+ * It is refused before the terminal is looked at,
* because the pair is wrong however the run was started. */
if !common.output.format.for_a_person() {
bail!("--interactive is only available with --format human or review");
}
- /* NOTE: Without somebody there to answer, the questions would be read out
- * of whatever the pipe happened to carry and files would be
- * rewritten from it. Nothing is scanned, let alone written. */
+ /* NOTE: Without somebody there to answer, the questions would be read out of whatever the pipe happened to carry and files would be rewritten from it.
+ * Nothing is scanned, let alone written. */
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
bail!("--interactive needs a terminal; run without -i or use `ocomment diff`");
}
run_target(
- Operation::Fix,
+ Operation::Fix(args.writes()),
args.target(),
&common,
RunFlags::INTERACTIVE,
)
}
Some(Command::Fix(args)) => {
- run_target(Operation::Fix, args.target(), &common, RunFlags::NONE)
+ let operation = Operation::Fix(args.writes());
+ run_target(operation, args.target(), &common, RunFlags::NONE)
}
- Some(Command::Diff(args)) => run_target(Operation::Diff, args, &common, RunFlags::NONE),
+ Some(Command::Diff(args)) => run_target(
+ Operation::Diff(Writes::Everything),
+ args,
+ &common,
+ RunFlags::NONE,
+ ),
Some(Command::Scan(args)) => run_target(Operation::Scan, args, &common, RunFlags::NONE),
Some(Command::Strip) => run_strip(&common),
Some(Command::Lsp) => lsp::run(common.config.as_deref()),
@@ -678,15 +686,11 @@ pub fn run() -> Result {
}
}
-/// Scan `bytes` the way `file` would be scanned: through its plugin, through
-/// its declarative profile, or through the built-in scanner for its language.
+/// Scan `bytes` the way `file` would be scanned: through its plugin, through its declarative profile, or through the built-in scanner for its language.
///
-/// The three-way dispatch is here once. It was written out at each of the
-/// places that needed it, and the bytes are not always the file's own — a
-/// rewrite is verified by rescanning what it produced, and a hook judges bytes
-/// that are not on the disk at all — so each copy had to remember to route the
-/// same way. One that forgot would check a plugin's file with the wrong
-/// scanner and report on a language nobody selected.
+/// The three-way dispatch is here once.
+/// It was written out at each of the places that needed it, and the bytes are not always the file's own — a rewrite is verified by rescanning what it produced, and a hook judges bytes that are not on the disk at all — so each copy had to remember to route the same way.
+/// One that forgot would check a plugin's file with the wrong scanner and report on a language nobody selected.
pub(crate) fn scan_bytes(
bytes: &[u8],
file: &files::SourceFile,
@@ -734,23 +738,19 @@ fn run_target(
}
let progress = progress_enabled(common);
let staged = args.git.staged || resolved.config.git.staged;
- if operation == Operation::Fix && !staged && args.paths.is_empty() {
+ if operation.writes() && !staged && args.paths.is_empty() {
note_fix_scope(&resolved, common)?;
}
- /* NOTE: `git` names a staged path relative to the repository root rather than to
- * the working directory, so a staged run measures its paths against the
- * root from there. Every other run measures them from where it was typed. */
+ /* NOTE: `git` names a staged path relative to the repository root rather than to the working directory, so a staged run measures its paths against the root from there.
+ * Every other run measures them from where it was typed. */
if staged && let Some(repository) = config::locate_repository(&resolved.cwd) {
resolved.cwd = repository;
}
- /* NOTE: `fix --dry-run` writes nothing, but it is still the command whose job is
- * to rewrite files in place, and standard input cannot be rewritten. */
- let rewrites = operation == Operation::Fix || flags.dry_run;
+ /* NOTE: `fix --dry-run` writes nothing, but it is still the command whose job is to rewrite files in place, and standard input cannot be rewritten. */
+ let rewrites = operation.writes() || flags.dry_run;
let (paths, stdin) = target_paths(&args.paths, rewrites, staged)?;
if staged {
- /* NOTE: A staged run reports index blobs through a path that carries no
- * policy trace, so it says so rather than printing a listing with
- * every explanation quietly missing. */
+ /* NOTE: A staged run reports index blobs through a path that carries no policy trace, so it says so rather than printing a listing with every explanation quietly missing. */
if common.output.explain {
bail!(
"--explain is not available with --staged; explain the working tree with \
@@ -778,36 +778,29 @@ fn run_target(
Some(base) => base_targets(base, &paths, &mut resolved, common, verbosity)?,
None => read_targets(&paths, stdin, &resolved, common)?,
};
- /* NOTE: One reading of the clock for the whole run, so that two files
- * judged a second apart cannot disagree about what day it is. */
+ /* NOTE: One reading of the clock for the whole run, so that two files judged a second apart cannot disagree about what day it is. */
let now = std::time::SystemTime::now();
let total = discovery.files.len();
let counter = Progress::default();
let trace_mode = TraceMode::from(common.output.trace);
let explain = common.output.explain;
- /* NOTE: Two different questions about the same material. `--explain` asks
- * for it to be printed under each finding on standard output; the trace
- * asks for it to name the rule in each recorded decision on standard
- * error. Either one needs it collected, and neither pays for it alone, but
- * asking for a trace must not start annotating the product. */
- /* NOTE: And the agent format, whose per-finding verb is the rule that
- * decided the comment: telling a reader to delete one that only had to
- * move is wrong advice however correct the verdict was. */
+ /* NOTE: Two different questions about the same material.
+ * `--explain` asks for it to be printed under each finding on standard output; the trace asks for it to name the rule in each recorded decision on standard error.
+ * Either one needs it collected, and neither pays for it alone, but asking for a trace must not start annotating the product. */
+ /* NOTE: And the agent format, whose per-finding verb is the rule that decided the comment: telling a reader to delete one that only had to move is wrong advice however correct the verdict was. */
let needs_explanations =
explain || trace_mode.is_on() || common.output.format == OutputFormat::Agent;
- let materialize_output = operation == Operation::Fix
+ let materialize_output = operation.writes()
|| flags.interactive
- || (operation == Operation::Diff && common.output.format.for_a_person());
- /* NOTE: Built only for a run that will print it. It is one segment per
- * unchanged run of bytes, which is the largest thing a report carries. */
+ || (matches!(operation, Operation::Diff(_)) && common.output.format.for_a_person());
+ /* NOTE: Built only for a run that will print it.
+ * It is one segment per unchanged run of bytes, which is the largest thing a report carries. */
let materialize_source_map = common.output.source_map
&& matches!(
common.output.format,
OutputFormat::Json | OutputFormat::Jsonl
);
- /* NOTE: The JSON formats carry the edit list whether or not they carry the
- * map, so they plan either way: `edits` is part of the report and the map
- * is the thing `--source-map` is about. */
+ /* NOTE: The JSON formats carry the edit list whether or not they carry the map, so they plan either way: `edits` is part of the report and the map is the thing `--source-map` is about. */
let needs_plan = materialize_output
|| materialize_source_map
|| matches!(
@@ -817,9 +810,8 @@ fn run_target(
{
let stderr = io::stderr();
let mut sink = stderr.lock();
- /* NOTE: First, because every later event is judged against the settings
- * this one names, and a reader who is about to ask "why did it do
- * that?" is usually asking about a layer they forgot was there. */
+ /* NOTE: First, because every later event is judged against the settings this one names, and a reader who is about to ask "why did it do that?"
+ * is usually asking about a layer they forgot was there. */
let layers = config_trace(&resolved.trace);
crate::trace::emit(
&mut sink,
@@ -843,8 +835,7 @@ fn run_target(
.files
.into_par_iter()
.map(|file| {
- /* NOTE: Only an explaining run pays for the trace; every other one takes
- * the hot path it always took. */
+ /* NOTE: Only an explaining run pays for the trace; every other one takes the hot path it always took. */
let trace = if needs_explanations {
let (traced_language, traced_options, trace) =
resolved.for_path_traced(&file.path, file.language, file.dialect)?;
@@ -860,17 +851,12 @@ fn run_target(
let scanner = scanners
.get(&options.scan)
.expect("every discovered policy was prepared");
- /* NOTE: Recorded as the scan is about to run with them, `--language` and
- * `--dialect` included, so an explanation accounts for the run that
- * actually happened. */
+ /* NOTE: Recorded as the scan is about to run with them, `--language` and `--dialect` included, so an explanation accounts for the run that actually happened. */
let material = trace.map(|trace| FileExplanation {
options: options.scan.clone(),
trace,
});
- /* NOTE: Scanned once and planned from what the scan decided, rather
- * than planned by a call that scans again inside itself: a
- * deadline is settled here, between the two, and a plan built from
- * a fresh scan would not have heard about it. */
+ /* NOTE: Scanned once and planned from what the scan decided, rather than planned by a call that scans again inside itself: a deadline is settled here, between the two, and a plan built from a fresh scan would not have heard about it. */
let mut report = scan_bytes(&file.source, &file, scanner, &plugin_host)?;
let overdue = deadline::apply(
&resolved.root,
@@ -881,37 +867,41 @@ fn run_target(
now,
)?;
let result = if needs_plan {
- let plan = ocomment_core::plan_report(
- &file.source,
- report,
- options.layout,
- options.scan.force_invalid,
- );
+ /* NOTE: A tidying run plans one axis and reports both.
+ * The removals stay in the report so that the run still names them and still exits 1 for them; what they do not get is an edit. */
+ let plan = match operation.half() {
+ Some(Writes::RewritesOnly) => ocomment_core::plan_rewrites(
+ &file.source,
+ report,
+ options.scan.force_invalid,
+ ),
+ Some(Writes::Everything) | None => ocomment_core::plan_report(
+ &file.source,
+ report,
+ options.layout,
+ options.scan.force_invalid,
+ ),
+ };
let result = ProcessedResult::plan(
&file.source,
plan,
materialize_output,
materialize_source_map,
);
- /* NOTE: Only a run that is going to write checks what it
- * would write; `diff` and `check` show a person the same bytes.
- * A file already reported broken is exempt and has to be, since
- * its result cannot scan cleanly either. The flag is not the
- * exemption: a valid file in a forced run is still checked. */
- if operation == Operation::Fix
+ /* NOTE: Only a run that is going to write checks what it would write; `diff` and `check` show a person the same bytes.
+ * A file already reported broken is exempt and has to be, since its result cannot scan cleanly either.
+ * The flag is not the exemption: a valid file in a forced run is still checked. */
+ if operation.writes()
&& result.changed()
&& (result.report.valid || !options.scan.force_invalid)
{
let rescan = scan_bytes(result.output(), &file, scanner, &plugin_host)?;
- verify_rewrite(&file.path, &rescan)?;
+ verify_rewrite(&file.path, &rescan, operation)?;
}
result
} else {
- let changed = (report.valid || scanner.options().force_invalid)
- && report
- .comments
- .iter()
- .any(|comment| comment.disposition.is_remove());
+ let changed =
+ (report.valid || scanner.options().force_invalid) && report.changes_bytes();
ProcessedResult::report(report, changed)
};
if progress {
@@ -933,9 +923,7 @@ fn run_target(
if progress {
counter.clear();
}
- /* NOTE: The explanations travel beside the files rather than inside them: a
- * staged run reports the same `ProcessedFile` and has no trace to put in
- * one, and the path is what the renderer looks each file up by anyway. */
+ /* NOTE: The explanations travel beside the files rather than inside them: a staged run reports the same `ProcessedFile` and has no trace to put in one, and the path is what the renderer looks each file up by anyway. */
let processed = processed?;
let mut explanations = Explanations::new();
let mut files = Vec::with_capacity(processed.len());
@@ -952,14 +940,12 @@ fn run_target(
let io_invalid = discovery.skipped.iter().any(|item| item.error);
let invalid = report_invalid || io_invalid;
let may_fix = !io_invalid && (!report_invalid || resolved.config.policy.force_invalid);
- /* NOTE: An interactive run replaces the whole `fix` report: what it wrote is the
- * answers it was given, and the ordinary summary counts what the run
- * *could* have removed. A run the invalid-file gate has already stopped
- * falls through instead, so that report says why nothing was written. */
+ /* NOTE: An interactive run replaces the whole `fix` report: what it wrote is the answers it was given, and the ordinary summary counts what the run *could* have removed.
+ * A run the invalid-file gate has already stopped falls through instead, so that report says why nothing was written. */
if flags.interactive && may_fix {
return run_interactive(&files, &discovery.skipped, invalid, presentation, verbosity);
}
- let applied = operation == Operation::Fix && may_fix;
+ let applied = operation.writes() && may_fix;
if applied {
let plans = files
.iter()
@@ -996,17 +982,13 @@ fn run_target(
},
&explanations,
)?;
- /* NOTE: Written after the product and before the verdict, so a file that
- * exists is a run that finished. `-q` does not reach it: a caller who
- * named a path for the counts asked for the counts. */
+ /* NOTE: Written after the product and before the verdict, so a file that exists is a run that finished.
+ * `-q` does not reach it: a caller who named a path for the counts asked for the counts. */
if let Some(path) = &common.output.summary {
output::write_summary(path, &files, &discovery.skipped, operation)?;
}
- /* NOTE: Said on its own line rather than folded into the summary: a
- * deadline that passed is not a statistic about the run, it is a thing
- * somebody said they would do. Human runs only, like every other note --
- * a machine format keeps standard error empty, and the agent report
- * already carries the age on the finding's own line. */
+ /* NOTE: Said on its own line rather than folded into the summary: a deadline that passed is not a statistic about the run, it is a thing somebody said they would do.
+ * Human runs only, like every other note -- a machine format keeps standard error empty, and the agent report already carries the age on the finding's own line. */
if common.output.format.for_a_person()
&& let Some(line) = overdue.note()
{
@@ -1014,10 +996,7 @@ fn run_target(
let mut sink = stderr.lock();
output::note(&mut sink, verbosity, Detail::Normal, &line)?;
}
- /* NOTE: Asked of a walk and not of a list: only a walk means "everything
- * under here", and `--base` does not -- it is the caller saying what they
- * changed, so a pattern with nothing to match in those files has not
- * thereby failed. */
+ /* NOTE: Asked of a walk and not of a list: only a walk means "everything under here", and `--base` does not -- it is the caller saying what they changed, so a pattern with nothing to match in those files has not thereby failed. */
let walked = !stdin
&& args.git.base.is_none()
&& (paths.is_empty() || paths.iter().any(|path| path.is_dir()));
@@ -1028,10 +1007,8 @@ fn run_target(
Dialect::Standard,
)?;
output::report_unused_settings(&files, &root_options.scan, &root_trace, verbosity)?;
- /* NOTE: Every path the walk reached, skips included. A file the walk
- * passed over is still a file the glob was written for, and calling
- * the glob unused because its language has no scanner here would send
- * a reader to fix the wrong line. */
+ /* NOTE: Every path the walk reached, skips included.
+ * A file the walk passed over is still a file the glob was written for, and calling the glob unused because its language has no scanner here would send a reader to fix the wrong line. */
let reached: Vec<&std::path::Path> = files
.iter()
.map(|file| file.path.as_path())
@@ -1046,66 +1023,71 @@ fn run_target(
if invalid {
return Ok(2);
}
- /* NOTE: A skip the caller refuses ranks with a finding rather than with a
- * failure: the run worked, and what it found is a file the gate was meant
- * to cover and did not. Exit 2 stays reserved for a run that could not do
- * its job at all. */
+ /* NOTE: A skip the caller refuses ranks with a finding rather than with a failure: the run worked, and what it found is a file the gate was meant to cover and did not.
+ * Exit 2 stays reserved for a run that could not do its job at all. */
let denied = deny_exit_code(
&discovery.skipped,
common.policy.deny_skipped.as_deref(),
verbosity,
)?;
- match operation {
- Operation::Check | Operation::Diff if output::changed(&files) => Ok(1),
- Operation::Check | Operation::Scan | Operation::Diff | Operation::Fix => Ok(denied),
- }
+ /* NOTE: A working-tree run rewrites files the author can still look at before committing them, so it has no index to have changed under anybody. */
+ Ok(output::exit_code(operation, &files, false).max(denied))
}
/// Re-scan what a rewrite produced, and refuse it if it is wrong.
///
-/// The tool's central claim is that a removal changes what a file says and not
-/// what it does, and until now that claim was asserted. It cannot be proved
-/// without a parser for every language -- which would cost the property that
-/// makes this one binary that runs anywhere -- but the failures that are
-/// actually reachable can be caught by asking the scanner about its own
-/// output:
+/// The tool's central claim is that a removal changes what a file says and not what it does, and until now that claim was asserted.
+/// It cannot be proved without a parser for every language -- which would cost the property that makes this one binary that runs anywhere -- but the failures that are actually reachable can be caught by asking the scanner about its own output:
///
/// - the result still lexes, so a removal did not open or close a string;
-/// - nothing removable is left, so the rewrite reached a fixed point.
+/// - nothing the run planned for is left, so the rewrite reached a fixed point.
///
-/// Idempotence is the sharper of the two: it is what catches a removal that
-/// made a new comment token out of the bytes around the hole.
+/// Idempotence is the sharper of the two: it is what catches a removal that made a new comment token out of the bytes around the hole.
+/// Which verdicts count as "left" is the half of the report the run actually planned from.
+/// A tidying run leaves every removal where it found it and has to, so asking it for a report with none would fail every time it was asked to do exactly what it was told.
///
/// This runs before anything reaches the disk, so a failure costs nothing.
-/// The transaction is still there for an I/O failure part-way through; this is
-/// for the failure a transaction cannot help with, which is having computed
-/// the wrong bytes in the first place.
-fn verify_rewrite(path: &std::path::Path, rewritten: &ocomment_core::ScanReport) -> Result<()> {
+/// The transaction is still there for an I/O failure part-way through; this is for the failure a transaction cannot help with, which is having computed the wrong bytes in the first place.
+fn verify_rewrite(
+ path: &std::path::Path,
+ rewritten: &ocomment_core::ScanReport,
+ operation: Operation,
+) -> Result<()> {
let path = output::sanitize_path(&path.to_string_lossy());
ensure!(
rewritten.valid,
"{path}: the rewrite does not scan cleanly, so nothing was written. \
This is a defect in OComment; the file is unchanged."
);
- let left = rewritten
- .comments
- .iter()
- .filter(|comment| comment.disposition.is_remove())
- .count();
+ let (left, subject) = if operation.half() == Some(Writes::RewritesOnly) {
+ let comments = rewritten
+ .comments
+ .iter()
+ .filter(|comment| comment.action() == Action::Rewrite)
+ .count();
+ (
+ comments + rewritten.runs.len(),
+ "comment(s) left to rewrite",
+ )
+ } else {
+ let comments = rewritten
+ .comments
+ .iter()
+ .filter(|comment| comment.action().removes())
+ .count();
+ (comments, "removable comment(s)")
+ };
ensure!(
left == 0,
- "{path}: the rewrite still holds {left} removable comment(s), so nothing \
+ "{path}: the rewrite still holds {left} {subject}, so nothing \
was written. This is a defect in OComment; the file is unchanged."
);
Ok(())
}
-/// Ask about each comment this run would remove, write the accepted removals
-/// through the same transaction a plain `fix` uses, and report what the answers
-/// came to.
+/// Ask about each comment this run would remove, write the accepted removals through the same transaction a plain `fix` uses, and report what the answers came to.
///
-/// A clean abort is not a failure of the run: `x` is the answer for a fix that
-/// should never have started, and it exits 0 having touched nothing.
+/// A clean abort is not a failure of the run: `x` is the answer for a fix that should never have started, and it exits 0 having touched nothing.
fn run_interactive(
files: &[ProcessedFile],
skipped: &[files::SkippedFile],
@@ -1119,9 +1101,7 @@ fn run_interactive(
let mut answers = stdin.lock();
let mut questions = output::stdout();
let selection = interactive::select(files, &mut answers, &mut questions, &presentation)?;
- /* NOTE: The conversation is on standard output and the verdict that follows
- * is on standard error; a terminal sees both, so the buffer is emptied
- * first to keep them in the order they were written. */
+ /* NOTE: The conversation is on standard output and the verdict that follows is on standard error; a terminal sees both, so the buffer is emptied first to keep them in the order they were written. */
output::finish(&mut questions)?;
selection
};
@@ -1147,9 +1127,7 @@ fn run_interactive(
)?;
return Ok(0);
}
- /* NOTE: A skipped path can be the whole answer to a run that was never asked a
- * question, so the one command that writes no report of its own still says
- * why it passed a file over. */
+ /* NOTE: A skipped path can be the whole answer to a run that was never asked a question, so the one command that writes no report of its own still says why it passed a file over. */
for line in output::skip_lines(skipped, presentation, verbosity) {
output::note(&mut report, verbosity, Detail::Normal, &line)?;
}
@@ -1165,15 +1143,13 @@ fn run_interactive(
/// How the PATH list names standard input.
const STDIN_ARGUMENT: &str = "-";
-/// Split the requested targets into ordinary paths and the `-` that stands for
-/// standard input, refusing the combinations that cannot be honoured.
+/// Split the requested targets into ordinary paths and the `-` that stands for standard input, refusing the combinations that cannot be honoured.
fn target_paths(paths: &[PathBuf], rewrites: bool, staged: bool) -> Result<(Vec, bool)> {
let is_stdin = |path: &PathBuf| path.as_os_str() == STDIN_ARGUMENT;
match paths.iter().filter(|path| is_stdin(path)).count() {
0 => return Ok((paths.to_vec(), false)),
1 => {}
- /* NOTE: A pipe is consumed once; a second `-` would silently report the same
- * bytes twice or nothing at all. */
+ /* NOTE: A pipe is consumed once; a second `-` would silently report the same bytes twice or nothing at all. */
_ => bail!("cannot read standard input twice; `-` may appear only once"),
}
if rewrites {
@@ -1192,18 +1168,13 @@ fn target_paths(paths: &[PathBuf], rewrites: bool, staged: bool) -> Result<(Vec<
))
}
-/// Discover the working-tree files a branch changed, under the limits a walk
-/// applies.
+/// Discover the working-tree files a branch changed, under the limits a walk applies.
///
/// A caller could already write `ocomment check $(git diff --name-only ...)`,
-/// and that run means something slightly different: a path named on the
-/// command line is the caller saying *this one*, so it lifts the hidden-file
-/// and size rules. `--base` is the caller saying *what I changed*, which is a
-/// walk narrowed rather than a list, so the limits stay on and a generated
-/// file the branch touched is still passed over.
+/// and that run means something slightly different: a path named on the command line is the caller saying *this one*, so it lifts the hidden-file and size rules.
+/// `--base` is the caller saying *what I changed*, which is a walk narrowed rather than a list, so the limits stay on and a generated file the branch touched is still passed over.
///
-/// The paths a caller *also* named narrow it further: `--base main src` is the
-/// files under `src` that the branch changed.
+/// The paths a caller *also* named narrow it further: `--base main src` is the files under `src` that the branch changed.
fn base_targets(
base: &str,
paths: &[PathBuf],
@@ -1212,9 +1183,7 @@ fn base_targets(
verbosity: Verbosity,
) -> Result {
let (root, changed) = git::changed_since(base)?;
- /* NOTE: `git` names a changed path from the repository root, so the globs
- * and the report are measured from there too -- as a staged run already
- * does, and for the same reason. */
+ /* NOTE: `git` names a changed path from the repository root, so the globs and the report are measured from there too -- as a staged run already does, and for the same reason. */
resolved.cwd = root;
let selected: Vec = if paths.is_empty() {
changed
@@ -1234,10 +1203,8 @@ fn base_targets(
})
.collect()
};
- /* NOTE: A branch that changed nothing this run can read is a run that will
- * report nothing and exit 0, which reads exactly like a clean branch. The
- * same footgun `--staged` has, and it is said out loud for the same
- * reason. */
+ /* NOTE: A branch that changed nothing this run can read is a run that will report nothing and exit 0, which reads exactly like a clean branch.
+ * The same footgun `--staged` has, and it is said out loud for the same reason. */
if selected.is_empty() {
let stderr = io::stderr();
let mut sink = stderr.lock();
@@ -1255,9 +1222,7 @@ fn base_targets(
files::discover_workspace_with(&selected, resolved, common.language(), common.dialect())
}
-/// Discover the named paths and, when `-` was among them, fold the bytes read
-/// from standard input in as one more file so a piped run takes exactly the
-/// same reporting path as a walked one.
+/// Discover the named paths and, when `-` was among them, fold the bytes read from standard input in as one more file so a piped run takes exactly the same reporting path as a walked one.
fn read_targets(
paths: &[PathBuf],
stdin: bool,
@@ -1267,8 +1232,7 @@ fn read_targets(
if !stdin {
return files::discover(paths, resolved, common.language(), common.dialect());
}
- /* NOTE: An empty list means "the whole repository" only when no target was named
- * at all; `-` on its own is a target, and walking would ignore it. */
+ /* NOTE: An empty list means "the whole repository" only when no target was named at all; `-` on its own is a target, and walking would ignore it. */
let mut discovery = if paths.is_empty() {
files::Discovery::default()
} else {
@@ -1281,8 +1245,7 @@ fn read_targets(
.context("cannot read standard input")?;
match files::stdin_source(bytes, resolved, common.language(), common.dialect()) {
Ok(file) => discovery.files.push(file),
- /* NOTE: A skip that cannot be reported per file — nothing was named to skip
- * — is a usage error the run must not swallow. */
+ /* NOTE: A skip that cannot be reported per file — nothing was named to skip — is a usage error the run must not swallow. */
Err(skipped) if skipped.error => {
let reason = skipped.reason;
bail!("{reason}")
@@ -1300,15 +1263,10 @@ fn read_targets(
/// Strip one file from standard input to standard output.
///
-/// The product is the stripped source itself — the bytes of the file, not a
-/// report about it — so there is no report for a machine format to encode.
-/// Writing the source under `--format sarif` would answer with something that
-/// is not SARIF, and wrapping it in one of the schemas would answer with
-/// something that is not the file, so the flag is refused the way
-/// `ocomment languages` refuses the formats that carry no language table.
+/// The product is the stripped source itself — the bytes of the file, not a report about it — so there is no report for a machine format to encode.
+/// Writing the source under `--format sarif` would answer with something that is not SARIF, and wrapping it in one of the schemas would answer with something that is not the file, so the flag is refused the way `ocomment languages` refuses the formats that carry no language table.
fn run_strip(common: &CommonArgs) -> Result {
- /* NOTE: `strip` writes bytes rather than a report, so the two formats that
- * differ only in how a report is laid out are the same thing here. */
+ /* NOTE: `strip` writes bytes rather than a report, so the two formats that differ only in how a report is laid out are the same thing here. */
ensure!(
common.output.format.for_a_person(),
"`ocomment strip` is only available with --format human or review"
@@ -1359,9 +1317,7 @@ fn run_strip(common: &CommonArgs) -> Result {
Ok(if result.report.valid { 0 } else { 2 })
}
-/// Layer the command line over the merged configuration, noting what it
-/// overrode so `--explain` can name the flag rather than a file that never
-/// mentioned the setting.
+/// Layer the command line over the merged configuration, noting what it overrode so `--explain` can name the flag rather than a file that never mentioned the setting.
pub(crate) fn apply_cli_overrides(resolved: &mut config::ResolvedConfig, common: &CommonArgs) {
let policy = &common.policy;
let config = &mut resolved.config;
@@ -1377,8 +1333,7 @@ pub(crate) fn apply_cli_overrides(resolved: &mut config::ResolvedConfig, common:
overrides.layout = true;
}
if !policy.keep_kind.is_empty() {
- /* NOTE: The flag adds to the configured list rather than replacing it, so
- * the boundary is what tells the two apart afterwards. */
+ /* NOTE: The flag adds to the configured list rather than replacing it, so the boundary is what tells the two apart afterwards. */
overrides.keep_kind_from = Some(config.policy.keep_kind.len());
config
.policy
@@ -1398,18 +1353,16 @@ pub(crate) fn apply_cli_overrides(resolved: &mut config::ResolvedConfig, common:
if policy.force_protected {
config.policy.force_protected = true;
}
- /* NOTE: A `[files]` key set from the policy flags, because that is where the
- * flag lives on the command line. The setting itself belongs to discovery:
- * it decides which files are read at all, not what is decided about the
- * comments in them. */
+ /* NOTE: A `[files]` key set from the policy flags, because that is where the flag lives on the command line.
+ * The setting itself belongs to discovery:
+ * it decides which files are read at all, not what is decided about the comments in them. */
if policy.include_generated {
config.files.include_generated = true;
}
}
-/// The apostrophe definition `roff` writes at the top of every fragment it
-/// renders. A page needs it once, so it is stripped from every fragment after
-/// the first.
+/// The apostrophe definition `roff` writes at the top of every fragment it renders.
+/// A page needs it once, so it is stripped from every fragment after the first.
const ROFF_PREAMBLE: &str = concat!(r".ie \n(.g .ds Aq \(aq", "\n", r".el .ds Aq '", "\n");
/// Append one rendered `roff` fragment to the page under construction.
@@ -1421,10 +1374,8 @@ fn append_fragment(page: &mut String, fragment: &[u8]) -> Result<()> {
/// Render the arguments that belong to one command alone, as `.SS` subsections.
///
-/// `clap_mangen` renders a single page for the root command, so an argument
-/// declared on a subcommand — `fix --dry-run`, `init --force`, `plugin add
-/// --sha256` — would never reach the manual at all. Every command is walked
-/// and the arguments it does not inherit are written under its own heading.
+/// `clap_mangen` renders a single page for the root command, so an argument declared on a subcommand — `fix --dry-run`, `init --force`, `plugin add --sha256` — would never reach the manual at all.
+/// Every command is walked and the arguments it does not inherit are written under its own heading.
fn command_options(command: &clap::Command, path: &str, page: &mut String) -> Result<()> {
for subcommand in command.get_subcommands() {
if subcommand.is_hide_set() || subcommand.get_name() == "help" {
@@ -1433,8 +1384,8 @@ fn command_options(command: &clap::Command, path: &str, page: &mut String) -> Re
let name = format!("{path} {}", subcommand.get_name());
/* NOTE: The global arguments already have one entry each under OPTIONS,
* POLICY, and OUTPUT, and `--help` is on every command by definition.
- * Repeating them here would bury the few arguments this section is
- * for. Hiding is how `clap_mangen` is told to skip an argument. */
+ * Repeating them here would bury the few arguments this section is for.
+ * Hiding is how `clap_mangen` is told to skip an argument. */
let mut own = subcommand.clone();
let inherited: Vec = own
.get_arguments()
@@ -1454,8 +1405,7 @@ fn command_options(command: &clap::Command, path: &str, page: &mut String) -> Re
.context("cannot render the manual page")?;
let mut rendered = String::new();
append_fragment(&mut rendered, &fragment)?;
- /* NOTE: A command with nothing of its own renders an empty fragment, and an
- * empty heading would claim otherwise. */
+ /* NOTE: A command with nothing of its own renders an empty fragment, and an empty heading would claim otherwise. */
if let Some(body) = rendered.strip_prefix(".SH OPTIONS\n")
&& !body.is_empty()
{
@@ -1469,13 +1419,10 @@ fn command_options(command: &clap::Command, path: &str, page: &mut String) -> Re
/// Render the roff manual page from the parser definition itself.
fn run_man() -> Result {
/* NOTE: `clap_mangen` renders `after_long_help` as one opaque `.SH EXTRA` body,
- * so the page is built without it and the same content is appended below
- * as real roff sections. It is assembled section by section rather than
- * through `render`, because the per-command options belong next to the
- * command list and `render` puts VERSION after it.
+ * so the page is built without it and the same content is appended below as real roff sections.
+ * It is assembled section by section rather than through `render`, because the per-command options belong next to the command list and `render` puts VERSION after it.
*
- * The `.TH` date is left blank on purpose: stamping the build date would
- * make two reproducible builds of the same source disagree. */
+ * The `.TH` date is left blank on purpose: stamping the build date would make two reproducible builds of the same source disagree. */
let man = clap_mangen::Man::new(Cli::command().after_long_help(None))
.title("OCOMMENT")
.manual("User Commands");
@@ -1499,8 +1446,7 @@ fn run_man() -> Result {
}
let mut per_command = String::new();
let mut root = Cli::command();
- /* NOTE: Building propagates the global arguments into every subcommand, which is
- * what makes them recognizable as inherited below. */
+ /* NOTE: Building propagates the global arguments into every subcommand, which is what makes them recognizable as inherited below. */
root.build();
command_options(&root, "ocomment", &mut per_command)?;
if !per_command.is_empty() {
@@ -1523,9 +1469,7 @@ fn run_man() -> Result {
/// Write the shell completion script.
///
-/// `clap_complete` writes straight into the handle it is given and panics if
-/// that write fails, so it is given a buffer in memory and the one write that
-/// can fail is made here.
+/// `clap_complete` writes straight into the handle it is given and panics if that write fails, so it is given a buffer in memory and the one write that can fail is made here.
fn run_completions(shell: Shell) -> Result {
let mut script = Vec::new();
generate(shell, &mut Cli::command(), "ocomment", &mut script);
@@ -1536,8 +1480,7 @@ fn run_completions(shell: Shell) -> Result {
}
fn run_init(args: InitArgs, verbosity: Verbosity) -> Result {
- /* NOTE: Writing the file is only the first half of the task, so each template
- * carries the step that finishes it. */
+ /* NOTE: Writing the file is only the first half of the task, so each template carries the step that finishes it. */
let (path, contents, next_step) = match args.kind {
InitKind::Config => (
config::CONFIG_FILE,
@@ -1545,7 +1488,9 @@ fn run_init(args: InitArgs, verbosity: Verbosity) -> Result {
"edit [policy] and run `ocomment check`",
),
InitKind::Lefthook => {
- let command = if args.fix {
+ let command = if args.tidy {
+ "ocomment fix --tidy --staged"
+ } else if args.fix {
"ocomment fix --staged"
} else {
"ocomment check --staged"
@@ -1559,33 +1504,25 @@ fn run_init(args: InitArgs, verbosity: Verbosity) -> Result {
};
let mut stdout = output::stdout();
if args.stdout {
- /* NOTE: Nothing is created, so nothing is said about creating it: the
- * template alone is on standard output, ready to be redirected. */
+ /* NOTE: Nothing is created, so nothing is said about creating it: the template alone is on standard output, ready to be redirected. */
output::wrote(write!(stdout, "{contents}"))?;
output::finish(&mut stdout)?;
return Ok(0);
}
write_template(&mut stdout, path, &contents, args.force, next_step)?;
- /* NOTE: The note is advice about the file that now exists, so it follows the
- * line that reports it — and a refused `init` never reaches it, because
- * there is no new file for an inherited configuration to layer under.
- * Standard output is flushed first so a terminal reading both streams sees
- * the creation before the note about it. */
+ /* NOTE: The note is advice about the file that now exists, so it follows the line that reports it — and a refused `init` never reaches it, because there is no new file for an inherited configuration to layer under.
+ * Standard output is flushed first so a terminal reading both streams sees the creation before the note about it. */
output::finish(&mut stdout)?;
note_inherited_config(verbosity)?;
Ok(0)
}
-/// Say so when a project configuration from a parent directory already governs
-/// this directory.
+/// Say so when a project configuration from a parent directory already governs this directory.
///
-/// The starter file layers over it rather than starting from nothing, and the
-/// hook a `lefthook` run installs will read it — either way the reader is
-/// better off knowing before they start editing. It is a note and not a
-/// refusal: a nested per-crate configuration is a normal thing to want.
+/// The starter file layers over it rather than starting from nothing, and the hook a `lefthook` run installs will read it — either way the reader is better off knowing before they start editing.
+/// It is a note and not a refusal: a nested per-crate configuration is a normal thing to want.
///
-/// The search starts at the parent so that the file this very run is about to
-/// write — or the one `--force` is replacing — is never reported as inherited.
+/// The search starts at the parent so that the file this very run is about to write — or the one `--force` is replacing — is never reported as inherited.
fn note_inherited_config(verbosity: Verbosity) -> Result<()> {
let Ok(directory) = std::env::current_dir() else {
return Ok(());
@@ -1606,12 +1543,9 @@ fn note_inherited_config(verbosity: Verbosity) -> Result<()> {
)
}
-/// Write one starter file, refusing an existing one unless `force` says
-/// otherwise.
+/// Write one starter file, refusing an existing one unless `force` says otherwise.
///
-/// The refusal is `create_new` rather than a prior `exists()` test: between
-/// such a test and the open the file could appear, and never writing over
-/// someone's edited configuration is the whole point of the check.
+/// The refusal is `create_new` rather than a prior `exists()` test: between such a test and the open the file could appear, and never writing over someone's edited configuration is the whole point of the check.
fn write_template(
output: &mut impl Write,
path: &str,
@@ -1643,12 +1577,8 @@ fn write_template(
/// Answer one question about the configuration.
///
-/// Every answer here is about settings rather than about comments: the merged
-/// file as TOML, where the files were found, how they were layered, and the
-/// schema they are checked against. None of the report schemas has a place to
-/// put any of that — `--format json` would name the report format, not the
-/// TOML `show` writes or the JSON Schema `schema` writes — so the flag is
-/// refused rather than accepted and ignored.
+/// Every answer here is about settings rather than about comments: the merged file as TOML, where the files were found, how they were layered, and the schema they are checked against.
+/// None of the report schemas has a place to put any of that — `--format json` would name the report format, not the TOML `show` writes or the JSON Schema `schema` writes — so the flag is refused rather than accepted and ignored.
fn run_config(args: ConfigArgs, common: &CommonArgs) -> Result {
ensure!(
common.output.format.for_a_person(),
@@ -1717,11 +1647,8 @@ fn run_config(args: ConfigArgs, common: &CommonArgs) -> Result {
"policy: {}; layout: {}",
resolved.config.policy.mode, resolved.config.policy.layout
))?;
- /* NOTE: The three lines above are the whole of what this
- * used to print, which left it explaining a configuration
- * without naming anything the configuration says. A
- * `keep_regex` is the setting most likely to be wrong and
- * was the one setting `explain` would not show. */
+ /* NOTE: The three lines above are the whole of what this used to print, which left it explaining a configuration without naming anything the configuration says.
+ * A `keep_regex` is the setting most likely to be wrong and was the one setting `explain` would not show. */
let (_, root_options, root_trace) = resolved.for_path_traced(
&resolved.root.clone(),
Language::Unknown,
@@ -1757,9 +1684,7 @@ fn run_config(args: ConfigArgs, common: &CommonArgs) -> Result {
}
}
if wrote_any {
- /* NOTE: This page lists the settings; only a run can say
- * which of them met anything, because that is a fact
- * about the files rather than about the table. */
+ /* NOTE: This page lists the settings; only a run can say which of them met anything, because that is a fact about the files rather than about the table. */
output::wrote(writeln!(
stdout,
"a walk reports any of these that met no comment; \
@@ -1780,26 +1705,20 @@ fn run_config(args: ConfigArgs, common: &CommonArgs) -> Result {
Ok(0)
}
-/// ` ([policy] in .ocomment.toml)`, or nothing at all when the trace cannot
-/// place the setting.
+/// ` ([policy] in .ocomment.toml)`, or nothing at all when the trace cannot place the setting.
fn setting_origin(trace: &config::PolicyTrace, key: &str, index: usize) -> String {
trace
.origin_at(key, index)
.map_or_else(String::new, |origin| format!(" ({origin})"))
}
-/// The shared language table, embedded from `spec/languages.toml` at build
-/// time so a released binary carries the same list the repository publishes.
-/// `tools/check_embedded_specs.py` and `spec_languages.rs` both fail when the
-/// copy under `assets/` stops being the canonical file.
+/// The shared language table, embedded from `spec/languages.toml` at build time so a released binary carries the same list the repository publishes.
+/// `tools/check_embedded_specs.py` and `spec_languages.rs` both fail when the copy under `assets/` stops being the canonical file.
const LANGUAGE_TABLE: &str = include_str!("../assets/languages.toml");
/// One language of the shared table.
///
-/// The field names are the keys of `spec/languages.toml` and the members of the
-/// objects `--format json` writes; the three that can be empty are left out of
-/// the JSON rather than written as an empty collection, so a reader can tell
-/// "no reserved names" from "reserved names not described".
+/// The field names are the keys of `spec/languages.toml` and the members of the objects `--format json` writes; the three that can be empty are left out of the JSON rather than written as an empty collection, so a reader can tell "no reserved names" from "reserved names not described".
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct LanguageRow {
@@ -1809,8 +1728,7 @@ struct LanguageRow {
editor_ids: Vec,
/// Every file extension that selects the language, without the dot.
extensions: Vec,
- /// Every dialect the language accepts, in the order `--dialect` names them
- /// when it refuses one.
+ /// Every dialect the language accepts, in the order `--dialect` names them when it refuses one.
dialects: Vec,
/// The extensions that select a dialect other than `standard`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
@@ -1838,9 +1756,7 @@ struct LanguageTable {
/// Read the embedded table.
///
-/// A failure here is a broken build rather than a broken run — the bytes are
-/// compiled in — so the error says which file is at fault instead of blaming
-/// the command line.
+/// A failure here is a broken build rather than a broken run — the bytes are compiled in — so the error says which file is at fault instead of blaming the command line.
fn language_table() -> Result> {
let table: LanguageTable = toml::from_str(LANGUAGE_TABLE)
.context("the embedded spec/languages.toml is not a language table")?;
@@ -1855,16 +1771,13 @@ fn language_table() -> Result> {
/// Print the shared language table.
///
/// The human listing is one tab-separated row per language — name, extensions,
-/// dialects, and the spec's remark where it has one — and `--format json`
-/// writes the same rows as an array of objects. The other formats are report
-/// schemas with nowhere to put a language table, so they are refused rather
-/// than quietly answered with the human one.
+/// dialects, and the spec's remark where it has one — and `--format json` writes the same rows as an array of objects.
+/// The other formats are report schemas with nowhere to put a language table, so they are refused rather than quietly answered with the human one.
fn print_languages(common: &CommonArgs) -> Result {
let rows = language_table()?;
let mut stdout = output::stdout();
match common.output.format {
- /* NOTE: A language table has no findings to group, so the terminal
- * format and the pipe format are the same table. */
+ /* NOTE: A language table has no findings to group, so the terminal format and the pipe format are the same table. */
OutputFormat::Human | OutputFormat::Review => {
output::wrote(writeln!(stdout, "language\textensions\tdialects\tnotes"))?;
for row in &rows {
@@ -1877,9 +1790,7 @@ fn print_languages(common: &CommonArgs) -> Result {
output::wrote(writeln!(stdout, "{line}"))?;
}
}
- /* NOTE: Rendered into a string rather than straight into the writer, so the
- * one write is raised through `output::wrote` and a reader that closed
- * the pipe still ends the run quietly. */
+ /* NOTE: Rendered into a string rather than straight into the writer, so the one write is raised through `output::wrote` and a reader that closed the pipe still ends the run quietly. */
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&rows)
.context("cannot render the language table as JSON")?;
@@ -1896,12 +1807,10 @@ fn print_languages(common: &CommonArgs) -> Result {
/// One declarative profile as the listing reports it.
#[derive(Serialize)]
struct ProfileRow {
- /// What the profile is called, which is also what a `[[overrides]]` or a
- /// `[profiles.]` in the configuration refers to.
+ /// What the profile is called, which is also what a `[[overrides]]` or a `[profiles.]` in the configuration refers to.
name: String,
/// Where it came from: `bundled` when it is the one this build ships,
- /// `configured` when the project declared it or replaced a shipped one of
- /// the same name.
+ /// `configured` when the project declared it or replaced a shipped one of the same name.
source: &'static str,
/// Whole file names the profile claims.
#[serde(skip_serializing_if = "Vec::is_empty")]
@@ -1915,19 +1824,13 @@ struct ProfileRow {
/// What this build and this project can read beyond the built-in languages.
///
-/// `ocomment languages` is the built-in scanner table and nothing else, which
-/// is the right contract for it -- it is `spec/languages.toml`, rendered. It is
-/// also, on its own, an incomplete answer to "can it read this file": a
-/// declarative profile reads files no language claims, and a release that adds
-/// one changes what a gate covers without changing a single policy. Before
-/// this listing existed that change was unannounced, and a project met it as
-/// findings in files the previous version had passed over in silence.
+/// `ocomment languages` is the built-in scanner table and nothing else, which is the right contract for it -- it is `spec/languages.toml`, rendered.
+/// It is also, on its own, an incomplete answer to "can it read this file": a declarative profile reads files no language claims, and a release that adds one changes what a gate covers without changing a single policy.
+/// Before this listing existed that change was unannounced, and a project met it as findings in files the previous version had passed over in silence.
fn profile_table(common: &CommonArgs) -> Result> {
let resolved = config::load(common.config.as_deref())?;
- /* NOTE: Normalized the way configuration loading normalizes them. A
- * shipped profile that left `name` implicit would otherwise differ from
- * the resolved copy in that one field and be reported as one the project
- * declared -- a listing wrong about exactly the thing it is for. */
+ /* NOTE: Normalized the way configuration loading normalizes them.
+ * A shipped profile that left `name` implicit would otherwise differ from the resolved copy in that one field and be reported as one the project declared -- a listing wrong about exactly the thing it is for. */
let bundled: BTreeMap = config::bundled_profiles()?
.into_iter()
.map(|(name, mut profile)| {
@@ -1943,10 +1846,7 @@ fn profile_table(common: &CommonArgs) -> Result> {
.iter()
.map(|(name, profile)| ProfileRow {
name: name.clone(),
- /* NOTE: Compared by value rather than by name: a project replaces a
- * shipped profile by declaring one of the same name, and a listing
- * that keyed on the name alone would call the replacement bundled
- * and send a reader to the wrong file to change it. */
+ /* NOTE: Compared by value rather than by name: a project replaces a shipped profile by declaring one of the same name, and a listing that keyed on the name alone would call the replacement bundled and send a reader to the wrong file to change it. */
source: if bundled.get(name) == Some(profile) {
"bundled"
} else {
@@ -1974,14 +1874,11 @@ fn print_profiles(common: &CommonArgs) -> Result {
let rows = profile_table(common)?;
let mut stdout = output::stdout();
match common.output.format {
- /* NOTE: As with the language table, there are no findings to group, so
- * the terminal format and the pipe format are the same table. */
+ /* NOTE: As with the language table, there are no findings to group, so the terminal format and the pipe format are the same table. */
OutputFormat::Human | OutputFormat::Review => {
output::wrote(writeln!(stdout, "profile\tsource\tfiles\tcomments"))?;
for row in &rows {
- /* NOTE: Extensions carry their dot here, so that a reader can
- * tell `.opam` the suffix from `dune` the whole file name in a
- * column that holds both. */
+ /* NOTE: Extensions carry their dot here, so that a reader can tell `.opam` the suffix from `dune` the whole file name in a column that holds both. */
let files = row
.filenames
.iter()
@@ -2046,16 +1943,12 @@ fn run_plugin(args: PluginArgs, common: &CommonArgs) -> Result {
Ok(0)
}
-/// What `git` is needed for. The four plugin purposes are declared beside the
-/// spawn sites that name them in a failure; this one belongs to the flag it
-/// serves, and is worded the same way so the rows read alike.
+/// What `git` is needed for.
+/// The four plugin purposes are declared beside the spawn sites that name them in a failure; this one belongs to the flag it serves, and is worded the same way so the rows read alike.
const STAGED_READS: &str = "--staged";
-/// The optional external tools OComment shells out to, in the order `doctor`
-/// reports them: the binary, the arguments that make it identify itself, and
-/// the part of a run that stops working without it. Not one of them is needed
-/// to check or fix a file, so a missing tool is a row in the report and never
-/// a failing run.
+/// The optional external tools OComment shells out to, in the order `doctor` reports them: the binary, the arguments that make it identify itself, and the part of a run that stops working without it.
+/// Not one of them is needed to check or fix a file, so a missing tool is a row in the report and never a failing run.
const PROBED_TOOLS: [(&str, &[&str], &str); 5] = [
("git", &["--version"], STAGED_READS),
("curl", &["--version"], plugin::HTTPS_SOURCES),
@@ -2076,10 +1969,8 @@ enum Probe {
/// Ask one external tool for its version.
///
-/// The answer is read from standard output, or from standard error for the
-/// tools that put their banner there, and it is the line the tool chose:
-/// `doctor` reports what a tool says about itself rather than parsing it into
-/// fields that the next release would rename.
+/// The answer is read from standard output, or from standard error for the tools that put their banner there, and it is the line the tool chose:
+/// `doctor` reports what a tool says about itself rather than parsing it into fields that the next release would rename.
fn probe(tool: &str, args: &[&str]) -> Probe {
let output = match std::process::Command::new(tool).args(args).output() {
Ok(output) => output,
@@ -2097,19 +1988,12 @@ fn probe(tool: &str, args: &[&str]) -> Probe {
/// The line a tool identifies itself by, out of everything it printed.
///
-/// Usually that is the first line carrying anything, but `cosign version`
-/// draws six lines of ASCII art before it mentions a version, and a row
-/// showing the top of that banner would tell the reader nothing. A version has
-/// a number in it, so the first line with a digit wins and the first non-empty
-/// line is the fallback for a tool that names no number at all.
+/// Usually that is the first line carrying anything, but `cosign version` draws six lines of ASCII art before it mentions a version, and a row showing the top of that banner would tell the reader nothing.
+/// A version has a number in it, so the first line with a digit wins and the first non-empty line is the fallback for a tool that names no number at all.
///
-/// A banner that is not UTF-8 is still worth showing, so the bytes are read
-/// lossily rather than dropped, and one line of it is kept: a row of the
-/// report stays one line whatever the tool decided to print.
+/// A banner that is not UTF-8 is still worth showing, so the bytes are read lossily rather than dropped, and one line of it is kept: a row of the report stays one line whatever the tool decided to print.
///
-/// The tool chose those bytes, so the line it identifies itself by is
-/// untrusted input on its way to a terminal, and it is sanitised exactly like
-/// a comment preview before it becomes a row.
+/// The tool chose those bytes, so the line it identifies itself by is untrusted input on its way to a terminal, and it is sanitised exactly like a comment preview before it becomes a row.
fn version_line(bytes: &[u8]) -> Option {
let text = String::from_utf8_lossy(bytes);
let mut fallback = None;
@@ -2124,10 +2008,8 @@ fn version_line(bytes: &[u8]) -> Option {
/// Report what a walk over `target` would and would not look at.
///
-/// It reads the same discovery every other command starts from, so the answer
-/// is about the run the reader is actually making: the same configuration, the
-/// same includes and excludes, the same size limit. A coverage report computed
-/// any other way would be about a different walk.
+/// It reads the same discovery every other command starts from, so the answer is about the run the reader is actually making: the same configuration, the same includes and excludes, the same size limit.
+/// A coverage report computed any other way would be about a different walk.
/// `ratchet`, which checks a tree against its ledger or records one.
#[derive(Clone, Debug, Args)]
struct RatchetArgs {
@@ -2140,10 +2022,8 @@ struct RatchetArgs {
/// Hold a tree to the ledger recorded beside it, or record one.
///
-/// The ledger only falls: a file holding more than it allows fails, and a file
-/// holding fewer fails too, asking to be recorded. A ledger that only noticed
-/// growth would eventually describe a repository that no longer exists, and
-/// the distance left to go would stop being readable from the file.
+/// The ledger only falls: a file holding more than it allows fails, and a file holding fewer fails too, asking to be recorded.
+/// A ledger that only noticed growth would eventually describe a repository that no longer exists, and the distance left to go would stop being readable from the file.
fn run_ratchet(args: &RatchetArgs, common: &CommonArgs) -> Result {
let resolved = config::load(common.config.as_deref())?;
let configured = resolved.config.ratchet.ledger.clone();
@@ -2172,9 +2052,7 @@ fn run_ratchet(args: &RatchetArgs, common: &CommonArgs) -> Result {
/// Scan a walk and hand back the processed files, with no report written.
///
-/// `ratchet` needs the counts and nothing else, so it takes the shortest path
-/// that still resolves the same configuration and the same policy every other
-/// command would have used.
+/// `ratchet` needs the counts and nothing else, so it takes the shortest path that still resolves the same configuration and the same policy every other command would have used.
fn scan_for_counts(
paths: &[PathBuf],
resolved: &config::ResolvedConfig,
@@ -2202,10 +2080,7 @@ fn scan_for_counts(
} else {
scanner.scan(&file.source, file.language)
};
- let changed = report
- .comments
- .iter()
- .any(|comment| comment.disposition.is_remove());
+ let changed = report.changes_bytes();
let read_by = file.read_by();
files.push(ProcessedFile {
path: file.path,
@@ -2226,9 +2101,8 @@ fn run_coverage(target: &TargetArgs, common: &CommonArgs) -> Result {
"coverage reports on a walk; standard input is one source with no walk around it"
);
let discovery = read_targets(&paths, stdin, &resolved, common)?;
- /* NOTE: What the walk's own limits kept out, which nothing met and so
- * nothing reported. Without it the percentage is of the walk rather than
- * of the tree, and a run that read three of seven files says `100.0%`. */
+ /* NOTE: What the walk's own limits kept out, which nothing met and so nothing reported.
+ * Without it the percentage is of the walk rather than of the tree, and a run that read three of seven files says `100.0%`. */
let reached: Vec = discovery
.files
.iter()
@@ -2238,9 +2112,7 @@ fn run_coverage(target: &TargetArgs, common: &CommonArgs) -> Result {
let not_walked = files::not_walked(&paths, &resolved, &reached)?;
let coverage = coverage::Coverage::compute(&discovery.files, &discovery.skipped, ¬_walked);
coverage::render(&coverage, common.output.format)?;
- /* NOTE: `--deny-skipped` turns the report into a gate here too, so that the
- * command that measures the hole and the command that refuses it agree
- * about which skips count. */
+ /* NOTE: `--deny-skipped` turns the report into a gate here too, so that the command that measures the hole and the command that refuses it agree about which skips count. */
deny_exit_code(
&discovery.skipped,
common.policy.deny_skipped.as_deref(),
@@ -2248,12 +2120,9 @@ fn run_coverage(target: &TargetArgs, common: &CommonArgs) -> Result {
)
}
-/// Count the tags this tree writes, and say which way the convention has
-/// drifted.
+/// Count the tags this tree writes, and say which way the convention has drifted.
///
-/// Reports rather than gates, as `coverage` does: what to do about a tag
-/// nobody configured is a decision about that tag, and a run that failed would
-/// be making it.
+/// Reports rather than gates, as `coverage` does: what to do about a tag nobody configured is a decision about that tag, and a run that failed would be making it.
fn run_tags(target: &TargetArgs, common: &CommonArgs) -> Result {
let mut resolved = config::load(common.config.as_deref())?;
apply_cli_overrides(&mut resolved, common);
@@ -2284,10 +2153,7 @@ fn run_tags(target: &TargetArgs, common: &CommonArgs) -> Result {
/// `1` when a skip the run refuses to pass over happened, `0` otherwise.
///
-/// The refused paths are named on standard error rather than counted, because
-/// the answer to this failure is a decision about particular files -- teach
-/// the language, exclude the path, or accept the gap -- and a count does not
-/// say which files to decide about.
+/// The refused paths are named on standard error rather than counted, because the answer to this failure is a decision about particular files -- teach the language, exclude the path, or accept the gap -- and a count does not say which files to decide about.
fn deny_exit_code(
skipped: &[files::SkippedFile],
reasons: Option<&[output::SkipReason]>,
@@ -2329,18 +2195,12 @@ fn deny_exit_code(
/// Which binary is answering, by path and by what it is made of.
///
-/// A version string cannot tell two builds apart, and two that cannot be told
-/// apart is not a hypothetical: a release `ocomment 0.1.0` and a working-tree
-/// `ocomment 0.1.0` disagreed about the same file on one machine on one day,
-/// because `mise exec` and a bare `PATH` resolved to different ones. The
-/// session that hit it spent the afternoon reporting a gate as broken that was
-/// not, and the only thing that would have answered it in one command is this.
+/// A version string cannot tell two builds apart, and two that cannot be told apart is not a hypothetical: a release `ocomment 0.1.0` and a working-tree `ocomment 0.1.0` disagreed about the same file on one machine on one day,
+/// because `mise exec` and a bare `PATH` resolved to different ones.
+/// The session that hit it spent the afternoon reporting a gate as broken that was not, and the only thing that would have answered it in one command is this.
///
-/// The digest is taken at run time from the file on disk rather than stamped in
-/// at build time. A commit hash baked into the binary would make every build
-/// differ from every other, which is the opposite of what the signed release
-/// archives are for; this asks the same question of the bytes that are actually
-/// running and costs a build nothing.
+/// The digest is taken at run time from the file on disk rather than stamped in at build time.
+/// A commit hash baked into the binary would make every build differ from every other, which is the opposite of what the signed release archives are for; this asks the same question of the bytes that are actually running and costs a build nothing.
fn running_binary() -> String {
let Ok(path) = std::env::current_exe() else {
return "unavailable".to_owned();
@@ -2356,8 +2216,7 @@ fn running_binary() -> String {
}
fn run_doctor(common: &CommonArgs) -> Result {
- /* NOTE: Asked before standard output is locked for the report, so the answer is
- * about the same handle the report is written to. */
+ /* NOTE: Asked before standard output is locked for the report, so the answer is about the same handle the report is written to. */
let stdout_tty = io::stdout().is_terminal();
let mut stdout = output::stdout();
output::wrote(writeln!(stdout, "ocomment {}", env!("CARGO_PKG_VERSION")))?;
@@ -2381,8 +2240,7 @@ fn run_doctor(common: &CommonArgs) -> Result {
"languages: {} built in",
Language::ALL.len()
))?;
- /* NOTE: Whether the report is decorated is the first thing a reader piping it
- * somewhere wants explained, and both halves of that answer are here. */
+ /* NOTE: Whether the report is decorated is the first thing a reader piping it somewhere wants explained, and both halves of that answer are here. */
output::wrote(writeln!(
stdout,
"stdout: {}",
@@ -2422,8 +2280,8 @@ fn run_doctor(common: &CommonArgs) -> Result {
/// How many files may be processed between two redraws of the counter.
const PROGRESS_STEP: usize = 50;
-/// Whether this run draws the live scanning counter. The counter is terminal
-/// decoration: it never belongs in a machine format, and `-q` silences it.
+/// Whether this run draws the live scanning counter.
+/// The counter is terminal decoration: it never belongs in a machine format, and `-q` silences it.
fn progress_enabled(common: &CommonArgs) -> bool {
// NOTE: Decoration rather than a line of the report, so it asks directly.
common.output.format.for_a_person()
@@ -2435,8 +2293,7 @@ fn progress_enabled(common: &CommonArgs) -> bool {
}
}
-/// The live scanning counter: how many files it has seen, and whether it ever
-/// put a line on the screen.
+/// The live scanning counter: how many files it has seen, and whether it ever put a line on the screen.
#[derive(Default)]
struct Progress {
scanned: AtomicUsize,
@@ -2444,8 +2301,7 @@ struct Progress {
}
impl Progress {
- /// Advance the live `n/total` counter, rewriting one line on standard
- /// error rather than scrolling a line for every file.
+ /// Advance the live `n/total` counter, rewriting one line on standard error rather than scrolling a line for every file.
fn report(&self, total: usize) {
let seen = self.scanned.fetch_add(1, Ordering::Relaxed) + 1;
if !seen.is_multiple_of(PROGRESS_STEP) && seen != total {
@@ -2459,9 +2315,7 @@ impl Progress {
/// Erase the counter so the report that follows starts on a clean line.
///
- /// A run with nothing to scan draws no counter, and erasing a line it
- /// never wrote would put an escape sequence on a standard error whose
- /// reader was promised only the summary.
+ /// A run with nothing to scan draws no counter, and erasing a line it never wrote would put an escape sequence on a standard error whose reader was promised only the summary.
fn clear(&self) {
if !self.drawn.load(Ordering::Relaxed) {
return;
@@ -2493,15 +2347,12 @@ fn presentation(common: &CommonArgs) -> Presentation {
/// The project root, as a report names it.
///
/// A directory name is chosen by whoever made the directory, not by OComment,
-/// so a row carrying one is untrusted text on its way to a terminal for the
-/// same reason a probed tool's version line is — and, unlike one, it must not
-/// be cut short: a path that ends in an ellipsis names no directory at all.
+/// so a row carrying one is untrusted text on its way to a terminal for the same reason a probed tool's version line is — and, unlike one, it must not be cut short: a path that ends in an ellipsis names no directory at all.
fn root_row(resolved: &config::ResolvedConfig) -> String {
output::sanitize_path(&resolved.root.to_string_lossy())
}
-/// What the run was pointed at, in the words the caller used, or the implicit
-/// target that stands in when they named nothing.
+/// What the run was pointed at, in the words the caller used, or the implicit target that stands in when they named nothing.
fn target_label(paths: &[PathBuf]) -> String {
if paths.is_empty() {
return files::DEFAULT_TARGET.to_owned();
@@ -2513,16 +2364,12 @@ fn target_label(paths: &[PathBuf]) -> String {
.join(" ")
}
-/// Say where a bare `fix` is pointed when that is not where the project
-/// starts.
+/// Say where a bare `fix` is pointed when that is not where the project starts.
///
-/// A reader who has only ever run `ocomment fix` from the top of a repository
-/// can read the bare command as "fix the project", and it is the one command
-/// that writes. So the run that was told nothing about where to write names
-/// both the target it chose and the root the configuration came from, once,
-/// before it starts. A caller who named a path has already said what they
-/// meant, and from the root itself the two are the same directory: either way
-/// the line would be noise.
+/// A reader who has only ever run `ocomment fix` from the top of a repository can read the bare command as "fix the project", and it is the one command that writes.
+/// So the run that was told nothing about where to write names both the target it chose and the root the configuration came from, once,
+/// before it starts.
+/// A caller who named a path has already said what they meant, and from the root itself the two are the same directory: either way the line would be noise.
fn note_fix_scope(resolved: &config::ResolvedConfig, common: &CommonArgs) -> Result<()> {
if resolved.cwd == resolved.root || !common.output.format.for_a_person() {
return Ok(());
@@ -2542,19 +2389,13 @@ fn note_fix_scope(resolved: &config::ResolvedConfig, common: &CommonArgs) -> Res
}
/// The `--verbose` header: where the run is rooted, what it was pointed at,
-/// The value a flag was given on the command line, or `None` when the flag was
-/// not named at all.
+/// The value a flag was given on the command line, or `None` when the flag was not named at all.
///
-/// Clap resolves a default and an alias into the same parsed value and keeps no
-/// record of which arrived, so the two questions that need the difference are
-/// answered from the arguments themselves: whether a format was chosen or
-/// defaulted, and whether a policy was named under a spelling that has moved.
+/// Clap resolves a default and an alias into the same parsed value and keeps no record of which arrived, so the two questions that need the difference are answered from the arguments themselves: whether a format was chosen or defaulted, and whether a policy was named under a spelling that has moved.
fn named_flag(flag: &str) -> Option {
- /* NOTE: `args_os`, not `args`. The second panics on an argument that is not
- * UTF-8, and this tool is given paths -- which on a Unix filesystem are
- * bytes and are not obliged to be text. A flag's value is a flag's value in
- * any encoding, and a path that cannot be read as one is simply not the
- * spelling being looked for. */
+ /* NOTE: `args_os`, not `args`.
+ * The second panics on an argument that is not UTF-8, and this tool is given paths -- which on a Unix filesystem are bytes and are not obliged to be text.
+ * A flag's value is a flag's value in any encoding, and a path that cannot be read as one is simply not the spelling being looked for. */
let arguments: Vec = std::env::args_os()
.map(|argument| argument.to_string_lossy().into_owned())
.collect();
@@ -2568,14 +2409,10 @@ fn named_flag(flag: &str) -> Option {
})
}
-/// The policy named on the command line under a name that has moved, and the
-/// name it moved to.
+/// The policy named on the command line under a name that has moved, and the name it moved to.
///
-/// `legal` and `safe` still resolve, to `conservative` and `standard`, so that
-/// a repository which pinned one of them does not break on an upgrade. That
-/// bargain has two halves and only one of them was kept: a name that goes on
-/// working while nobody is told it changed is a bridge the reader does not know
-/// they are standing on, and the day it is taken away is the day they find out.
+/// `legal` and `safe` still resolve, to `conservative` and `standard`, so that a repository which pinned one of them does not break on an upgrade.
+/// That bargain has two halves and only one of them was kept: a name that goes on working while nobody is told it changed is a bridge the reader does not know they are standing on, and the day it is taken away is the day they find out.
fn renamed_policy() -> Option<(String, &'static str)> {
let spelling = named_flag("policy")?;
ocomment_core::Policy::ALL
@@ -2586,9 +2423,7 @@ fn renamed_policy() -> Option<(String, &'static str)> {
/// and which configuration files it merged.
///
-/// The one line here that is not `-v` material is the renaming notice: a run
-/// steered by a name that has moved has to say so at the volume of an ordinary
-/// note, because the reader of that line is the one who has not noticed.
+/// The one line here that is not `-v` material is the renaming notice: a run steered by a name that has moved has to say so at the volume of an ordinary note, because the reader of that line is the one who has not noticed.
fn trace_run(
resolved: &config::ResolvedConfig,
paths: &[PathBuf],
@@ -2630,11 +2465,9 @@ fn trace_run(
Ok(())
}
-/// Which configuration files a run merged, one line each in the order they
-/// were layered, or the single line that says there were none.
+/// Which configuration files a run merged, one line each in the order they were layered, or the single line that says there were none.
///
-/// `doctor` and the `-v` trace both report this, and a reader comparing the
-/// two is entitled to read the same answer twice, so they read it from here.
+/// `doctor` and the `-v` trace both report this, and a reader comparing the two is entitled to read the same answer twice, so they read it from here.
fn config_trace(trace: &config::ConfigTrace) -> Vec {
let sources: Vec = [
("user", &trace.user),
@@ -2643,8 +2476,7 @@ fn config_trace(trace: &config::ConfigTrace) -> Vec {
]
.into_iter()
.filter_map(|(label, path)| {
- /* INVARIANT: The row carries a directory name OComment did not choose, so it is
- * sanitised for the same reason a `root` row is. */
+ /* INVARIANT: The row carries a directory name OComment did not choose, so it is sanitised for the same reason a `root` row is. */
path.as_ref()
.map(|path| format!("{label} {}", output::sanitize_path(&path.to_string_lossy())))
})
diff --git a/rust/ocomment/src/config.rs b/rust/ocomment/src/config.rs
index 7cf283b..01a0e3b 100644
--- a/rust/ocomment/src/config.rs
+++ b/rust/ocomment/src/config.rs
@@ -2,7 +2,7 @@ use anyhow::{Context, Result, anyhow, bail, ensure};
use globset::{Glob, GlobMatcher};
use ocomment_core::{
AllowRules, CommentKind, DeclarativeProfile, Dialect, DispositionExplanation, Language, Layout,
- Policy, ProtectedPattern, ScanOptions, TransformOptions, validate_profile,
+ Policy, ProtectedPattern, ScanOptions, StyleRules, TransformOptions, validate_profile,
};
use serde::{Deserialize, Serialize};
use std::{
@@ -19,6 +19,12 @@ pub struct Config {
pub version: Option,
pub files: FilesConfig,
pub policy: PolicyConfig,
+ /// How the comments that survive are written.
+ ///
+ /// A table of its own rather than a corner of `[policy]`, because the policy decides what stays and this decides how what stays reads.
+ /// A project that removes nothing still has an opinion about the second, and under `[policy]` it would have had to say so inside a table whose every other entry is about removal.
+ #[serde(default)]
+ pub style: StyleRules,
pub git: GitConfig,
pub ratchet: RatchetConfig,
pub lsp: LspConfig,
@@ -37,13 +43,10 @@ pub struct FilesConfig {
pub ignore: bool,
pub include: Vec,
pub exclude: Vec,
- /// Scan files another tool writes: lock files, recorded seed lists, the
- /// output of a code generator.
+ /// Scan files another tool writes: lock files, recorded seed lists, the output of a code generator.
///
- /// Off, because a comment in one of those belongs to the tool that wrote
- /// it and will be written again on the next run. It is the class most
- /// likely to be auto-fixed without being read, since nobody opens a
- /// generated file before committing it.
+ /// Off, because a comment in one of those belongs to the tool that wrote it and will be written again on the next run.
+ /// It is the class most likely to be auto-fixed without being read, since nobody opens a generated file before committing it.
pub include_generated: bool,
}
@@ -83,10 +86,8 @@ pub struct PolicyConfig {
impl Default for PolicyConfig {
fn default() -> Self {
Self {
- /* NOTE: Deferred to the core enum rather than named here, so the
- * built-in default the CLI reports and the default the library
- * documents cannot drift apart. They did: renaming the policies
- * left this line naming the old default under its new spelling. */
+ /* NOTE: Deferred to the core enum rather than named here, so the built-in default the CLI reports and the default the library documents cannot drift apart.
+ * They did: renaming the policies left this line naming the old default under its new spelling. */
mode: Policy::default(),
layout: Layout::Lines,
keep_kind: Vec::new(),
@@ -103,8 +104,7 @@ impl Default for PolicyConfig {
/// Where the ledger lives, and whether a run is held to it.
///
-/// Empty means no ledger: a project that has not asked for one is not held to
-/// a file that does not exist.
+/// Empty means no ledger: a project that has not asked for one is not held to a file that does not exist.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RatchetConfig {
@@ -173,20 +173,17 @@ pub struct PathOverride {
pub remove_kind: Vec,
pub keep_regex: Vec,
pub remove_regex: Vec,
- /// A different `[policy.allow]` for this part of the tree, replacing the
- /// global one whole rather than merging into it.
+ /// A different `[policy.allow]` for this part of the tree, replacing the global one whole rather than merging into it.
///
- /// Whole, because these rules are a convention and half a convention is
- /// not one: a table that merged would let a subtree inherit a length limit
- /// it never asked for and could not turn off.
+ /// Whole, because these rules are a convention and half a convention is not one: a table that merged would let a subtree inherit a length limit it never asked for and could not turn off.
pub allow: Option,
+ /// A different `[style]` for this part of the tree, replacing the global one whole rather than merging into it, for the reason `allow` does.
+ pub style: Option,
}
/// Where one effective setting came from.
///
-/// The layers are the ones [`ResolvedConfig::for_path`] merges, and a source
-/// names the layer a value arrived on rather than the value itself, so
-/// `--explain` can send a reader to the table they have to edit.
+/// The layers are the ones [`ResolvedConfig::for_path`] merges, and a source names the layer a value arrived on rather than the value itself, so `--explain` can send a reader to the table they have to edit.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum Source {
/// The `[policy]` table, or the built-in default when no file set it.
@@ -201,11 +198,9 @@ pub enum Source {
}
impl Source {
- /// How an explanation names this source, given the `[policy]` key it
- /// decided and the file a `Global` value was written in.
+ /// How an explanation names this source, given the `[policy]` key it decided and the file a `Global` value was written in.
///
- /// `#N` counts an `[[overrides]]` table from zero, the way the regex
- /// indices printed beside it count the patterns they address.
+ /// `#N` counts an `[[overrides]]` table from zero, the way the regex indices printed beside it count the patterns they address.
fn describe(&self, key: &str, origin: Option<&Path>) -> String {
match self {
Self::Global => match origin {
@@ -225,9 +220,8 @@ impl Source {
}
}
-/// The `[policy]` keys a trace can attribute to a file, spelled as the file
-/// spells them.
-const POLICY_KEYS: [&str; 7] = [
+/// The `[policy]` keys a trace can attribute to a file, spelled as the file spells them.
+const POLICY_KEYS: [&str; 8] = [
"mode",
"layout",
"keep_kind",
@@ -235,35 +229,33 @@ const POLICY_KEYS: [&str; 7] = [
"keep_regex",
"remove_regex",
"allow",
+ "style",
];
-/// The table a `[policy]` key is written in, which is the table an explanation
-/// sends a reader to. Every key but one is written in `[policy]` itself.
+/// The table a `[policy]` key is written in, which is the table an explanation sends a reader to.
+/// Most are written in `[policy]` itself.
+///
+/// `style` is not under `[policy]` at all, and the answer here is what sends a reader to the table they would actually edit rather than to the one the trace happens to file it under.
fn policy_table(key: &str) -> &'static str {
- if key == "allow" {
- "[policy.allow]"
- } else {
- "[policy]"
+ match key {
+ "allow" => "[policy.allow]",
+ "style" => "[style]",
+ _ => "[policy]",
}
}
-/// Which configuration file last set each `[policy]` key. A key no file sets
-/// keeps no entry, and an explanation calls it a built-in default rather than
-/// sending the reader to a file that never mentions it.
+/// Which configuration file last set each `[policy]` key.
+/// A key no file sets keeps no entry, and an explanation calls it a built-in default rather than sending the reader to a file that never mentions it.
type PolicyOrigins = BTreeMap<&'static str, PathBuf>;
-/// What the command line overrode, recorded while it was applied so a trace
-/// can say the command line rather than the file the value would otherwise
-/// have been written in.
+/// What the command line overrode, recorded while it was applied so a trace can say the command line rather than the file the value would otherwise have been written in.
#[derive(Clone, Copy, Debug, Default)]
pub struct CliOverrides {
pub language: Option,
pub dialect: Option,
pub policy: bool,
pub layout: bool,
- /// Where the `--keep-kind` values start in `policy.keep_kind`: the command
- /// line appends to the configured list instead of replacing it, so only
- /// the tail of that list belongs to the command line.
+ /// Where the `--keep-kind` values start in `policy.keep_kind`: the command line appends to the configured list instead of replacing it, so only the tail of that list belongs to the command line.
pub keep_kind_from: Option,
/// The same boundary for `--remove-kind` in `policy.remove_kind`.
pub remove_kind_from: Option,
@@ -271,9 +263,7 @@ pub struct CliOverrides {
/// Where every effective disposition setting for one path came from.
///
-/// The `*_kind` and `*_regex` vectors run parallel to the vectors in the
-/// [`ScanOptions`] that [`ResolvedConfig::for_path_traced`] returned beside
-/// this: entry `i` says which layer contributed entry `i` of that list.
+/// The `*_kind` and `*_regex` vectors run parallel to the vectors in the [`ScanOptions`] that [`ResolvedConfig::for_path_traced`] returned beside this: entry `i` says which layer contributed entry `i` of that list.
#[derive(Clone, Debug, Default)]
pub struct PolicyTrace {
pub policy: Source,
@@ -281,20 +271,18 @@ pub struct PolicyTrace {
pub remove_kind: Vec,
pub keep_regex: Vec,
pub remove_regex: Vec,
- /// Which layer last set `[policy.allow]`. The table is replaced whole
- /// rather than merged entry by entry, so one source covers all of it.
+ /// Which layer last set `[policy.allow]`.
+ /// The table is replaced whole rather than merged entry by entry, so one source covers all of it.
pub allow: Source,
+ /// Which layer last set `[style]`, for the reason `allow` has one.
+ pub style: Source,
origins: PolicyOrigins,
}
impl PolicyTrace {
- /// Where the setting that decided `explanation` came from, worded the way
- /// `--explain` prints it, or `None` when a built-in rule decided it and
- /// there is no table to point at.
+ /// Where the setting that decided `explanation` came from, worded the way `--explain` prints it, or `None` when a built-in rule decided it and there is no table to point at.
///
- /// `options` is the one the explanation was produced from: a regex
- /// explanation carries its index into those lists, and a kind explanation
- /// is found by the kind it names.
+ /// `options` is the one the explanation was produced from: a regex explanation carries its index into those lists, and a kind explanation is found by the kind it names.
pub fn origin_of(
&self,
explanation: &DispositionExplanation,
@@ -319,18 +307,19 @@ impl PolicyTrace {
DispositionExplanation::RemovedByRegex { index, .. } => {
(self.remove_regex.get(*index)?, "remove_regex")
}
- /* NOTE: Every one of these is the policy having the last word, whether it
- * took the comment out or protected it. */
+ /* NOTE: Every one of these is the policy having the last word, whether it took the comment out or protected it. */
DispositionExplanation::RemovedByPolicy { .. }
| DispositionExplanation::RemovedByDefault { .. }
+ | DispositionExplanation::KeptByPolicy { .. }
| DispositionExplanation::KeptDocumentation { .. }
| DispositionExplanation::KeptLicense { .. } => (&self.policy, "mode"),
- /* NOTE: The three rules that are about a comment's shape rather
- * than its kind, and the one table that sets all three. */
+ /* NOTE: The three rules that are about a comment's shape rather than its kind, and the one table that sets all three. */
DispositionExplanation::KeptByTag { .. }
| DispositionExplanation::RemovedAsTrailing
| DispositionExplanation::RemovedAsExpired { .. }
| DispositionExplanation::RemovedByLength { .. } => (&self.allow, "allow"),
+ // NOTE: The other axis, and the other table.
+ DispositionExplanation::RewrittenByStyle { .. } => (&self.style, "style"),
// NOTE: A built-in rule, decided by no setting at all.
DispositionExplanation::ProtectedPreamble
| DispositionExplanation::KeptLoadBearing { .. }
@@ -341,12 +330,10 @@ impl PolicyTrace {
Some(source.describe(key, self.origins.get(key).map(PathBuf::as_path)))
}
- /// Where the `key` entry at `index` was written, worded exactly as
- /// [`Self::origin_of`] words the setting behind a verdict.
+ /// Where the `key` entry at `index` was written, worded exactly as [`Self::origin_of`] words the setting behind a verdict.
///
/// `origin_of` starts from a comment and asks which setting decided it.
- /// This starts from the setting, which is what a report about a setting
- /// *nothing* decided has to do: there is no comment to ask about.
+ /// This starts from the setting, which is what a report about a setting *nothing* decided has to do: there is no comment to ask about.
pub fn origin_at(&self, key: &str, index: usize) -> Option {
let source = match key {
"keep_kind" => self.keep_kind.get(index),
@@ -371,10 +358,8 @@ struct TracedLayer<'a> {
/// Attribute every entry of one merged list to the layer that introduced it.
///
-/// [`ResolvedConfig::for_path`] starts from the global list verbatim and then
-/// appends whatever a later layer adds that is not there yet, so replaying that
-/// walk reproduces the merged list position for position. The tail of the
-/// global list from `cli_from` on is what `flag` appended to it.
+/// [`ResolvedConfig::for_path`] starts from the global list verbatim and then appends whatever a later layer adds that is not there yet, so replaying that walk reproduces the merged list position for position.
+/// The tail of the global list from `cli_from` on is what `flag` appended to it.
fn attribute(
global: &[T],
cli_from: Option,
@@ -403,8 +388,7 @@ fn attribute(
sources
}
-/// Where a setting that holds a single value came from, before any language or
-/// path layer has had its say.
+/// Where a setting that holds a single value came from, before any language or path layer has had its say.
fn scalar_source(overridden: bool, flag: &'static str) -> Source {
if overridden {
Source::Cli { flag }
@@ -429,13 +413,10 @@ pub struct ResolvedConfig {
pub config: Config,
pub trace: ConfigTrace,
/// Where the project starts: the directory `.ocomment.toml` was found in,
- /// the repository above the working directory, or the working directory
- /// itself. It decides where configuration is discovered, what the file and
- /// override globs are written relative to, and where the plugin lock
- /// lives — no longer what a command with no path walks.
+ /// the repository above the working directory, or the working directory itself.
+ /// It decides where configuration is discovered, what the file and override globs are written relative to, and where the plugin lock lives — no longer what a command with no path walks.
pub root: PathBuf,
- /// The directory the command was run from, which is what a path typed on
- /// the command line is relative to.
+ /// The directory the command was run from, which is what a path typed on the command line is relative to.
pub cwd: PathBuf,
/// What the command line overrode, filled in after the files were merged.
pub cli_overrides: CliOverrides,
@@ -444,23 +425,15 @@ pub struct ResolvedConfig {
}
impl ResolvedConfig {
- /// Where `path` sits under the project root, spelled the way a
- /// configuration glob is written.
+ /// Where `path` sits under the project root, spelled the way a configuration glob is written.
///
- /// `files.include`, `files.exclude`, and every `[[overrides]].paths`
- /// pattern is relative to the root, while a path named on the command line
- /// is relative to the working directory. The two agree only when the
- /// command is run from the root, so the path is resolved against the
- /// directory it was typed in before it is measured against the root, and
- /// the separators come out as forward slashes so one glob reads the same
- /// on every platform.
+ /// `files.include`, `files.exclude`, and every `[[overrides]].paths` pattern is relative to the root, while a path named on the command line is relative to the working directory.
+ /// The two agree only when the command is run from the root, so the path is resolved against the directory it was typed in before it is measured against the root, and the separators come out as forward slashes so one glob reads the same on every platform.
///
- /// A path outside the root — an explicit target above it, say — has no
- /// root-relative spelling at all, so it keeps its absolute one and only an
- /// absolute glob can match it.
+ /// A path outside the root — an explicit target above it, say — has no root-relative spelling at all, so it keeps its absolute one and only an absolute glob can match it.
pub fn relative_to_root(&self, path: &Path) -> String {
- /* NOTE: Standard input has no place on disk. The pseudo-path is what the
- * renderers print, so it is also what the globs are shown. */
+ /* NOTE: Standard input has no place on disk.
+ * The pseudo-path is what the renderers print, so it is also what the globs are shown. */
if path.as_os_str() == crate::files::STDIN_PATH {
return crate::files::STDIN_PATH.to_owned();
}
@@ -473,18 +446,10 @@ impl ResolvedConfig {
/// The `[[overrides]]` entries whose globs matched none of these paths,
/// with the globs they were written as.
///
- /// A settings block that matches no file does nothing, and nothing said
- /// so. The report this feeds exists to catch a `keep_regex` that will
- /// never fire; a path glob that will never fire is the same mistake one
- /// level up, and it is the level at which a project narrows a rule to the
- /// files the rule is about — so a typo there does not narrow anything, it
- /// leaves the wider rule in place over files somebody had decided to
- /// exempt.
+ /// A settings block that matches no file does nothing, and nothing said so.
+ /// The report this feeds exists to catch a `keep_regex` that will never fire; a path glob that will never fire is the same mistake one level up, and it is the level at which a project narrows a rule to the files the rule is about — so a typo there does not narrow anything, it leaves the wider rule in place over files somebody had decided to exempt.
///
- /// Matched against every path the walk reached, scanned or skipped: a file
- /// the walk passed over is still a file the glob was written for, and
- /// calling the glob unused because its file is in a language this build
- /// cannot read would send a reader to fix the wrong line.
+ /// Matched against every path the walk reached, scanned or skipped: a file the walk passed over is still a file the glob was written for, and calling the glob unused because its file is in a language this build cannot read would send a reader to fix the wrong line.
pub fn unused_overrides<'a>(
&'a self,
paths: impl IntoIterator,
@@ -530,10 +495,8 @@ impl ResolvedConfig {
let normalized = self.relative_to_root(path);
let mut chosen_language = language;
let mut chosen_dialect = dialect;
- /* NOTE: Language is selected before its language-specific policy is
- * applied. A language change without an accompanying dialect starts at
- * that language's standard dialect instead of carrying (for example)
- * `tsx` into a Rust scan. */
+ /* NOTE: Language is selected before its language-specific policy is applied.
+ * A language change without an accompanying dialect starts at that language's standard dialect instead of carrying (for example) `tsx` into a Rust scan. */
for override_ in &self.overrides {
if override_
.matchers
@@ -559,6 +522,7 @@ impl ResolvedConfig {
let mut keep_regex = self.config.policy.keep_regex.clone();
let mut remove_regex = self.config.policy.remove_regex.clone();
let mut allow = self.config.policy.allow.clone();
+ let mut style = self.config.style.clone();
if let Some(language_config) = self.config.languages.get(chosen_language.as_str()) {
if let Some(value) = language_config.dialect {
@@ -597,6 +561,9 @@ impl ResolvedConfig {
if let Some(value) = &override_.value.allow {
allow = value.clone();
}
+ if let Some(value) = &override_.value.style {
+ style = value.clone();
+ }
}
}
if self.cli_overrides.policy {
@@ -620,19 +587,16 @@ impl ResolvedConfig {
keep_regex,
remove_regex,
allow,
+ style,
protected: self.config.policy.protected.clone(),
};
Ok((chosen_language, TransformOptions { scan, layout }))
}
- /// The same answer as [`Self::for_path`], with a record of where each
- /// setting came from.
+ /// The same answer as [`Self::for_path`], with a record of where each setting came from.
///
- /// The values are [`Self::for_path`]'s own, so what a run does and what
- /// `--explain` says about it cannot disagree; only the attribution is
- /// computed here, by replaying the same merge with the layer names
- /// attached. `--explain` is the only caller, which is why the hot path is
- /// left as it was.
+ /// The values are [`Self::for_path`]'s own, so what a run does and what `--explain` says about it cannot disagree; only the attribution is computed here, by replaying the same merge with the layer names attached.
+ /// `--explain` is the only caller, which is why the hot path is left as it was.
pub fn for_path_traced(
&self,
path: &Path,
@@ -704,24 +668,23 @@ impl ResolvedConfig {
"--remove-kind",
&remove_kinds,
),
- /* NOTE: No flag supplies a pattern, so no entry of either list can have
- * come from the command line. */
+ /* NOTE: No flag supplies a pattern, so no entry of either list can have come from the command line. */
keep_regex: attribute(&self.config.policy.keep_regex, None, "", &keep_patterns),
remove_regex: attribute(&self.config.policy.remove_regex, None, "", &remove_patterns),
- /* NOTE: No flag sets an allow rule, so the command line never wins
- * this one and the file the merge left standing is the answer. */
+ /* NOTE: No flag sets an allow rule, so the command line never wins this one and the file the merge left standing is the answer. */
allow: Source::Global,
+ // NOTE: No flag sets a style rule either, for now.
+ style: Source::Global,
origins: self.origins.clone(),
};
- /* NOTE: A single-valued setting is not merged but replaced, so the last layer
- * that names it is the one that decided it. */
+ /* NOTE: A single-valued setting is not merged but replaced, so the last layer that names it is the one that decided it. */
for layer in &layers {
if layer.policy.is_some() {
trace.policy = layer.source.clone();
}
}
- /* NOTE: Flags are the final layer. Starting the trace at CLI and then
- * replaying path layers would claim that a value which never won did. */
+ /* NOTE: Flags are the final layer.
+ * Starting the trace at CLI and then replaying path layers would claim that a value which never won did. */
if cli.policy {
trace.policy = Source::Cli { flag: "--policy" };
}
@@ -735,8 +698,8 @@ pub fn load(explicit: Option<&Path>) -> Result {
}
/// Resolve configuration as though the command were invoked from `cwd`,
-/// without changing the process-wide current directory. LSP workspace folders
-/// use this to keep discovery and glob roots independent.
+/// without changing the process-wide current directory.
+/// LSP workspace folders use this to keep discovery and glob roots independent.
pub fn load_from(cwd: &Path, explicit: Option<&Path>) -> Result {
let cwd = if cwd.is_absolute() {
lexical(cwd)
@@ -751,9 +714,8 @@ pub fn load_from(cwd: &Path, explicit: Option<&Path>) -> Result
};
lexical(&joined)
});
- /* NOTE: `--config` is discovery's replacement, not one more layer on top
- * of whatever happens to surround the caller. Its own directory is the
- * root for globs and the plugin lock. */
+ /* NOTE: `--config` is discovery's replacement, not one more layer on top of whatever happens to surround the caller.
+ * Its own directory is the root for globs and the plugin lock. */
let project_path = explicit_path
.is_none()
.then(|| locate_project(&cwd))
@@ -811,12 +773,8 @@ pub fn load_from(cwd: &Path, explicit: Option<&Path>) -> Result
let mut config: Config = merged
.try_into()
.context("cannot resolve merged configuration")?;
- /* NOTE: Under the configuration's own, never over it: a project that
- * disagrees with a shipped profile replaces it by declaring one of the
- * same name, which is the ordinary way every other setting is overridden.
- * They are added after the merge because they are not a configuration
- * layer -- no `[profiles]` table in any file should be able to delete one
- * by being silent about it. */
+ /* NOTE: Under the configuration's own, never over it: a project that disagrees with a shipped profile replaces it by declaring one of the same name, which is the ordinary way every other setting is overridden.
+ * They are added after the merge because they are not a configuration layer -- no `[profiles]` table in any file should be able to delete one by being silent about it. */
for (name, profile) in bundled_profiles()? {
config.profiles.entry(name).or_insert(profile);
}
@@ -842,11 +800,8 @@ pub fn load_from(cwd: &Path, explicit: Option<&Path>) -> Result
/// The declarative profiles OComment ships with.
///
-/// `spec/profiles.toml` is the canonical copy and this is the one the binary
-/// embeds; `tools/check_embedded_specs.py` holds them to each other. They
-/// describe file formats whose comments delimiters describe completely --
-/// `.gitignore`, `dune`, `.wit` -- and exist because the alternative was not
-/// reading those files at all.
+/// `spec/profiles.toml` is the canonical copy and this is the one the binary embeds; `tools/check_embedded_specs.py` holds them to each other.
+/// They describe file formats whose comments delimiters describe completely -- `.gitignore`, `dune`, `.wit` -- and exist because the alternative was not reading those files at all.
pub fn bundled_profiles() -> Result> {
#[derive(Deserialize)]
struct Bundled {
@@ -857,12 +812,9 @@ pub fn bundled_profiles() -> Result> {
Ok(bundled.profiles.into_iter().collect())
}
-/// Layer one configuration file over the merged document, noting every
-/// `[policy]` key it sets on the way.
+/// Layer one configuration file over the merged document, noting every `[policy]` key it sets on the way.
///
-/// A later layer overwrites an earlier one exactly as `merge_value` does, so
-/// what is left is the file whose value survived the merge — the one an
-/// explanation is worth sending a reader to.
+/// A later layer overwrites an earlier one exactly as `merge_value` does, so what is left is the file whose value survived the merge — the one an explanation is worth sending a reader to.
fn merge_layer(
merged: &mut toml::Value,
origins: &mut PolicyOrigins,
@@ -880,13 +832,10 @@ fn merge_layer(
merge_value(merged, layer);
}
-/// How an explanation names a configuration file: relative to the directory
-/// the command was run from when it sits there, and absolute otherwise.
+/// How an explanation names a configuration file: relative to the directory the command was run from when it sits there, and absolute otherwise.
///
-/// The label is repeated on every explained line, so the short spelling is
-/// worth having — but only where it still names the file the reader would open.
-/// A file further up the tree, or the user file under `$HOME`, keeps its
-/// absolute path.
+/// The label is repeated on every explained line, so the short spelling is worth having — but only where it still names the file the reader would open.
+/// A file further up the tree, or the user file under `$HOME`, keeps its absolute path.
fn origin_label(path: &Path, cwd: &Path) -> PathBuf {
path.strip_prefix(cwd).unwrap_or(path).to_path_buf()
}
@@ -998,14 +947,9 @@ fn validate_policy_regexes(config: &Config) -> Result<()> {
);
for pattern in patterns {
regex::bytes::Regex::new(pattern).map_err(|error| {
- /* INVARIANT: Both halves of this line came out of a file in the project: the
- * pattern the caller wrote, and a parse error that quotes that
- * same pattern back with a caret under it. Neither may reach a
- * terminal verbatim, and the line stays one line. The pattern
- * keeps the spacing it was written with, because a reader who is
- * shown something else cannot find it in the file; the parse
- * error, which `regex` spreads over four lines, is folded onto
- * this one and kept whole. */
+ /* INVARIANT: Both halves of this line came out of a file in the project: the pattern the caller wrote, and a parse error that quotes that same pattern back with a caret under it.
+ * Neither may reach a terminal verbatim, and the line stays one line.
+ * The pattern keeps the spacing it was written with, because a reader who is shown something else cannot find it in the file; the parse error, which `regex` spreads over four lines, is folded onto this one and kept whole. */
anyhow!(
"invalid comment policy regex `{}`: {}",
crate::output::sanitize_path(pattern),
@@ -1021,28 +965,20 @@ fn parse_layer(path: &Path, require_version: bool) -> Result {
fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let config: Config = toml::from_str(&text).map_err(|error| {
let message = error.to_string();
- /* INVARIANT: `toml` quotes the line it stopped on, with a caret under the byte
- * that is wrong with it, so the whole of that line — bytes a project
- * file chose, an escape sequence among them — is on its way to a
- * terminal over four lines of diagram. It is folded onto the one line
- * an error is and kept whole, the way an invalid `[policy]` regex is:
- * the caret means nothing once the lines are joined, and the sentence
- * after it is the entire answer. The hint reads the unfolded message
- * because it quotes nothing back — only a key it found in the schema. */
+ /* INVARIANT: `toml` quotes the line it stopped on, with a caret under the byte that is wrong with it, so the whole of that line — bytes a project file chose, an escape sequence among them — is on its way to a terminal over four lines of diagram.
+ * It is folded onto the one line an error is and kept whole, the way an invalid `[policy]` regex is:
+ * the caret means nothing once the lines are joined, and the sentence after it is the entire answer.
+ * The hint reads the unfolded message because it quotes nothing back — only a key it found in the schema. */
anyhow!(
"invalid configuration {}: {}{}",
- /* NOTE: The path is the project's too — a directory it named — so it is
- * held to what every other path in the report is held to: printed
- * as it was spelled, with nothing in it a terminal would act on. */
+ /* NOTE: The path is the project's too — a directory it named — so it is held to what every other path in the report is held to: printed as it was spelled, with nothing in it a terminal would act on. */
crate::output::sanitize_path(&path.display().to_string()),
crate::output::sanitize_message(&message),
unknown_key_hint(&message) + &unknown_value_hint(&message)
)
})?;
if require_version && config.version != Some(1) {
- /* NOTE: The path is repeated deliberately: the first half is the verdict on
- * a file the reader may not have opened, the second is the edit that
- * settles it, and an editor is opened on the second one. */
+ /* NOTE: The path is repeated deliberately: the first half is the verdict on a file the reader may not have opened, the second is the edit that settles it, and an editor is opened on the second one. */
let path = path.display();
bail!("{path} must contain `version = 1` (add `version = 1` at the top of {path})");
}
@@ -1106,12 +1042,9 @@ fn compile_overrides(overrides: &[PathOverride]) -> Result
/// Resolve `.` and `..` without asking the file system.
///
-/// A configuration glob is matched against text, so the text has to be the one
-/// the reader would have written: `../sibling/main.rs`, named from `nested/`,
-/// is `sibling/main.rs` under the root, and leaving the `..` in place would
-/// let it match a `nested/**` override it is not under. The resolution is
-/// lexical because the path need not exist and because `canonicalize` would
-/// also resolve the symbolic links the root itself may be reached through,
+/// A configuration glob is matched against text, so the text has to be the one the reader would have written: `../sibling/main.rs`, named from `nested/`,
+/// is `sibling/main.rs` under the root, and leaving the `..` in place would let it match a `nested/**` override it is not under.
+/// The resolution is lexical because the path need not exist and because `canonicalize` would also resolve the symbolic links the root itself may be reached through,
/// which would leave the two ends of the comparison in different spellings.
pub(crate) fn lexical(path: &Path) -> PathBuf {
let mut resolved = PathBuf::new();
@@ -1147,9 +1080,9 @@ pub fn locate_project(start: &Path) -> Option {
None
}
-/// Locate the nearest repository without starting `git`. A `.git` directory
-/// and the indirection file used by worktrees are both accepted. This keeps
-/// the no-argument command fast while making its scope the current repository.
+/// Locate the nearest repository without starting `git`.
+/// A `.git` directory and the indirection file used by worktrees are both accepted.
+/// This keeps the no-argument command fast while making its scope the current repository.
pub fn locate_repository(start: &Path) -> Option {
let start = if start.is_dir() {
start
@@ -1179,16 +1112,12 @@ fn extend_unique(target: &mut Vec, values: &[T]) {
/// What to say when a value is one this build does not know.
///
-/// A configuration written for a newer OComment reaches an older one as
-/// `unknown variant \`conservative\``. That is accurate and says nothing about
-/// the fix, and the reader cannot work it out: both builds answer `--version`
-/// with the same number for the whole of a release cycle, so neither they nor
-/// the file can tell which binary is running. Naming the build turns "unknown
-/// variant" into "reinstall".
+/// A configuration written for a newer OComment reaches an older one as `unknown variant \`conservative\``.
+/// That is accurate and says nothing about the fix, and the reader cannot work it out: both builds answer `--version` with the same number for the whole of a release cycle, so neither they nor the file can tell which binary is running.
+/// Naming the build turns "unknown variant" into "reinstall".
///
-/// Only for an unknown *value*. An unknown key is a typo far more often than
-/// it is a version skew, and [`unknown_key_hint`] already answers that one by
-/// naming the key the writer meant.
+/// Only for an unknown *value*.
+/// An unknown key is a typo far more often than it is a version skew, and [`unknown_key_hint`] already answers that one by naming the key the writer meant.
fn unknown_value_hint(message: &str) -> String {
if message.contains("unknown variant") {
format!(
@@ -1253,9 +1182,7 @@ mod tests {
assert!(unknown_key_hint(message).contains("layout"));
}
- /// `for_path_traced` must not become a second copy of the merge that can
- /// drift from it: the values it returns are `for_path`'s own, and the trace
- /// beside them lines up with those values position for position.
+ /// `for_path_traced` must not become a second copy of the merge that can drift from it: the values it returns are `for_path`'s own, and the trace beside them lines up with those values position for position.
#[test]
fn a_traced_lookup_returns_the_untraced_answer_and_lines_up_with_it() {
let directory = tempfile::tempdir().unwrap();
@@ -1273,8 +1200,7 @@ mod tests {
config.overrides = vec![PathOverride {
paths: vec!["nested/**".to_owned()],
policy: Some(Policy::All),
- /* NOTE: The duplicate is dropped by the merge, so the trace must not
- * record a source for it either. */
+ /* NOTE: The duplicate is dropped by the merge, so the trace must not record a source for it either. */
keep_regex: vec!["override".to_owned(), "global".to_owned()],
..PathOverride::default()
}];
diff --git a/rust/ocomment/src/coverage.rs b/rust/ocomment/src/coverage.rs
index 429dcf0..3b2e82b 100644
--- a/rust/ocomment/src/coverage.rs
+++ b/rust/ocomment/src/coverage.rs
@@ -1,17 +1,11 @@
//! What a run did not look at.
//!
-//! `check` reports the comments it found and mentions the files it skipped in
-//! a clause at the end of the summary. That clause is a note; the number in it
-//! is not. A repository whose gate says "no removable comments in 143 files"
-//! while 25 files were never opened has a gate over 85% of itself, and nothing
-//! in that sentence says so.
+//! `check` reports the comments it found and mentions the files it skipped in a clause at the end of the summary.
+//! That clause is a note; the number in it is not.
+//! A repository whose gate says "no removable comments in 143 files" while 25 files were never opened has a gate over 85% of itself, and nothing in that sentence says so.
//!
-//! This is the other half of the report: every file the walk reached, grouped
-//! by whether it was scanned and by why it was not, with the extensions that
-//! account for the gaps named in the order they matter. It exists to be acted
-//! on — the answer to "unknown language: 9 `.json`" is a decision about
-//! `.json`, and the decision cannot be made by someone who does not know the
-//! nine are there.
+//! This is the other half of the report: every file the walk reached, grouped by whether it was scanned and by why it was not, with the extensions that account for the gaps named in the order they matter.
+//! It exists to be acted on — the answer to "unknown language: 9 `.json`" is a decision about `.json`, and the decision cannot be made by someone who does not know the nine are there.
use crate::{
files::{NotWalked, SkippedFile, SourceFile},
@@ -27,10 +21,8 @@ use std::{
/// How many extensions a human listing names per reason before it stops.
///
-/// The list is there to be acted on and a reader acts on the common ones
-/// first, so the tail is summarised rather than printed. `--format json`
-/// carries all of them, because a tool reading it is not the one getting
-/// tired.
+/// The list is there to be acted on and a reader acts on the common ones first, so the tail is summarised rather than printed.
+/// `--format json` carries all of them, because a tool reading it is not the one getting tired.
const TOP_EXTENSIONS: usize = 8;
/// The files of one run, split by what happened to them.
@@ -38,20 +30,14 @@ const TOP_EXTENSIONS: usize = 8;
pub struct Coverage {
/// What was scanned, split by which reader answered for it.
///
- /// A single count said how many files were read and could not say what
- /// read them. That is the one thing this report cannot leave out: when a
- /// release teaches the tool a format it used to pass over, the files it
- /// newly reads move from a skip reason into this total, and a reader
- /// looking at the number alone sees a repository that grew. Keyed by
- /// [`ReadBy`], whose ordering puts the built-in languages before the
- /// profiles and plugins that were added to reach past them.
+ /// A single count said how many files were read and could not say what read them.
+ /// That is the one thing this report cannot leave out: when a release teaches the tool a format it used to pass over, the files it newly reads move from a skip reason into this total, and a reader looking at the number alone sees a repository that grew.
+ /// Keyed by [`ReadBy`], whose ordering puts the built-in languages before the profiles and plugins that were added to reach past them.
scanned: BTreeMap,
- /// Reason label to how many files it accounts for, and for which
- /// extensions.
+ /// Reason label to how many files it accounts for, and for which extensions.
skipped: BTreeMap,
- /// The files the walk's own limits kept out, which nothing met and so
- /// nothing reported. Counted in the total, because a percentage of what a
- /// gate happened to walk is not a percentage of anything.
+ /// The files the walk's own limits kept out, which nothing met and so nothing reported.
+ /// Counted in the total, because a percentage of what a gate happened to walk is not a percentage of anything.
not_walked: BTreeMap,
io_errors: usize,
}
@@ -62,12 +48,9 @@ struct Group {
extensions: BTreeMap,
}
-/// How a file is named in the listing: its extension, or its whole file name
-/// when it has none.
+/// How a file is named in the listing: its extension, or its whole file name when it has none.
///
-/// A `dune` file and a `CODEOWNERS` file have no extension and are exactly the
-/// kind of file this listing exists to surface, so falling back to the name is
-/// what keeps them from collapsing into one anonymous bucket.
+/// A `dune` file and a `CODEOWNERS` file have no extension and are exactly the kind of file this listing exists to surface, so falling back to the name is what keeps them from collapsing into one anonymous bucket.
fn label_of(path: &Path) -> String {
path.extension()
.and_then(|value| value.to_str())
@@ -118,13 +101,10 @@ impl Coverage {
coverage
}
- /// Every file in the tree: the ones the walk reached, and the ones its
- /// own limits kept out.
+ /// Every file in the tree: the ones the walk reached, and the ones its own limits kept out.
///
- /// The second half is what makes the number mean something. Counting only
- /// what the walk reached gives a percentage of the walk rather than of the
- /// repository, and a run that walked three of seven files then reports
- /// `100.0%` -- which is true and is a false assurance.
+ /// The second half is what makes the number mean something.
+ /// Counting only what the walk reached gives a percentage of the walk rather than of the repository, and a run that walked three of seven files then reports `100.0%` -- which is true and is a false assurance.
fn total(&self) -> usize {
self.scanned()
+ self.io_errors
@@ -136,19 +116,12 @@ impl Coverage {
.sum::()
}
- /// The share that was scanned, in tenths of a percent, or 1000 when there
- /// was nothing to scan.
+ /// The share that was scanned, in tenths of a percent, or 1000 when there was nothing to scan.
///
- /// An empty walk covers everything it was given, which is not a useful
- /// number but is the only honest one; reporting 0% for it would read as a
- /// failure that has not happened.
+ /// An empty walk covers everything it was given, which is not a useful number but is the only honest one; reporting 0% for it would read as a failure that has not happened.
///
- /// Tenths rather than a float because the only thing this number is for is
- /// being printed to one decimal place, and integer arithmetic gets there
- /// without a lossy conversion to apologise for. The machine format leaves
- /// it out entirely: it carries `scanned` and `files`, and a caller that
- /// wants a ratio can divide two exact numbers rather than parse a rounded
- /// one.
+ /// Tenths rather than a float because the only thing this number is for is being printed to one decimal place, and integer arithmetic gets there without a lossy conversion to apologise for.
+ /// The machine format leaves it out entirely: it carries `scanned` and `files`, and a caller that wants a ratio can divide two exact numbers rather than parse a rounded one.
fn tenths_of_a_percent(&self) -> usize {
let total = self.total();
if total == 0 {
@@ -164,10 +137,8 @@ impl Coverage {
/// How a group of files is named by what read them.
///
- /// The built-in arm names no language, unlike the per-file label: this
- /// listing groups every built-in scanner together, because the question it
- /// answers is which files needed something beyond them. The extensions
- /// under each line say which files those were.
+ /// The built-in arm names no language, unlike the per-file label: this listing groups every built-in scanner together, because the question it answers is which files needed something beyond them.
+ /// The extensions under each line say which files those were.
fn reader_label(read_by: &ReadBy) -> String {
match *read_by {
ReadBy::Language => "read by a built-in language".to_owned(),
@@ -180,8 +151,7 @@ impl Coverage {
/// List the kinds of file one group accounts for, most first.
fn names_of(out: &mut impl Write, group: &Group) -> Result<()> {
let mut names: Vec<_> = group.extensions.iter().collect();
- /* NOTE: Most files first, and the name as the tie-break so that two runs
- * over the same tree print the same listing. */
+ /* NOTE: Most files first, and the name as the tie-break so that two runs over the same tree print the same listing. */
names.sort_by(|left, right| right.1.cmp(left.1).then(left.0.cmp(right.0)));
for (name, count) in names.iter().take(TOP_EXTENSIONS) {
wrote(writeln!(out, " {count:>4} {name}"))?;
@@ -210,10 +180,8 @@ pub fn render(coverage: &Coverage, format: OutputFormat) -> Result<()> {
.iter()
.map(|(read_by, group)| {
/* NOTE: `kind` without a `name` is the built-in group,
- * which spans every language the walk met and so has no
- * single one to name. Naming one anyway -- `unknown`
- * being the only candidate -- would be the same false
- * answer this field exists to stop giving. */
+ * which spans every language the walk met and so has no single one to name.
+ * Naming one anyway -- `unknown` being the only candidate -- would be the same false answer this field exists to stop giving. */
let (kind, name) = match *read_by {
ReadBy::Language => ("language", None),
ReadBy::Profile(ref name) => ("profile", Some(name)),
@@ -286,11 +254,8 @@ pub fn render(coverage: &Coverage, format: OutputFormat) -> Result<()> {
tenths / 10,
tenths % 10
))?;
- /* NOTE: Only when more than one reader answered. A run every
- * built-in language read is a run where naming the reader adds a
- * line and no information; the listing is here for the run where a
- * profile or a plugin read files nothing else would have, which is
- * exactly the run whose coverage just changed under its reader. */
+ /* NOTE: Only when more than one reader answered.
+ * A run every built-in language read is a run where naming the reader adds a line and no information; the listing is here for the run where a profile or a plugin read files nothing else would have, which is exactly the run whose coverage just changed under its reader. */
if coverage.scanned.len() > 1 {
for (read_by, group) in &coverage.scanned {
wrote(writeln!(
@@ -316,20 +281,15 @@ pub fn render(coverage: &Coverage, format: OutputFormat) -> Result<()> {
/// The skip reasons a run refuses to pass over, when it was told to refuse.
///
-/// Not every skip is a hole. A binary file has no comments to miss and a file
-/// the configuration excluded was excluded on purpose; both are decisions
-/// already taken. An unreadable file and a file in a language nothing here
-/// knows are different: those are files the gate was meant to cover and did
-/// not, and a run that is supposed to be a gate should be able to say so with
-/// its exit status rather than in a note.
+/// Not every skip is a hole.
+/// A binary file has no comments to miss and a file the configuration excluded was excluded on purpose; both are decisions already taken.
+/// An unreadable file and a file in a language nothing here knows are different: those are files the gate was meant to cover and did not, and a run that is supposed to be a gate should be able to say so with its exit status rather than in a note.
pub fn denied(skipped: &[SkippedFile], reasons: &[&str]) -> Vec {
skipped
.iter()
.filter(|item| {
- /* NOTE: Named through the enum rather than as a literal. It is
- * the one label no `skip_label` answer produces -- an unreadable
- * file carries the I/O error as its reason -- so a literal here
- * would be the one spelling nothing held to the others. */
+ /* NOTE: Named through the enum rather than as a literal.
+ * It is the one label no `skip_label` answer produces -- an unreadable file carries the I/O error as its reason -- so a literal here would be the one spelling nothing held to the others. */
let label = if item.error {
SkipReason::Unreadable.label()
} else {
diff --git a/rust/ocomment/src/deadline.rs b/rust/ocomment/src/deadline.rs
index 22ea6d3..4922133 100644
--- a/rust/ocomment/src/deadline.rs
+++ b/rust/ocomment/src/deadline.rs
@@ -1,23 +1,17 @@
//! Deadlines on the tags that are promises.
//!
-//! `[policy.allow] tags` keeps a comment for the tag it opens with, and that
-//! is the right rule for a `SAFETY` — it records why the code is the way it
-//! is, and it is true for as long as the code is. It is the wrong rule for a
-//! `TODO`, which says somebody will do something. Keeping one forever is how a
-//! repository ends up with a `TODO` from four years ago that everybody has
-//! learned to read past; forbidding one outright loses the note along with the
-//! nagging, and nobody obeys it anyway.
+//! `[policy.allow] tags` keeps a comment for the tag it opens with, and that is the right rule for a `SAFETY` — it records why the code is the way it is, and it is true for as long as the code is.
+//! It is the wrong rule for a `TODO`, which says somebody will do something.
+//! Keeping one forever is how a repository ends up with a `TODO` from four years ago that everybody has learned to read past; forbidding one outright loses the note along with the nagging, and nobody obeys it anyway.
//!
-//! `[policy.allow.expiry]` is the third answer. Write the `TODO`, commit it,
-//! and it is fine — for a fortnight. After that it is a finding, with the
-//! reason spelled out and the age counted, every run, until somebody either
-//! does it or deletes it.
+//! `[policy.allow.expiry]` is the third answer.
+//! Write the `TODO`, commit it,
+//! and it is fine — for a fortnight.
+//! After that it is a finding, with the reason spelled out and the age counted, every run, until somebody either does it or deletes it.
//!
-//! The clock is the repository's: the age of a line is the age of the commit
-//! that introduced it, read from `git blame`. That is why this lives here and
-//! not in `ocomment-core`, which performs no I/O. The core owns the vocabulary
-//! — [`ShapeRule::Expired`] — so a verdict reached here is reported through
-//! the same channel every other verdict is.
+//! The clock is the repository's: the age of a line is the age of the commit that introduced it, read from `git blame`.
+//! That is why this lives here and not in `ocomment-core`, which performs no I/O.
+//! The core owns the vocabulary — [`ShapeRule::Expired`] — so a verdict reached here is reported through the same channel every other verdict is.
use anyhow::Result;
use ocomment_core::{Age, AllowRules, ScanReport, ShapeRule};
@@ -49,8 +43,8 @@ impl Overdue {
/// The sentence a run writes about them, or `None` when there were none.
///
- /// Deliberately not phrased as a summary. A deadline that passed is not a
- /// statistic about the run; it is a thing somebody said they would do.
+ /// Deliberately not phrased as a summary.
+ /// A deadline that passed is not a statistic about the run; it is a thing somebody said they would do.
pub fn note(&self) -> Option {
let total = self.total();
if total == 0 {
@@ -74,15 +68,10 @@ impl Overdue {
/// Take back the keeps whose deadline has passed.
///
-/// `source` is the exact content `report` describes, which need not be what is
-/// on the disk: a staged run judges an index blob and an editing hook judges
-/// an edit that has not happened yet, and `git blame --contents` answers for
-/// either. A line those bytes introduced is attributed to no commit and has
-/// therefore not started its deadline — which is the whole of "writing one
-/// costs nothing".
+/// `source` is the exact content `report` describes, which need not be what is on the disk: a staged run judges an index blob and an editing hook judges an edit that has not happened yet, and `git blame --contents` answers for either.
+/// A line those bytes introduced is attributed to no commit and has therefore not started its deadline — which is the whole of "writing one costs nothing".
///
-/// Nothing is measured, and no process is started, unless a comment this file
-/// actually holds carries a tag the configuration gave a deadline to.
+/// Nothing is measured, and no process is started, unless a comment this file actually holds carries a tag the configuration gave a deadline to.
pub fn apply(
root: &Path,
path: &Path,
@@ -99,8 +88,8 @@ pub fn apply(
.comments
.iter()
.enumerate()
- .filter(|(_, comment)| match &comment.shape {
- Some(ShapeRule::Tagged { tag }) => rules.expiry.contains_key(tag),
+ .filter(|(_, comment)| match comment.shape() {
+ Some(ShapeRule::Tagged { tag }) => rules.expiry.contains_key(tag.as_str()),
_ => false,
})
.map(|(index, _)| index)
@@ -108,19 +97,18 @@ pub fn apply(
if candidates.is_empty() {
return Ok(overdue);
}
- /* NOTE: No repository, no git, an untracked file: all of them mean the age
- * cannot be read, and a deadline nobody can measure has not passed. The
- * comment keeps the benefit of the doubt. */
+ /* NOTE: No repository, no git, an untracked file: all of them mean the age cannot be read, and a deadline nobody can measure has not passed.
+ * The comment keeps the benefit of the doubt. */
let Some(ages) = line_ages(root, path, source, now) else {
return Ok(overdue);
};
let lines = LineIndex::new(source);
for index in candidates {
let comment = &mut report.comments[index];
- let Some(ShapeRule::Tagged { tag }) = &comment.shape else {
+ let Some(ShapeRule::Tagged { tag }) = comment.shape() else {
continue;
};
- let limit = rules.expiry[tag];
+ let limit = rules.expiry[tag.as_str()];
let Some(age) = ages.get(&lines.line_of(comment.span.start)).copied() else {
continue;
};
@@ -129,18 +117,17 @@ pub fn apply(
}
let tag = tag.clone();
*overdue.by_tag.entry(tag.clone()).or_default() += 1;
- let rule = ShapeRule::Expired { tag, age, limit };
- comment.disposition = rule.disposition();
- comment.shape = Some(rule);
+ /* NOTE: One call, both halves.
+ * The verdict and the rule used to be written here as two statements, which is two chances to write a pair that disagree. */
+ comment.decide_by_shape(ShapeRule::Expired { tag, age, limit });
}
Ok(overdue)
}
/// The age of every line of `source`, by 1-based line number.
///
-/// `None` means the question could not be asked. A line attributed to no
-/// commit — one these bytes introduce — is absent from the map rather than
-/// recorded as new, so a caller that finds nothing leaves the comment alone.
+/// `None` means the question could not be asked.
+/// A line attributed to no commit — one these bytes introduce — is absent from the map rather than recorded as new, so a caller that finds nothing leaves the comment alone.
fn line_ages(
root: &Path,
path: &Path,
@@ -149,10 +136,8 @@ fn line_ages(
) -> Option> {
let blame = blame(root, path, source)?;
let now = i64::try_from(now.duration_since(UNIX_EPOCH).ok()?.as_secs()).ok()?;
- /* NOTE: Read in one pass and joined afterwards, because the porcelain form
- * announces a commit's date only the first time that commit is seen and
- * repeats the bare header for every group after it. Neither half can wait
- * for the other in a single sweep. */
+ /* NOTE: Read in one pass and joined afterwards, because the porcelain form announces a commit's date only the first time that commit is seen and repeats the bare header for every group after it.
+ * Neither half can wait for the other in a single sweep. */
let mut times: HashMap<&str, i64> = HashMap::new();
let mut groups: Vec<(&str, usize, usize)> = Vec::new();
for line in blame.lines() {
@@ -176,9 +161,8 @@ fn line_ages(
/// A porcelain group header: ` []`.
///
-/// Every other line of the form is a key and a value, so a first field of
-/// exactly forty hex digits is what separates the two. The count is written
-/// only the first time a group is announced, and one line is the default.
+/// Every other line of the form is a key and a value, so a first field of exactly forty hex digits is what separates the two.
+/// The count is written only the first time a group is announced, and one line is the default.
fn blame_header(line: &str) -> Option<(&str, usize, usize)> {
let mut fields = line.split(' ');
let sha = fields.next()?;
@@ -195,9 +179,7 @@ fn blame_header(line: &str) -> Option<(&str, usize, usize)> {
/// Record one blame group's age against each line it covers.
///
-/// A commit dated in the future — a clock that disagrees, a rebase — is
-/// recorded as no age at all rather than as a negative one, so it cannot make
-/// a deadline pass early or, worse, never.
+/// A commit dated in the future — a clock that disagrees, a rebase — is recorded as no age at all rather than as a negative one, so it cannot make a deadline pass early or, worse, never.
fn record(ages: &mut HashMap, first: usize, count: usize, seconds: i64, now: i64) {
let days = u32::try_from((now - seconds).max(0) / 86_400).unwrap_or(u32::MAX);
for line in first..first.saturating_add(count) {
@@ -205,8 +187,7 @@ fn record(ages: &mut HashMap, first: usize, count: usize, seconds: i
}
}
-/// `git blame --porcelain` over `source`, judged against the history of
-/// `path`.
+/// `git blame --porcelain` over `source`, judged against the history of `path`.
fn blame(root: &Path, path: &Path, source: &[u8]) -> Option {
let mut child = Command::new("git")
.current_dir(root)
diff --git a/rust/ocomment/src/files.rs b/rust/ocomment/src/files.rs
index 2d3f245..d0c62eb 100644
--- a/rust/ocomment/src/files.rs
+++ b/rust/ocomment/src/files.rs
@@ -17,27 +17,24 @@ pub struct SourceFile {
pub source: Vec,
pub language: Language,
pub dialect: Dialect,
- /// The path-resolved policy and layout used for this source. Resolution is
- /// part of discovery so the parallel scan does not repeat it.
+ /// The path-resolved policy and layout used for this source.
+ /// Resolution is part of discovery so the parallel scan does not repeat it.
pub options: TransformOptions,
pub profile: Option,
pub plugin: Option,
/// What decided the language: `extension`, `reserved-filename`, `shebang`,
/// `content`, `command-line`, or `configuration-routing`.
///
- /// Detection already answers this and the answer was being dropped. It is
- /// the first thing a run that scanned a file as the wrong language needs,
+ /// Detection already answers this and the answer was being dropped.
+ /// It is the first thing a run that scanned a file as the wrong language needs,
/// and the only place it can come from is the decision itself.
pub detection: &'static str,
}
impl SourceFile {
- /// What will read this file: the language that was detected, or the
- /// profile or plugin that claimed it when no language did.
+ /// What will read this file: the language that was detected, or the profile or plugin that claimed it when no language did.
///
- /// Routing already decided this and every caller was re-deriving it, or
- /// -- more often -- dropping it and reporting the `Language::Unknown`
- /// that a profile-read file necessarily carries.
+ /// Routing already decided this and every caller was re-deriving it, or -- more often -- dropping it and reporting the `Language::Unknown` that a profile-read file necessarily carries.
pub fn read_by(&self) -> ReadBy {
match (&self.profile, &self.plugin) {
(Some(profile), _) => ReadBy::Profile(profile.name.clone()),
@@ -52,9 +49,8 @@ pub struct SkippedFile {
pub path: PathBuf,
pub reason: String,
pub error: bool,
- /// The path itself was named on the command line. Such a skip is always
- /// reported on its own line; a skip found while walking a directory is
- /// folded into the end-of-run summary instead.
+ /// The path itself was named on the command line.
+ /// Such a skip is always reported on its own line; a skip found while walking a directory is folded into the end-of-run summary instead.
pub explicit: bool,
}
@@ -62,16 +58,15 @@ pub struct SkippedFile {
pub struct Discovery {
pub files: Vec,
pub skipped: Vec,
- /// A configuration resolution failure applies to the run rather than to
- /// one unreadable path. It is carried out of the walk and returned after
- /// traversal unwinds, instead of being reported as an I/O skip.
+ /// A configuration resolution failure applies to the run rather than to one unreadable path.
+ /// It is carried out of the walk and returned after traversal unwinds, instead of being reported as an I/O skip.
fatal: Option,
}
impl Discovery {
- /// Fold one answer in. The first configuration failure is the one reported:
- /// they are all the same failure, and a run that listed it once per file
- /// would bury it.
+ /// Fold one answer in.
+ /// The first configuration failure is the one reported:
+ /// they are all the same failure, and a run that listed it once per file would bury it.
fn absorb(&mut self, looked: Looked) {
match looked {
Looked::Nothing => {}
@@ -86,29 +81,23 @@ impl Discovery {
}
}
-/// The path standard input is reported under. It is not a real file name: the
-/// renderers print it, and the configuration override matcher sees it, exactly
-/// as it reads here.
+/// The path standard input is reported under.
+/// It is not a real file name: the renderers print it, and the configuration override matcher sees it, exactly as it reads here.
pub const STDIN_PATH: &str = "";
-/// What both `strip` and a `-` target say when the bytes carry no signature to
-/// detect a language from. Standard input has no name to fall back on, so the
-/// only way forward is for the caller to name the language.
+/// What both `strip` and a `-` target say when the bytes carry no signature to detect a language from.
+/// Standard input has no name to fall back on, so the only way forward is for the caller to name the language.
pub const STDIN_LANGUAGE_HELP: &str = "cannot detect the language of standard input; \
pass --language (see `ocomment languages`)";
-/// Why a file OComment has no scanner for is passed over, and the two ways out
-/// of it: consult the list of what is built in, or name a language anyway.
+/// Why a file OComment has no scanner for is passed over, and the two ways out of it: consult the list of what is built in, or name a language anyway.
///
-/// The end-of-run summary must not repeat this sentence once per file, so it
-/// folds the reason onto a short key of its own; `output::skip_label` is what
-/// ties the two together.
+/// The end-of-run summary must not repeat this sentence once per file, so it folds the reason onto a short key of its own; `output::skip_label` is what ties the two together.
pub const NO_LANGUAGE: &str =
"no built-in language for this file (see `ocomment languages`; use --language to force)";
-/// Why a named path was not found. A relative path is resolved against the
-/// working directory, which is exactly what a caller who typed it from the
-/// wrong place cannot see, so the directory that was searched is named.
+/// Why a named path was not found.
+/// A relative path is resolved against the working directory, which is exactly what a caller who typed it from the wrong place cannot see, so the directory that was searched is named.
fn missing_path_reason() -> String {
env::current_dir().map_or_else(
|_| "path does not exist".to_owned(),
@@ -121,13 +110,10 @@ fn missing_path_reason() -> String {
)
}
-/// Turn the bytes read from standard input into a source file the ordinary
-/// pipeline can process, or the skip that says why it cannot. Detection has no
-/// path to work with, so it is driven by `--language` or by the contents.
+/// Turn the bytes read from standard input into a source file the ordinary pipeline can process, or the skip that says why it cannot.
+/// Detection has no path to work with, so it is driven by `--language` or by the contents.
///
-/// Declarative profiles and plugins route on a file extension, which standard
-/// input does not have; a pipe is therefore always handled by a built-in
-/// language or not at all.
+/// Declarative profiles and plugins route on a file extension, which standard input does not have; a pipe is therefore always handled by a built-in language or not at all.
pub fn stdin_source(
bytes: Vec,
resolved: &ResolvedConfig,
@@ -138,8 +124,7 @@ pub fn stdin_source(
path: PathBuf::from(STDIN_PATH),
reason: reason.to_owned(),
error,
- /* NOTE: Standard input was named on the command line, so its skip is always
- * reported on its own line rather than folded into the summary. */
+ /* NOTE: Standard input was named on the command line, so its skip is always reported on its own line rather than folded into the summary. */
explicit: true,
};
if bytes.iter().take(8192).any(|byte| *byte == 0) {
@@ -184,25 +169,18 @@ pub fn stdin_source(
/// What a command with no PATH walks.
///
-/// The project root is where the configuration was found, not what the caller
-/// is looking at: a command run from a subdirectory checks that subdirectory,
-/// the way every other file-walking developer tool does. Reaching back up to
-/// the root would put files the caller cannot see — and, with `fix`, files
-/// they did not mean to rewrite — into the run.
+/// The project root is where the configuration was found, not what the caller is looking at: a command run from a subdirectory checks that subdirectory,
+/// the way every other file-walking developer tool does.
+/// Reaching back up to the root would put files the caller cannot see — and, with `fix`, files they did not mean to rewrite — into the run.
pub const DEFAULT_TARGET: &str = ".";
/// The one name a walk never offers, whatever else was asked for.
///
-/// `.git` is git's own storage rather than source, and `git` itself never
-/// treats it as a candidate for anything. Neither may a tool that rewrites
-/// files in place: `ocomment fix .` in a fresh repository would otherwise
-/// rewrite every sample hook git had just written into `.git/hooks`. Naming
-/// a directory lifts the hidden-file rule and so does `files.hidden`, so the
-/// exclusion cannot hang off either of them.
+/// `.git` is git's own storage rather than source, and `git` itself never treats it as a candidate for anything.
+/// Neither may a tool that rewrites files in place: `ocomment fix .` in a fresh repository would otherwise rewrite every sample hook git had just written into `.git/hooks`.
+/// Naming a directory lifts the hidden-file rule and so does `files.hidden`, so the exclusion cannot hang off either of them.
///
-/// A submodule or a linked worktree keeps its `.git` as a *file* pointing at
-/// the storage instead of holding it, which is why the name is matched rather
-/// than the file type.
+/// A submodule or a linked worktree keeps its `.git` as a *file* pointing at the storage instead of holding it, which is why the name is matched rather than the file type.
const GIT_DIRECTORY: &str = ".git";
pub fn discover(
@@ -212,9 +190,7 @@ pub fn discover(
forced_dialect: Option,
) -> Result {
let implicit = [PathBuf::from(DEFAULT_TARGET)];
- /* NOTE: The substituted target stands in for an argument nobody typed, so it is
- * walked with the ordinary limits: only a path the caller actually named
- * is a request to look past the hidden-file and size rules. */
+ /* NOTE: The substituted target stands in for an argument nobody typed, so it is walked with the ordinary limits: only a path the caller actually named is a request to look past the hidden-file and size rules. */
let (paths, explicit) = if paths.is_empty() {
(&implicit[..], false)
} else {
@@ -223,17 +199,15 @@ pub fn discover(
discover_with_scope(paths, resolved, forced_language, forced_dialect, explicit)
}
-/// Discover workspace roots with normal traversal limits. Unlike explicit CLI
-/// paths, an LSP workspace folder must still honor hidden-file and size rules.
+/// Discover workspace roots with normal traversal limits.
+/// Unlike explicit CLI paths, an LSP workspace folder must still honor hidden-file and size rules.
pub fn discover_workspace(paths: &[PathBuf], resolved: &ResolvedConfig) -> Result {
discover_with_scope(paths, resolved, None, None, false)
}
/// The same, with a language and dialect the caller forced.
///
-/// `--base` uses this: the paths come from Git rather than from the caller, so
-/// they are walked under the ordinary limits, but a `--language` on the same
-/// command line still has to reach them.
+/// `--base` uses this: the paths come from Git rather than from the caller, so they are walked under the ordinary limits, but a `--language` on the same command line still has to reach them.
pub fn discover_workspace_with(
paths: &[PathBuf],
resolved: &ResolvedConfig,
@@ -243,17 +217,13 @@ pub fn discover_workspace_with(
discover_with_scope(paths, resolved, forced_language, forced_dialect, false)
}
-/// One file's worth of bytes, judged as though they were the contents of
-/// `path`.
+/// One file's worth of bytes, judged as though they were the contents of `path`.
///
-/// The path decides everything about the judgement — the language, the
-/// `[[overrides]]` that apply, whether the file is excluded at all — and the
-/// bytes are the ones the caller is proposing to put there. That pair is what
-/// a pre-write hook has and what nothing else in this module accepts: a walk
-/// reads the bytes off the disk, and `-` has bytes with no name.
+/// The path decides everything about the judgement — the language, the `[[overrides]]` that apply, whether the file is excluded at all — and the bytes are the ones the caller is proposing to put there.
+/// That pair is what a pre-write hook has and what nothing else in this module accepts: a walk reads the bytes off the disk, and `-` has bytes with no name.
///
-/// The returned [`Discovery`] holds the one file, or the one skip that says
-/// why there is nothing to judge. `path` is never opened.
+/// The returned [`Discovery`] holds the one file, or the one skip that says why there is nothing to judge.
+/// `path` is never opened.
pub fn proposed_source(
path: &Path,
bytes: Vec,
@@ -317,10 +287,8 @@ fn discover_with_scope(
.map(|path| (path, explicit_arguments))
.collect()
};
- /* NOTE: Every candidate the walk finds, gathered before any of them is
- * opened. Traversal is one thread's job and reading a thousand files is
- * not, so the two are separated: the walk names them, and `load_one`
- * answers for all of them at once below. */
+ /* NOTE: Every candidate the walk finds, gathered before any of them is opened.
+ * Traversal is one thread's job and reading a thousand files is not, so the two are separated: the walk names them, and `load_one` answers for all of them at once below. */
let mut candidates: Vec<(PathBuf, bool, bool)> = Vec::new();
for (path, explicit_scope) in targets {
if path.is_file()
@@ -335,8 +303,7 @@ fn discover_with_scope(
builder
.follow_links(resolved.config.files.follow_symlinks)
.standard_filters(ignore)
- /* NOTE: `standard_filters` also resets the hidden-file flag, so this
- * must come afterwards for explicitly named directories. */
+ /* NOTE: `standard_filters` also resets the hidden-file flag, so this must come afterwards for explicitly named directories. */
.hidden(!explicit_scope && !resolved.config.files.hidden)
.git_ignore(ignore)
.git_global(ignore)
@@ -346,15 +313,10 @@ fn discover_with_scope(
if ignore {
builder.add_custom_ignore_filename(".ocommentignore");
}
- /* NOTE: The filter is never asked about the walk root, so a caller who
- * names a path inside `.git` — or `.git` itself — is still
- * answered; only what a walk *wanders* into is excluded. */
+ /* NOTE: The filter is never asked about the walk root, so a caller who names a path inside `.git` — or `.git` itself — is still answered; only what a walk *wanders* into is excluded. */
builder.filter_entry(|entry| entry.file_name() != GIT_DIRECTORY);
- /* NOTE: `ignore` reads a directory per thread and answers out of
- * order, so the candidates are sorted before they are looked at
- * and the report comes out in the same order on every run. A walk
- * whose output depended on how the scheduler felt would be a walk
- * whose diffs could not be reviewed. */
+ /* NOTE: `ignore` reads a directory per thread and answers out of order, so the candidates are sorted before they are looked at and the report comes out in the same order on every run.
+ * A walk whose output depended on how the scheduler felt would be a walk whose diffs could not be reviewed. */
builder.threads(rayon::current_num_threads());
let found = std::sync::Mutex::new(Vec::new());
let failed = std::sync::Mutex::new(Vec::new());
@@ -399,11 +361,8 @@ fn discover_with_scope(
});
}
}
- /* NOTE: Read and classified in parallel, folded in the order the
- * candidates were gathered. Reading is where the time goes -- a walk over
- * a large repository is thousands of `open`, `read`, `close` and a
- * language detection each -- and it is the part that has no reason to
- * happen one file at a time. */
+ /* NOTE: Read and classified in parallel, folded in the order the candidates were gathered.
+ * Reading is where the time goes -- a walk over a large repository is thousands of `open`, `read`, `close` and a language detection each -- and it is the part that has no reason to happen one file at a time. */
for looked in candidates
.par_iter()
.map(|(path, explicit_scope, explicit_path)| {
@@ -422,14 +381,9 @@ fn discover_with_scope(
discovery
.files
.dedup_by(|left, right| left.path == right.path);
- /* INVARIANT: A path is reached twice whenever it is named beside a directory holding
- * it, and it is one file either way: `files` says so with the sort and the
- * dedup above, and a skip is one file just as much — a report that
- * annotates the same path twice reads as two problems with it. Which of
- * the two entries survives is not arbitrary. An error decides the exit
- * code, and a path the caller actually typed is answered on a line of its
- * own rather than folded into the summary, so the entry that says the most
- * is sorted to the front of its path and is the one the dedup keeps. */
+ /* INVARIANT: A path is reached twice whenever it is named beside a directory holding it, and it is one file either way: `files` says so with the sort and the dedup above, and a skip is one file just as much — a report that annotates the same path twice reads as two problems with it.
+ * Which of the two entries survives is not arbitrary.
+ * An error decides the exit code, and a path the caller actually typed is answered on a line of its own rather than folded into the summary, so the entry that says the most is sorted to the front of its path and is the one the dedup keeps. */
discovery.skipped.sort_by(|left, right| {
left.path
.cmp(&right.path)
@@ -444,11 +398,10 @@ fn discover_with_scope(
/// The name a walked file is reported under.
///
-/// The implicit target is `.`, so a walk rooted there hands back every entry
-/// as `./name`. `ocomment` and `ocomment check name` report one file, and a
-/// reader — or a `git apply` reading the patch — is owed one spelling of it,
-/// so the prefix the walk root contributed is dropped. The target itself is
-/// left alone: `.` names a directory, and `` names nothing.
+/// The implicit target is `.`, so a walk rooted there hands back every entry as `./name`.
+/// `ocomment` and `ocomment check name` report one file, and a reader — or a `git apply` reading the patch — is owed one spelling of it,
+/// so the prefix the walk root contributed is dropped.
+/// The target itself is left alone: `.` names a directory, and `` names nothing.
fn reported_path(path: &Path) -> PathBuf {
match path.strip_prefix(DEFAULT_TARGET) {
Ok(stripped) if !stripped.as_os_str().is_empty() => stripped.to_path_buf(),
@@ -469,16 +422,13 @@ struct LoadContext<'a> {
/// What looking at one path produced.
///
-/// Returned rather than pushed, so that looking at a path is a pure function
-/// of the path and the configuration — which is what lets a walk look at a
-/// thousand of them at once and fold the answers in one deterministic order.
+/// Returned rather than pushed, so that looking at a path is a pure function of the path and the configuration — which is what lets a walk look at a thousand of them at once and fold the answers in one deterministic order.
enum Looked {
/// Excluded by a glob, or not a file at all.
Nothing,
Found(Box),
Passed(SkippedFile),
- /// A configuration failure, which applies to the run rather than to this
- /// path.
+ /// A configuration failure, which applies to the run rather than to this path.
Fatal(anyhow::Error),
}
@@ -495,9 +445,7 @@ fn load_one(
..
} = context;
let path = &reported_path(path);
- /* NOTE: The globs are written relative to the root; the path was typed — or
- * walked — relative to the working directory, so it is measured against
- * the root before either set is asked about it. */
+ /* NOTE: The globs are written relative to the root; the path was typed — or walked — relative to the working directory, so it is measured against the root before either set is asked about it. */
let relative = resolved.relative_to_root(path);
if (!include.is_empty() && !include.is_match(&relative)) || exclude.is_match(&relative) {
return Looked::Nothing;
@@ -527,8 +475,7 @@ fn load_one(
} else {
link_metadata
};
- /* NOTE: Every path under an explicitly named directory is explicit for hidden and
- * size handling. */
+ /* NOTE: Every path under an explicitly named directory is explicit for hidden and size handling. */
if !explicit_scope && metadata.len() > resolved.config.files.max_size {
return Looked::Passed(SkippedFile {
path: path.to_path_buf(),
@@ -548,11 +495,7 @@ fn load_one(
/// Everything deciding one file's fate that does not depend on reading it.
///
-/// Split out from [`load_one`] because the bytes and the path are separable
-/// questions: [`proposed_source`] has a path that exists and contents that do
-/// not, and every rule below — the binary test, the generated catalogue, the
-/// language, the overrides, the profile and plugin routing — has to reach the
-/// same answer for it that a walk would reach for the file once it is written.
+/// Split out from [`load_one`] because the bytes and the path are separable questions: [`proposed_source`] has a path that exists and contents that do not, and every rule below — the binary test, the generated catalogue, the language, the overrides, the profile and plugin routing — has to reach the same answer for it that a walk would reach for the file once it is written.
/// Two copies of this would be two answers.
fn classify(
path: &Path,
@@ -575,11 +518,8 @@ fn classify(
explicit: explicit_path,
});
}
- /* NOTE: Before the language is chosen, because this is not a question about
- * what the file is written in. A lock file is perfectly readable TOML and a
- * recorded seed list is perfectly readable prose; what makes them skippable
- * is that the comments in them belong to the tool that will write them
- * again. */
+ /* NOTE: Before the language is chosen, because this is not a question about what the file is written in.
+ * A lock file is perfectly readable TOML and a recorded seed list is perfectly readable prose; what makes them skippable is that the comments in them belong to the tool that will write them again. */
if !resolved.config.files.include_generated && generated.claims(path, &source) {
return Looked::Passed(SkippedFile {
path: path.to_path_buf(),
@@ -664,9 +604,8 @@ pub fn profile_for_path(path: &Path, resolved: &ResolvedConfig) -> Option Option Result {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|error| {
- /* INVARIANT: Both halves of this line came out of a file in the project: the
- * pattern the caller wrote, and a `globset` parse error that
- * quotes that same pattern straight back. Neither may reach a
- * terminal verbatim, and the line stays one line. The pattern
- * keeps the spacing it was written with, because a reader who is
- * shown something else cannot find it in the file. It is the same
- * treatment `config::validate_regexes` gives the other pattern a
- * project file carries. */
+ /* INVARIANT: Both halves of this line came out of a file in the project: the pattern the caller wrote, and a `globset` parse error that quotes that same pattern straight back.
+ * Neither may reach a terminal verbatim, and the line stays one line.
+ * The pattern keeps the spacing it was written with, because a reader who is shown something else cannot find it in the file.
+ * It is the same treatment `config::validate_regexes` gives the other pattern a project file carries. */
anyhow!(
"invalid file glob `{}`: {}",
crate::output::sanitize_path(pattern),
@@ -718,16 +651,12 @@ fn skip(path: &Path, explicit: bool, error: impl std::fmt::Display) -> SkippedFi
/// Why a file in the tree was never offered to the walk at all.
///
-/// A skip is a file the walk reached and passed over, and it is reported. This
-/// is the other thing: a file the walk's own limits kept out, which nothing
-/// reported because nothing met it. `ocomment coverage` said `100.0%` over a
-/// repository whose every GitHub workflow was under `.github` and therefore
-/// hidden -- a true sentence about what was walked and a false assurance about
-/// what was checked.
+/// A skip is a file the walk reached and passed over, and it is reported.
+/// This is the other thing: a file the walk's own limits kept out, which nothing reported because nothing met it.
+/// `ocomment coverage` said `100.0%` over a repository whose every GitHub workflow was under `.github` and therefore hidden -- a true sentence about what was walked and a false assurance about what was checked.
///
-/// A file a `.gitignore` excludes is deliberately not here. It is not a gap in
-/// the gate: it is build output, and a percentage taken over a hundred
-/// thousand object files would mean nothing at all.
+/// A file a `.gitignore` excludes is deliberately not here.
+/// It is not a gap in the gate: it is build output, and a percentage taken over a hundred thousand object files would mean nothing at all.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum NotWalked {
/// `[files] hidden = false`, and a path component opens with a dot.
@@ -752,10 +681,8 @@ impl NotWalked {
/// Every file under `paths` that this configuration's walk would not reach,
/// and the setting that kept each one out.
///
-/// Nothing is read. The walk here lifts only the hidden-file rule, so what it
-/// finds is the repository as its own ignore files describe it, and each path
-/// missing from `reached` is attributed to the first configured limit that
-/// would have stopped it -- in the order the walk applies them.
+/// Nothing is read.
+/// The walk here lifts only the hidden-file rule, so what it finds is the repository as its own ignore files describe it, and each path missing from `reached` is attributed to the first configured limit that would have stopped it -- in the order the walk applies them.
pub fn not_walked(
paths: &[PathBuf],
resolved: &ResolvedConfig,
@@ -803,14 +730,10 @@ pub fn not_walked(
Ok(missed)
}
-/// Which of the walk's limits would have stopped `path`, tested in the order
-/// the walk applies them.
+/// Which of the walk's limits would have stopped `path`, tested in the order the walk applies them.
///
-/// `None` cannot happen for a path this function is asked about: the caller
-/// has already taken out everything the walk reached, and the walk that found
-/// this one lifted exactly one rule. It is returned rather than asserted
-/// because a filesystem that changed under the two walks is not a defect worth
-/// a panic.
+/// `None` cannot happen for a path this function is asked about: the caller has already taken out everything the walk reached, and the walk that found this one lifted exactly one rule.
+/// It is returned rather than asserted because a filesystem that changed under the two walks is not a defect worth a panic.
fn kept_out(
path: &Path,
resolved: &ResolvedConfig,
diff --git a/rust/ocomment/src/generated.rs b/rust/ocomment/src/generated.rs
index f5146c9..66276a1 100644
--- a/rust/ocomment/src/generated.rs
+++ b/rust/ocomment/src/generated.rs
@@ -1,22 +1,13 @@
//! Files another tool owns.
//!
-//! A lock file, a recorded seed list, a code generator's output: something else
-//! wrote the comments in these and will write them again. Removing one is
-//! editing a tool's file, and it is the class most likely to be auto-fixed
-//! without being read, because nobody opens a generated file before committing
-//! it.
+//! A lock file, a recorded seed list, a code generator's output: something else wrote the comments in these and will write them again.
+//! Removing one is editing a tool's file, and it is the class most likely to be auto-fixed without being read, because nobody opens a generated file before committing it.
//!
-//! This exists because of a wrong turn. Closing the coverage gap by teaching
-//! OComment to read every format it met swept in nine `.proptest-regressions`
-//! files and reported sixty-eight findings in them — proptest's own header, in
-//! proptest's own format, above proptest's payload. Coverage of a file you
-//! should not touch is worse than skipping it: a skip is visible in the
-//! summary, while a removal in a generated file is a diff somebody waves
-//! through.
+//! This exists because of a wrong turn.
+//! Closing the coverage gap by teaching OComment to read every format it met swept in nine `.proptest-regressions` files and reported sixty-eight findings in them — proptest's own header, in proptest's own format, above proptest's payload.
+//! Coverage of a file you should not touch is worse than skipping it: a skip is visible in the summary, while a removal in a generated file is a diff somebody waves through.
//!
-//! So these are skipped, under a reason of their own that `--deny-skipped`
-//! does not refuse — being passed over is what should happen to them — and
-//! `--include-generated` scans them anyway for the run that means it.
+//! So these are skipped, under a reason of their own that `--deny-skipped` does not refuse — being passed over is what should happen to them — and `--include-generated` scans them anyway for the run that means it.
use anyhow::{Context, Result};
use serde::Deserialize;
@@ -30,8 +21,7 @@ const CATALOGUE: &str = include_str!("../assets/generated.toml");
/// The skip reason a generated file is counted under.
///
-/// Its own reason rather than one of the existing ones, because it answers a
-/// different question: `unknown language` is a gap in what OComment can read,
+/// Its own reason rather than one of the existing ones, because it answers a different question: `unknown language` is a gap in what OComment can read,
/// and this is a decision about what it should read.
pub const REASON: &str = "generated file";
@@ -63,12 +53,9 @@ impl Generated {
/// Whether this file is one another tool writes.
///
- /// The name is checked first because it costs nothing, then the first few
- /// lines. The line bound is what makes the header search usable: without
- /// it, a file that lists these markers claims itself, which this
- /// repository's own `spec/directives.toml` did by naming C#'s
- /// ` bool {
if let Some(name) = path.file_name().and_then(|value| value.to_str())
&& self.catalogue.filenames.iter().any(|claim| claim == name)
@@ -123,9 +110,7 @@ mod tests {
));
}
- /// The bound is the point: a file that lists these markers must not claim
- /// itself, and this repository has two that do -- the catalogue naming C#'s
- /// ` {
pub json: crate::output::JsonOptions,
/// `--annotation-level`, passed through to `--format github`.
pub annotation_level: Option,
- /// The run only previews the patch; `fix --dry-run` writes nothing to
- /// the index and reports what a real run would remove.
+ /// The run only previews the patch; `fix --dry-run` writes nothing to the index and reports what a real run would remove.
pub dry_run: bool,
}
@@ -71,11 +70,8 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
} = request;
let root = repository_root()?;
let (blobs, mut skipped) = configured_paths(&root, staged_paths(&root, paths)?, resolved)?;
- /* NOTE: Nothing staged is a run that reports nothing and exits 0, which
- * reads exactly like a clean index -- and that is how `--staged` under
- * `pre-commit run --all-files`, which stages nothing, becomes a gate that
- * is green forever. The run is still correct; it is the silence that is
- * the trap, so the silence goes. */
+ /* NOTE: Nothing staged is a run that reports nothing and exits 0, which reads exactly like a clean index -- and that is how `--staged` under `pre-commit run --all-files`, which stages nothing, becomes a gate that is green forever.
+ * The run is still correct; it is the silence that is the trap, so the silence goes. */
if blobs.is_empty() && skipped.is_empty() {
let stderr = std::io::stderr();
let mut sink = stderr.lock();
@@ -89,7 +85,7 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
)?;
}
let materialize_output =
- operation == Operation::Fix || (operation == Operation::Diff && format.for_a_person());
+ operation.writes() || (matches!(operation, Operation::Diff(_)) && format.for_a_person());
let materialize_source_map =
json.source_map && matches!(format, OutputFormat::Json | OutputFormat::Jsonl);
let mut scanners = HashMap::new();
@@ -97,10 +93,7 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
for StagedBlob { path, named, mode } in blobs {
let source = index_blob(&root, &path)?;
if source.iter().take(8192).any(|byte| *byte == 0) {
- /* NOTE: A walk says why it passed a file over, and so does this: a hook
- * that stages a PNG beside its source has to read as one file
- * scanned and one passed over, not as two files with nothing
- * to say about them. */
+ /* NOTE: A walk says why it passed a file over, and so does this: a hook that stages a PNG beside its source has to read as one file scanned and one passed over, not as two files with nothing to say about them. */
skipped.push(skipped_blob(
path,
"binary file (NUL byte)".to_owned(),
@@ -157,9 +150,11 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
));
continue;
}
- let full = if let Some(profile) = &profile {
+ /* NOTE: Scanned first and planned second, rather than asked for a plan in one call.
+ * Which half of the report becomes edits is the run's to decide, and a call that did both would have decided it here -- which is how a staged tidy came to report a removal as left alone and take it out anyway. */
+ let report = if let Some(profile) = &profile {
scanner
- .transform_profile_plan(&source, profile, options.layout)
+ .scan_profile(&source, profile)
.expect("profiles were validated while loading configuration")
} else if let Some(name) = &routed_plugin {
let language_name = path
@@ -167,9 +162,20 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_ascii_lowercase();
- plugin_host.transform_plan(name, &source, &language_name, &path, &options, &scanner)?
+ plugin_host.scan_report(name, &source, &language_name, &path, &options, &scanner)?
} else {
- scanner.transform_plan(&source, language, options.layout)
+ scanner.scan(&source, language)
+ };
+ let full = match operation.half() {
+ Some(Writes::RewritesOnly) => {
+ ocomment_core::plan_rewrites(&source, report, options.scan.force_invalid)
+ }
+ Some(Writes::Everything) | None => ocomment_core::plan_report(
+ &source,
+ report,
+ options.layout,
+ options.scan.force_invalid,
+ ),
};
let ranges = added_line_ranges(&root, &path)?;
let lines = LineNumberIndex::new(&source);
@@ -185,7 +191,7 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
if starts_added {
selected_comments.push(comment.clone());
} else if intersects
- && comment.disposition.is_remove()
+ && comment.disposition().action().changes_bytes()
&& matches!(comment.kind, CommentKind::Block | CommentKind::DocBlock)
{
conflict = Some(comment.span);
@@ -201,8 +207,24 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
})
.cloned()
.collect();
+ /* NOTE: The paragraphs too, by the same rule.
+ * A run is a finding of its own and is not in the comment list, so selecting only the comments left a report that named a paragraph this commit never touched — and a machine-wide gate that reported one would be asking somebody to reflow a history that is not theirs. */
+ let selected_runs = full
+ .report
+ .runs
+ .iter()
+ .filter(|run| {
+ let line = lines.line_number(run.span.start);
+ let end = lines.line_number(run.span.end.saturating_sub(1));
+ ranges
+ .iter()
+ .any(|range| range.start <= end && line < range.end)
+ })
+ .cloned()
+ .collect();
let mut report = full.report;
report.comments = selected_comments;
+ report.runs = selected_runs;
if let Some(span) = conflict {
report.valid = false;
report.diagnostics.push(Diagnostic {
@@ -240,9 +262,7 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
});
}
entries.sort_by(|left, right| left.path.cmp(&right.path));
- /* NOTE: The size skips were found before the blobs were read and the rest while
- * reading them, so the two arrive interleaved by nothing at all; a
- * machine format publishes this list, which owes its reader one order. */
+ /* NOTE: The size skips were found before the blobs were read and the rest while reading them, so the two arrive interleaved by nothing at all; a machine format publishes this list, which owes its reader one order. */
skipped.sort_by(|left, right| left.path.cmp(&right.path));
let invalid = entries
.iter()
@@ -256,7 +276,7 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
.iter()
.any(|diagnostic| diagnostic.code == "staged-existing-block-comment")
});
- let applied = operation == Operation::Fix
+ let applied = operation.writes()
&& (!invalid || (resolved.config.policy.force_invalid && !staged_conflict));
if applied {
fix_index(&root, &entries, index_only)?;
@@ -271,8 +291,7 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
presentation,
verbosity,
preview,
- /* NOTE: A staged run reports index blobs through a path that
- * carries no policy trace, so there is nothing to explain from;
+ /* NOTE: A staged run reports index blobs through a path that carries no policy trace, so there is nothing to explain from;
* `run_target` refuses the pair before it gets here. */
json: crate::output::JsonOptions {
explain: false,
@@ -289,10 +308,11 @@ pub fn run_staged(request: StagedRequest<'_>) -> Result {
if invalid {
return Ok(2);
}
- match operation {
- Operation::Check | Operation::Diff if output::changed(&files) => Ok(1),
- Operation::Check | Operation::Scan | Operation::Diff | Operation::Fix => Ok(0),
- }
+ Ok(output::exit_code(
+ operation,
+ &files,
+ applied && output::changed(&files),
+ ))
}
fn fix_index(root: &Path, entries: &[IndexEntry], index_only: bool) -> Result<()> {
@@ -321,8 +341,7 @@ fn fix_index(root: &Path, entries: &[IndexEntry], index_only: bool) -> Result<()
temporary_index.write_all(&original_index)?;
temporary_index.flush()?;
temporary_index.as_file_mut().sync_all()?;
- /* NOTE: Close the file before Git replaces it through `.lock`; retaining an
- * open NamedTempFile handle makes this update fail on Windows. */
+ /* NOTE: Close the file before Git replaces it through `.lock`; retaining an open NamedTempFile handle makes this update fail on Windows. */
let temporary_path = temporary_index.into_temp_path();
for entry in &changed {
@@ -364,8 +383,8 @@ fn fix_index(root: &Path, entries: &[IndexEntry], index_only: bool) -> Result<()
});
}
}
- /* INVARIANT: Treat the index itself as the last journaled file. The shared transaction
- * rolls working-tree files and index back together on any rename failure. */
+ /* INVARIANT: Treat the index itself as the last journaled file.
+ * The shared transaction rolls working-tree files and index back together on any rename failure. */
plans.push(WritePlan {
path: index_path,
original: Cow::Owned(original_index),
@@ -418,14 +437,11 @@ fn repository_root() -> Result {
Ok(bytes_to_path(&output))
}
-/// Every staged path a run has to consider, and which of them the caller
-/// named.
+/// Every staged path a run has to consider, and which of them the caller named.
struct StagedPaths {
- /// What `git diff --cached` answered: root-relative, sorted, each path
- /// once however many pathspecs covered it.
+ /// What `git diff --cached` answered: root-relative, sorted, each path once however many pathspecs covered it.
paths: Vec,
- /// The paths a pathspec picked out, which is what lifts the project's own
- /// limits from them.
+ /// The paths a pathspec picked out, which is what lifts the project's own limits from them.
named: BTreeSet,
}
@@ -438,20 +454,12 @@ struct StagedBlob {
/// Ask `git` what is staged, one question for each pathspec.
///
-/// A pathspec is `git`'s to interpret and nobody else's. `.hidden/*.rs` is a
-/// wildcard it expands, an absolute path is one it makes root-relative, `.` is
-/// a directory it resolves against the directory the command was typed in, and
-/// the answer to all of them is a path relative to the repository root. So
-/// each pathspec is put to `git` on its own and the answers are unioned, which
-/// leaves the run with both of the things it needs — the paths to scan, and
-/// the paths a caller asked about — without restating a word of pathspec
-/// syntax here, and so without the two readings drifting apart.
+/// A pathspec is `git`'s to interpret and nobody else's.
+/// `.hidden/*.rs` is a wildcard it expands, an absolute path is one it makes root-relative, `.` is a directory it resolves against the directory the command was typed in, and the answer to all of them is a path relative to the repository root.
+/// So each pathspec is put to `git` on its own and the answers are unioned, which leaves the run with both of the things it needs — the paths to scan, and the paths a caller asked about — without restating a word of pathspec syntax here, and so without the two readings drifting apart.
///
-/// It costs one `git` invocation for each pathspec the caller typed, where the
-/// single combined question it replaces cost one for all of them. A hook that
-/// passes its staged file names in one by one pays that per name, next to the
-/// four this run already spends on every path it keeps, and it buys the only
-/// reading of a pathspec that `git` itself would agree with.
+/// It costs one `git` invocation for each pathspec the caller typed, where the single combined question it replaces cost one for all of them.
+/// A hook that passes its staged file names in one by one pays that per name, next to the four this run already spends on every path it keeps, and it buys the only reading of a pathspec that `git` itself would agree with.
fn staged_paths(root: &Path, filters: &[PathBuf]) -> Result {
let base = pathspec_base(root);
let mut paths = Vec::new();
@@ -496,69 +504,40 @@ fn list_staged(base: &Path, pathspec: Option<&Path>) -> Result> {
/// The directory a relative pathspec is measured from.
///
-/// It is the directory the command was typed in, which is what `git` resolves
-/// `.` or `../lib` against. `resolved.cwd` cannot answer this question: a
-/// staged run points it at the repository root before it begins, because a
-/// staged path arrives root-relative and a `[files]` glob is written
-/// root-relative too. The root stands in where the working directory cannot be
-/// read at all: it is inside the repository by construction, so the worst it
-/// can do is read a relative pathspec as the top of the tree would.
+/// It is the directory the command was typed in, which is what `git` resolves `.` or `../lib` against.
+/// `resolved.cwd` cannot answer this question: a staged run points it at the repository root before it begins, because a staged path arrives root-relative and a `[files]` glob is written root-relative too.
+/// The root stands in where the working directory cannot be read at all: it is inside the repository by construction, so the worst it can do is read a relative pathspec as the top of the tree would.
fn pathspec_base(root: &Path) -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| root.to_path_buf())
}
/// Whether a pathspec covers the repository, and so picks nothing out of it.
///
-/// `ocomment check --staged .` from the top of the repository asks for the run
-/// that `ocomment check --staged` already is, and it has to get that run's
-/// answer — every `[files]` limit included. Naming a path is what lifts those
-/// limits, and the whole tree is not a path anybody picked out: a hook that
-/// spells its run with a trailing `.` would otherwise put exactly the hidden
-/// or oversized blob through a commit that a bare run passes over. The same
-/// `.` typed in `src/` does pick a subtree out, which is why the pathspec is
-/// resolved where it was written before it is compared.
+/// `ocomment check --staged .` from the top of the repository asks for the run that `ocomment check --staged` already is, and it has to get that run's answer — every `[files]` limit included.
+/// Naming a path is what lifts those limits, and the whole tree is not a path anybody picked out: a hook that spells its run with a trailing `.` would otherwise put exactly the hidden or oversized blob through a commit that a bare run passes over.
+/// The same `.` typed in `src/` does pick a subtree out, which is why the pathspec is resolved where it was written before it is compared.
///
-/// Only a pathspec that is a path is understood here. `git`'s `:(magic)`
-/// spellings are read as picking something out, which is the reading that
-/// answers about the paths the caller wrote rather than silently dropping
-/// them.
+/// Only a pathspec that is a path is understood here.
+/// `git`'s `:(magic)` spellings are read as picking something out, which is the reading that answers about the paths the caller wrote rather than silently dropping them.
fn names_whole_tree(pathspec: &Path, base: &Path, root: &Path) -> bool {
let joined = base.join(pathspec);
let absolute = std::path::absolute(&joined).unwrap_or(joined);
crate::config::lexical(&absolute) == crate::config::lexical(root)
}
-/// Drop the staged paths `[files]` puts out of bounds, and say which of them
-/// were passed over.
+/// Drop the staged paths `[files]` puts out of bounds, and say which of them were passed over.
///
-/// `git diff --cached` answers with every path the commit carries, which is a
-/// different question from the one `[files]` answers: a vendored tree the
-/// project excludes is still staged on the commit that updates it. A walk
-/// applies `include` and `exclude` in `files::load_one`, so a staged run
-/// applies them here, and to the same root-relative spelling — `git` names a
-/// staged path relative to the repository root, and `run_target` has already
-/// pointed `resolved.cwd` there for exactly this reason.
+/// `git diff --cached` answers with every path the commit carries, which is a different question from the one `[files]` answers: a vendored tree the project excludes is still staged on the commit that updates it.
+/// A walk applies `include` and `exclude` in `files::load_one`, so a staged run applies them here, and to the same root-relative spelling — `git` names a staged path relative to the repository root, and `run_target` has already pointed `resolved.cwd` there for exactly this reason.
///
-/// A staged path nobody named is a walked path: it never carries the licence
-/// an explicit argument does to look past the project's own limits. That is the
-/// whole of `[files]` and not just its two glob lists — `hidden` decides
-/// whether a dot-directory is looked into at all and `max_size` decides how
-/// much of a file is worth reading, and a hook that applied neither would put
-/// through a commit exactly what a walk would never have reached.
+/// A staged path nobody named is a walked path: it never carries the licence an explicit argument does to look past the project's own limits.
+/// That is the whole of `[files]` and not just its two glob lists — `hidden` decides whether a dot-directory is looked into at all and `max_size` decides how much of a file is worth reading, and a hook that applied neither would put through a commit exactly what a walk would never have reached.
///
-/// A path the caller *did* name is the other case, and
-/// [`StagedPaths::named`] is what tells the two apart.
-/// `ocomment check --staged .hidden/x.rs` is a request about that file, so
-/// answering "0 files" because the project does not walk into dot-directories
-/// reads as a clean file rather than as a path out of bounds — which is why a
-/// walk lifts both limits for an explicit argument, and why this lifts them
-/// for the same argument spelled as a pathspec.
+/// A path the caller *did* name is the other case, and [`StagedPaths::named`] is what tells the two apart.
+/// `ocomment check --staged .hidden/x.rs` is a request about that file, so answering "0 files" because the project does not walk into dot-directories reads as a clean file rather than as a path out of bounds — which is why a walk lifts both limits for an explicit argument, and why this lifts them for the same argument spelled as a pathspec.
///
-/// The two limits answer differently when they do apply, because they mean
-/// differently. A hidden path was never a candidate, so it leaves no trace; an
-/// oversized blob is a file the run *met* and declined, so it comes back as the
-/// same folded "too large" skip a walk reports, counted in the summary rather
-/// than annotated once per file.
+/// The two limits answer differently when they do apply, because they mean differently.
+/// A hidden path was never a candidate, so it leaves no trace; an oversized blob is a file the run *met* and declined, so it comes back as the same folded "too large" skip a walk reports, counted in the summary rather than annotated once per file.
fn configured_paths(
root: &Path,
staged: StagedPaths,
@@ -572,9 +551,7 @@ fn configured_paths(
let mut skipped = Vec::new();
for path in paths {
let relative = resolved.relative_to_root(&path);
- /* NOTE: The glob lists bound a named path too — a walk asks them about every
- * candidate before it asks anything else, and `load_one` asks them of
- * an explicit argument exactly as it asks them of a walked one. */
+ /* NOTE: The glob lists bound a named path too — a walk asks them about every candidate before it asks anything else, and `load_one` asks them of an explicit argument exactly as it asks them of a walked one. */
if (!include.is_empty() && !include.is_match(&relative)) || exclude.is_match(&relative) {
continue;
}
@@ -582,8 +559,8 @@ fn configured_paths(
if !explicit && !resolved.config.files.hidden && has_hidden_component(&path) {
continue;
}
- /* NOTE: Read the index mode before asking Git for blob bytes. A
- * symlink's blob is its target spelling and a gitlink names a commit,
+ /* NOTE: Read the index mode before asking Git for blob bytes.
+ * A symlink's blob is its target spelling and a gitlink names a commit,
* neither of which is source text. */
let mode = index_mode(root, &path)?;
let special = match mode.as_str() {
@@ -600,8 +577,7 @@ fn configured_paths(
path,
reason: format!("larger than {max_size} bytes"),
error: false,
- /* NOTE: Nobody typed this path, so its skip is folded into the summary
- * exactly as a walked one is. */
+ /* NOTE: Nobody typed this path, so its skip is folded into the summary exactly as a walked one is. */
explicit: false,
});
continue;
@@ -617,14 +593,8 @@ fn configured_paths(
/// A staged blob that was met and declined.
///
-/// Neither reason depends on what the caller typed — a PNG is not text and a
-/// `.md` file has no scanner however it got into the commit — but who typed
-/// the path decides where the skip is reported. One nobody named is counted in
-/// the end-of-run summary under the short label [`crate::output::skip_label`]
-/// gives it, and listed per file only when `-v` asks for the list; one the
-/// caller named is answered on a line of its own, because
-/// `ocomment check --staged notes.md` that says only "nothing to check" reads
-/// as a clean file rather than as a file nothing could read.
+/// Neither reason depends on what the caller typed — a PNG is not text and a `.md` file has no scanner however it got into the commit — but who typed the path decides where the skip is reported.
+/// One nobody named is counted in the end-of-run summary under the short label [`crate::output::skip_label`] gives it, and listed per file only when `-v` asks for the list; one the caller named is answered on a line of its own, because `ocomment check --staged notes.md` that says only "nothing to check" reads as a clean file rather than as a file nothing could read.
fn skipped_blob(path: PathBuf, reason: String, named: bool) -> SkippedFile {
SkippedFile {
path,
@@ -636,11 +606,8 @@ fn skipped_blob(path: PathBuf, reason: String, named: bool) -> SkippedFile {
/// Whether any component of a staged path is a hidden name.
///
-/// `git` names a staged path relative to the repository root, so every
-/// component of it is a real directory or file name — there is no walk root in
-/// front to leave out, the way `ignore` leaves one out. A leading `.` is the
-/// only byte that decides it, so a name that is not UTF-8 is judged on the
-/// bytes it actually has rather than on a lossy reading of them.
+/// `git` names a staged path relative to the repository root, so every component of it is a real directory or file name — there is no walk root in front to leave out, the way `ignore` leaves one out.
+/// A leading `.` is the only byte that decides it, so a name that is not UTF-8 is judged on the bytes it actually has rather than on a lossy reading of them.
fn has_hidden_component(path: &Path) -> bool {
path.components().any(|component| {
matches!(component, Component::Normal(name) if name.as_encoded_bytes().starts_with(b"."))
@@ -656,15 +623,11 @@ fn index_specification(path: &Path) -> OsString {
/// How large the staged blob is, without reading it.
///
-/// The size is asked of the index rather than of the working tree, because
-/// `--staged` judges the bytes the commit will carry: a file can be a line
-/// long on disk and a megabyte in the index, or the other way round.
+/// The size is asked of the index rather than of the working tree, because `--staged` judges the bytes the commit will carry: a file can be a line long on disk and a megabyte in the index, or the other way round.
///
/// Asking costs one `git` invocation for each path that got past the globs,
-/// next to the three the run already spends on every path it keeps. It buys
-/// the thing `max_size` exists for, which is that an oversized blob is never
-/// brought into memory at all — measuring it from `index_blob`'s answer would
-/// have read it first.
+/// next to the three the run already spends on every path it keeps.
+/// It buys the thing `max_size` exists for, which is that an oversized blob is never brought into memory at all — measuring it from `index_blob`'s answer would have read it first.
fn index_blob_size(root: &Path, path: &Path) -> Result {
let mut output = command_output(
Command::new("git")
@@ -830,17 +793,13 @@ fn bytes_to_path(bytes: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}
-/// The working-tree files that differ from `base`, as paths relative to the
-/// repository root.
+/// The working-tree files that differ from `base`, as paths relative to the repository root.
///
-/// `merge-base` rather than `base` itself: on a branch several commits behind
-/// its trunk, a plain diff against the trunk reports every file the trunk
-/// changed as well, and a gate that reported those would be asking this branch
-/// to answer for somebody else's work.
+/// `merge-base` rather than `base` itself: on a branch several commits behind its trunk, a plain diff against the trunk reports every file the trunk changed as well, and a gate that reported those would be asking this branch to answer for somebody else's work.
///
/// A deletion is dropped rather than reported: there is no file left to read,
-/// and a gate that failed on one would be refusing the change that cleaned it
-/// up. `--diff-filter=d` is git's own way of saying so.
+/// and a gate that failed on one would be refusing the change that cleaned it up.
+/// `--diff-filter=d` is git's own way of saying so.
pub fn changed_since(base: &str) -> Result<(PathBuf, Vec)> {
let root = repository_root()?;
let merge_base = command_output(
diff --git a/rust/ocomment/src/hook.rs b/rust/ocomment/src/hook.rs
index 37d2829..658da76 100644
--- a/rust/ocomment/src/hook.rs
+++ b/rust/ocomment/src/hook.rs
@@ -1,16 +1,11 @@
//! Agent editing hooks: the same check, spoken in an agent host's protocol.
//!
-//! A hook host hands its hook a description of an edit on standard input and
-//! reads a decision back. Nothing in this module decides anything: it works
-//! out which bytes are about to become which file, hands that pair to the same
-//! machinery `ocomment check` runs, and writes the answer in the shape the
-//! host reads. The judgement, the configuration, the policy and the report are
-//! the ones every other command uses.
+//! A hook host hands its hook a description of an edit on standard input and reads a decision back.
+//! Nothing in this module decides anything: it works out which bytes are about to become which file, hands that pair to the same machinery `ocomment check` runs, and writes the answer in the shape the host reads.
+//! The judgement, the configuration, the policy and the report are the ones every other command uses.
//!
-//! This is where the coupling lives, deliberately and in one file — the same
-//! arrangement as `editors/` and `action.yml`, which speak an editor's and a
-//! CI system's protocols without either reaching into the scanner. Supporting
-//! another host is one more [`Surface`] and one more `decide` arm.
+//! This is where the coupling lives, deliberately and in one file — the same arrangement as `editors/` and `action.yml`, which speak an editor's and a CI system's protocols without either reaching into the scanner.
+//! Supporting another host is one more [`Surface`] and one more `decide` arm.
use crate::{
cli::CommonArgs,
@@ -37,9 +32,8 @@ pub enum Surface {
/// What the run is being asked about: the bytes, and the path they are for.
///
-/// `None` is the ordinary answer. Most hook events are about something that is
-/// not a file — a command, a prompt, the end of a session — and a hook with no
-/// opinion has to be silent rather than guess.
+/// `None` is the ordinary answer.
+/// Most hook events are about something that is not a file — a command, a prompt, the end of a session — and a hook with no opinion has to be silent rather than guess.
type Subject = Option<(PathBuf, Vec)>;
pub fn run(surface: Surface, common: &CommonArgs) -> Result {
@@ -54,17 +48,14 @@ pub fn run(surface: Surface, common: &CommonArgs) -> Result {
/// Claude Code's hook payload, cut down to the fields a comment check needs.
///
-/// Unknown fields are ignored rather than refused: the payload grows, and a
-/// hook that failed on a field it had never heard of would break every editing
-/// session the day the host added one.
+/// Unknown fields are ignored rather than refused: the payload grows, and a hook that failed on a field it had never heard of would break every editing session the day the host added one.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct ClaudeCodeHook {
hook_event_name: String,
tool_name: String,
tool_input: ToolInput,
- /// The directory the session is working in, which is where the
- /// configuration is discovered from.
+ /// The directory the session is working in, which is where the configuration is discovered from.
cwd: Option,
}
@@ -90,22 +81,18 @@ struct Replacement {
replace_all: bool,
}
-/// Whether this event is about a file that is about to change, or one that just
-/// did.
+/// Whether this event is about a file that is about to change, or one that just did.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum When {
- /// The edit has not happened. Refusing it keeps the comment out of the file
- /// rather than reporting it once it is in.
+ /// The edit has not happened.
+ /// Refusing it keeps the comment out of the file rather than reporting it once it is in.
Before,
/// The edit has happened and the bytes are on the disk.
After,
}
fn claude_code(payload: &str, common: &CommonArgs) -> Result {
- /* NOTE: A payload this run cannot parse is the host's business rather than
- * the edit's, so it is reported as a hook failure — exit 1, which Claude
- * Code treats as non-blocking — instead of standing in the way of an edit
- * nothing has actually judged. */
+ /* NOTE: A payload this run cannot parse is the host's business rather than the edit's, so it is reported as a hook failure — exit 1, which Claude Code treats as non-blocking — instead of standing in the way of an edit nothing has actually judged. */
let hook: ClaudeCodeHook =
serde_json::from_str(payload).context("cannot read the hook payload as JSON")?;
let when = match hook.hook_event_name.as_str() {
@@ -131,11 +118,8 @@ fn claude_code(payload: &str, common: &CommonArgs) -> Result {
return Ok(0);
};
match when {
- /* NOTE: A denial carries its own reason and exits 0, because exit 2
- * would take the reason from standard error instead and the two would
- * have to be kept in step. Nothing here ever answers `allow`: that
- * would wave the edit past the permission rules its user set, and this
- * hook was asked about comments. */
+ /* NOTE: A denial carries its own reason and exits 0, because exit 2 would take the reason from standard error instead and the two would have to be kept in step.
+ * Nothing here ever answers `allow`: that would wave the edit past the permission rules its user set, and this hook was asked about comments. */
When::Before => {
let decision = json!({
"hookSpecificOutput": {
@@ -149,9 +133,8 @@ fn claude_code(payload: &str, common: &CommonArgs) -> Result {
output::finish(&mut stdout)?;
Ok(0)
}
- /* NOTE: The edit already happened, so there is nothing left to refuse
- * and the report is a correction. Exit 2 is how this host puts one in
- * front of the model; the text comes from standard error. */
+ /* NOTE: The edit already happened, so there is nothing left to refuse and the report is a correction.
+ * Exit 2 is how this host puts one in front of the model; the text comes from standard error. */
When::After => {
let stderr = std::io::stderr();
let mut sink = stderr.lock();
@@ -161,17 +144,13 @@ fn claude_code(payload: &str, common: &CommonArgs) -> Result {
}
}
-/// The tools whose events are about a file that is about to hold different
-/// bytes.
+/// The tools whose events are about a file that is about to hold different bytes.
///
-/// Named rather than inferred from the payload, because several tools carry a
-/// `file_path` and only these put anything in the file. Reading one is not an
-/// edit, and a hook that blocked on a file the agent had merely read would be
-/// reporting a comment nobody had just written.
+/// Named rather than inferred from the payload, because several tools carry a `file_path` and only these put anything in the file.
+/// Reading one is not an edit, and a hook that blocked on a file the agent had merely read would be reporting a comment nobody had just written.
const EDITING_TOOLS: [&str; 4] = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
-/// The path and the bytes this event is about, or `None` if it is about
-/// something else.
+/// The path and the bytes this event is about, or `None` if it is about something else.
fn subject(hook: &ClaudeCodeHook, when: When) -> Result {
if !EDITING_TOOLS.contains(&hook.tool_name.as_str()) {
return Ok(None);
@@ -180,17 +159,13 @@ fn subject(hook: &ClaudeCodeHook, when: When) -> Result {
return Ok(None);
};
if when == When::After {
- /* NOTE: Read rather than reconstructed. Whatever the tool reported it
- * would do, the file is the file. */
+ /* NOTE: Read rather than reconstructed.
+ * Whatever the tool reported it would do, the file is the file. */
return Ok(std::fs::read(&path).ok().map(|bytes| (path, bytes)));
}
let input = &hook.tool_input;
- /* NOTE: `Write` carries the whole file; the two edit tools carry
- * replacements against the file as it stands, so the file is read and the
- * replacements applied the way the tool is about to apply them. A
- * replacement that does not match is an edit the tool will refuse on its
- * own, and this hook says nothing about it rather than judging bytes that
- * will never exist. */
+ /* NOTE: `Write` carries the whole file; the two edit tools carry replacements against the file as it stands, so the file is read and the replacements applied the way the tool is about to apply them.
+ * A replacement that does not match is an edit the tool will refuse on its own, and this hook says nothing about it rather than judging bytes that will never exist. */
if let Some(content) = &input.content {
return Ok(Some((path, content.clone().into_bytes())));
}
@@ -228,13 +203,10 @@ fn subject(hook: &ClaudeCodeHook, when: When) -> Result {
Ok(Some((path, proposed.into_bytes())))
}
-/// The agent report for `bytes` judged as the contents of `path`, or `None`
-/// when there is nothing to say.
+/// The agent report for `bytes` judged as the contents of `path`, or `None` when there is nothing to say.
///
-/// Everything below is the same call `ocomment check` makes. A hook that
-/// scanned differently from the command would be a second implementation of
-/// the project's policy, and the first thing it would disagree with is the
-/// gate the project already runs.
+/// Everything below is the same call `ocomment check` makes.
+/// A hook that scanned differently from the command would be a second implementation of the project's policy, and the first thing it would disagree with is the gate the project already runs.
fn judge(
path: &Path,
bytes: Vec,
@@ -256,11 +228,9 @@ fn judge(
let scanner = PreparedScanner::new(file.options.scan.clone())
.context("cannot prepare comment policy")?;
let mut report = crate::cli::scan_bytes(&file.source, &file, &scanner, &plugin_host)?;
- /* NOTE: The bytes under judgement are not the ones on the disk, and the
- * deadline is read from the history of the file they would become —
- * `git blame --contents` answers for exactly that. Without this an
- * editing hook would be the one surface where a promise never ran
- * out. */
+ /* NOTE: The bytes under judgement are not the ones on the disk, and the deadline is read from the history of the file they would become —
+ * `git blame --contents` answers for exactly that.
+ * Without this an editing hook would be the one surface where a promise never ran out. */
deadline::apply(
&resolved.root,
&file.path,
@@ -269,11 +239,7 @@ fn judge(
&file.options.scan.allow,
std::time::SystemTime::now(),
)?;
- let changed = (report.valid || scanner.options().force_invalid)
- && report
- .comments
- .iter()
- .any(|comment| comment.disposition.is_remove());
+ let changed = (report.valid || scanner.options().force_invalid) && report.changes_bytes();
let (_, _, trace) = resolved.for_path_traced(&file.path, file.language, file.dialect)?;
explanations.insert(
file.path.clone(),
diff --git a/rust/ocomment/src/interactive.rs b/rust/ocomment/src/interactive.rs
index e8afe85..70c176a 100644
--- a/rust/ocomment/src/interactive.rs
+++ b/rust/ocomment/src/interactive.rs
@@ -1,17 +1,11 @@
//! The comment-by-comment prompt behind `fix --interactive`.
//!
-//! The run has already transformed every file by the time this module is
-//! reached, so what it asks about is a list of edits that were computed
-//! together. Applying only some of them is safe because a replacement is
-//! computed from the *source* alone: under `layout = "columns"` it is exactly
-//! as wide as the comment it stands for, so a removal moves nothing that comes
-//! after it, and under every other layout it depends only on the bytes either
-//! side of its own span. `partial_column_edits_keep_the_replacement_the_transform_computed`
-//! pins that.
+//! The run has already transformed every file by the time this module is reached, so what it asks about is a list of edits that were computed together.
+//! Applying only some of them is safe because a replacement is computed from the *source* alone: under `layout = "columns"` it is exactly as wide as the comment it stands for, so a removal moves nothing that comes after it, and under every other layout it depends only on the bytes either side of its own span.
+//! `partial_column_edits_keep_the_replacement_the_transform_computed` pins that.
//!
-//! The prompt is line-based on purpose: no raw mode, no cursor addressing, no
-//! terminal library. One question, one line of answer, and a transcript that a
-//! test can read.
+//! The prompt is line-based on purpose: no raw mode, no cursor addressing, no terminal library.
+//! One question, one line of answer, and a transcript that a test can read.
use crate::{
atomic::WritePlan,
@@ -28,9 +22,7 @@ use std::{
/// What the reader decided about the removable comments of one run.
///
-/// Deliberately not `Debug`: a plan carries the whole before-and-after text of
-/// a source file, and the one thing this type must never do is put it on a
-/// terminal by accident.
+/// Deliberately not `Debug`: a plan carries the whole before-and-after text of a source file, and the one thing this type must never do is put it on a terminal by accident.
#[derive(Default)]
pub struct Selection<'a> {
/// One plan per file that keeps at least one accepted removal.
@@ -41,8 +33,7 @@ pub struct Selection<'a> {
pub aborted: bool,
}
-/// The question, ending in a space rather than a newline so the answer is typed
-/// on the same line.
+/// The question, ending in a space rather than a newline so the answer is typed on the same line.
const PROMPT: &str = "Remove? [y,n,a,d,q,x,?] ";
/// What each answer does, in the order the prompt lists them.
@@ -56,18 +47,16 @@ const HELP: [&str; 7] = [
"? - show this help",
];
-/// What is said to an answer that is not one of them. A typo is never taken for
-/// a decision about somebody's source file.
+/// What is said to an answer that is not one of them.
+/// A typo is never taken for a decision about somebody's source file.
const UNKNOWN: &str = "unknown answer; press ? for help";
/// How many unchanged lines are shown either side of the change.
const CONTEXT: usize = 3;
-/// Ask about every comment this run would remove and collect the answers into
-/// the writes they come to.
+/// Ask about every comment this run would remove and collect the answers into the writes they come to.
///
-/// `input` and `output` are the reader's terminal; they are parameters so the
-/// whole conversation can be driven from a script in a test.
+/// `input` and `output` are the reader's terminal; they are parameters so the whole conversation can be driven from a script in a test.
pub fn select<'a>(
files: &'a [ProcessedFile],
input: &mut dyn BufRead,
@@ -115,8 +104,7 @@ pub fn select<'a>(
stopped = true;
break;
}
- /* NOTE: Everything accepted so far goes with it: `x` is the
- * answer for a run that should never have started. */
+ /* NOTE: Everything accepted so far goes with it: `x` is the answer for a run that should never have started. */
Answer::Abort => {
return Ok(Selection {
aborted: true,
@@ -154,15 +142,14 @@ pub fn select<'a>(
/// The comments this run would remove, each with the edit that removes it.
///
/// `transform` pushes exactly one edit per removable comment, in source order,
-/// so the two lists line up pairwise. A file whose source failed to scan has no
-/// edits at all, and nothing about it is offered — the same gate a
-/// non-interactive `fix` applies before it writes.
+/// so the two lists line up pairwise.
+/// A file whose source failed to scan has no edits at all, and nothing about it is offered — the same gate a non-interactive `fix` applies before it writes.
fn offers(file: &ProcessedFile) -> Vec<(&Comment, &Edit)> {
file.result
.report
.comments
.iter()
- .filter(|comment| comment.disposition.is_remove())
+ .filter(|comment| comment.disposition().action().changes_bytes())
.zip(file.result.edits.iter())
.collect()
}
@@ -202,19 +189,15 @@ fn show(
Ok(())
}
-/// The lines the reader is answering for: the ones the comment sits on as they
-/// are, the same ones as this single edit would leave them, and `CONTEXT` lines
-/// of unchanged source either side.
+/// The lines the reader is answering for: the ones the comment sits on as they are, the same ones as this single edit would leave them, and `CONTEXT` lines of unchanged source either side.
///
-/// The "after" text is produced by applying this one edit and nothing else, so
-/// what is shown is what answering `y` to this question alone would do.
+/// The "after" text is produced by applying this one edit and nothing else, so what is shown is what answering `y` to this question alone would do.
fn hunk(source: &[u8], edit: &Edit, presentation: &Presentation) -> Vec {
let length = source.len();
let begin = edit.span.start.min(length);
let finish = edit.span.end.clamp(begin, length);
let start = line_start(source, begin);
- /* NOTE: The last byte the span covers, so a span that ends exactly on a line
- * break does not drag the following line into the hunk. */
+ /* NOTE: The last byte the span covers, so a span that ends exactly on a line break does not drag the following line into the hunk. */
let inner = if finish > begin { finish - 1 } else { begin };
let end = line_end(source, inner);
let after = apply_edits(source, std::slice::from_ref(edit));
@@ -243,18 +226,12 @@ fn hunk(source: &[u8], edit: &Edit, presentation: &Presentation) -> Vec
rows
}
-/// How many lines of one changed side are shown before the rest are folded
-/// into a single marker: `CONTEXT` at each end, the same window the unchanged
-/// context gets.
+/// How many lines of one changed side are shown before the rest are folded into a single marker: `CONTEXT` at each end, the same window the unchanged context gets.
const BLOCK: usize = 2 * CONTEXT;
-/// One side of the change, capped so a comment taller than the screen cannot
-/// push the question off it.
+/// One side of the change, capped so a comment taller than the screen cannot push the question off it.
///
-/// A block comment can run to any length, and the reader is answering about
-/// the comment, not reading it here: the first and last `CONTEXT` lines say
-/// which comment it is and where it ends, and the marker between them says how
-/// much was left out rather than pretending there was nothing.
+/// A block comment can run to any length, and the reader is answering about the comment, not reading it here: the first and last `CONTEXT` lines say which comment it is and where it ends, and the marker between them says how much was left out rather than pretending there was nothing.
fn changed(rows: &mut Vec, marker: char, lines: &[&[u8]], presentation: &Presentation) {
let show = |rows: &mut Vec, block: &[&[u8]]| {
rows.extend(
@@ -272,9 +249,8 @@ fn changed(rows: &mut Vec, marker: char, lines: &[&[u8]], presentation:
show(rows, &lines[lines.len() - CONTEXT..]);
}
-/// What stands in for the lines a capped side folded away. It carries the
-/// marker of the side it belongs to so the two columns stay aligned, and is
-/// dimmed rather than tinted so it is never read as a line of the source.
+/// What stands in for the lines a capped side folded away.
+/// It carries the marker of the side it belongs to so the two columns stay aligned, and is dimmed rather than tinted so it is never read as a line of the source.
fn elision(marker: char, hidden: usize, presentation: &Presentation) -> String {
format!(
"{}{marker}... {hidden} more line{} ...{}",
@@ -286,9 +262,7 @@ fn elision(marker: char, hidden: usize, presentation: &Presentation) -> String {
/// Runs of the same blank line folded to one.
///
-/// Under `layout = "lines"` a removed block comment leaves exactly as many
-/// empty lines as it occupied, and the twenty-seventh of them tells the reader
-/// nothing the first did not.
+/// Under `layout = "lines"` a removed block comment leaves exactly as many empty lines as it occupied, and the twenty-seventh of them tells the reader nothing the first did not.
fn collapse_blanks(lines: Vec<&[u8]>) -> Vec<&[u8]> {
let mut kept: Vec<&[u8]> = Vec::with_capacity(lines.len());
for line in lines {
@@ -301,8 +275,7 @@ fn collapse_blanks(lines: Vec<&[u8]>) -> Vec<&[u8]> {
kept
}
-/// One line of the hunk: its marker, its terminal-safe text, and the colour
-/// that says which of the three it is.
+/// One line of the hunk: its marker, its terminal-safe text, and the colour that says which of the three it is.
fn rendered(marker: char, line: &[u8], presentation: &Presentation) -> String {
let tint = match marker {
'-' => "\x1b[31m",
@@ -317,8 +290,7 @@ fn rendered(marker: char, line: &[u8], presentation: &Presentation) -> String {
)
}
-/// One block of bytes as the lines it holds, with the carriage return of a
-/// CRLF file left out of the text rather than shown as a control character.
+/// One block of bytes as the lines it holds, with the carriage return of a CRLF file left out of the text rather than shown as a control character.
fn rows_of(block: &[u8]) -> Vec<&[u8]> {
block
.split(|byte| *byte == b'\n')
@@ -347,8 +319,7 @@ fn preceding(source: &[u8], start: usize, count: usize) -> Vec<&[u8]> {
let mut lines = Vec::new();
let mut at = start;
while lines.len() < count && at > 0 {
- /* NOTE: `at` is a line start, so the byte before it is the terminator of the
- * line being collected. */
+ /* NOTE: `at` is a line start, so the byte before it is the terminator of the line being collected. */
let end = at - 1;
let begin = line_start(source, end);
lines.push(&source[begin..end]);
@@ -363,8 +334,8 @@ fn following(source: &[u8], end: usize, count: usize) -> Vec<&[u8]> {
let mut lines = Vec::new();
let mut at = end;
while lines.len() < count && at < source.len() {
- /* NOTE: Step over the terminator `end` stopped in front of. A file whose last
- * line ends in one has nothing after it, and the loop ends here. */
+ /* NOTE: Step over the terminator `end` stopped in front of.
+ * A file whose last line ends in one has nothing after it, and the loop ends here. */
at += 1;
if at >= source.len() {
break;
@@ -389,12 +360,9 @@ enum Answer {
Help,
}
-/// Put the question and read one answer, explaining itself and asking again
-/// until the reader gives one.
+/// Put the question and read one answer, explaining itself and asking again until the reader gives one.
///
-/// The answer is read as bytes rather than as a line of text: a terminal can
-/// deliver anything, and a stray byte is a typo to ask about again, not an I/O
-/// failure that ends a run somebody is in the middle of.
+/// The answer is read as bytes rather than as a line of text: a terminal can deliver anything, and a stray byte is a typo to ask about again, not an I/O failure that ends a run somebody is in the middle of.
fn ask(
input: &mut dyn BufRead,
output: &mut dyn Write,
@@ -402,8 +370,7 @@ fn ask(
) -> Result {
loop {
wrote(write!(output, "{PROMPT}"))?;
- /* NOTE: The question ends without a newline, so it has to be pushed out by
- * hand before the run blocks waiting for the answer to it. */
+ /* NOTE: The question ends without a newline, so it has to be pushed out by hand before the run blocks waiting for the answer to it. */
wrote(output.flush())?;
let mut line = Vec::new();
/* NOTE: Nothing left to read is a reader who is no longer there to answer,
@@ -467,8 +434,7 @@ mod tests {
}
}
- /// Drive `select` with a scripted answer per line and collect everything it
- /// wrote to the terminal.
+ /// Drive `select` with a scripted answer per line and collect everything it wrote to the terminal.
fn ask<'a>(files: &'a [ProcessedFile], script: &str) -> (Selection<'a>, String) {
let mut input = Cursor::new(script.as_bytes().to_vec());
let mut written: Vec = Vec::new();
@@ -481,8 +447,7 @@ mod tests {
String::from_utf8(selection.plans[0].replacement.to_vec()).unwrap()
}
- /// The answers apply to one comment each: the accepted span is gone and the
- /// declined one is still in the bytes that would be written.
+ /// The answers apply to one comment each: the accepted span is gone and the declined one is still in the bytes that would be written.
#[test]
fn yes_and_no_apply_only_the_accepted_comment() {
let files = [file("a.c", TWO)];
@@ -494,9 +459,7 @@ mod tests {
assert_eq!(selection.plans[0].original, TWO.as_bytes());
}
- /// The question says which comment it is about — where it starts, what kind
- /// it is, and how far through the file and the run it sits — and shows the
- /// line as it stands against the line the answer would leave behind.
+ /// The question says which comment it is about — where it starts, what kind it is, and how far through the file and the run it sits — and shows the line as it stands against the line the answer would leave behind.
#[test]
fn the_prompt_names_the_comment_and_shows_the_hunk() {
let (_, transcript) = ask(&[file("a.c", TWO)], "y\nn\n");
@@ -523,8 +486,7 @@ mod tests {
);
}
- /// Three lines either side of the comment are shown unprefixed, so the
- /// reader can tell what the line is doing before answering for it.
+ /// Three lines either side of the comment are shown unprefixed, so the reader can tell what the line is doing before answering for it.
#[test]
fn the_hunk_carries_three_lines_of_context_on_each_side() {
let source = "1\n2\n3\n4\n5\nx/* c */y\n6\n7\n8\n9\n10\n";
@@ -554,8 +516,7 @@ mod tests {
}
/// A comment tall enough to fill the screen would push the question off it.
- /// Both sides of the change are capped at `CONTEXT` lines each end, with one
- /// marker standing for everything folded away, so the prompt stays in view.
+ /// Both sides of the change are capped at `CONTEXT` lines each end, with one marker standing for everything folded away, so the prompt stays in view.
#[test]
fn a_tall_hunk_is_capped_on_both_sides() {
let source = tall(27);
@@ -594,8 +555,7 @@ mod tests {
);
}
- /// A change that fits is shown whole: nothing is folded and nothing says it
- /// was.
+ /// A change that fits is shown whole: nothing is folded and nothing says it was.
#[test]
fn a_short_hunk_is_shown_whole() {
let (_, transcript) = ask(&[file("a.c", TWO)], "n\nn\n");
@@ -609,8 +569,7 @@ mod tests {
);
}
- /// `a` answers for the rest of the file at once and asks nothing more about
- /// it; the next file starts asking again.
+ /// `a` answers for the rest of the file at once and asks nothing more about it; the next file starts asking again.
#[test]
fn a_removes_the_rest_of_the_file_without_asking() {
let files = [file("a.c", TWO), file("b.c", TWO)];
@@ -632,8 +591,7 @@ mod tests {
);
}
- /// `d` is the same for the other answer: nothing in the file is removed, so
- /// the file has no plan at all.
+ /// `d` is the same for the other answer: nothing in the file is removed, so the file has no plan at all.
#[test]
fn d_keeps_the_rest_of_the_file_without_asking() {
let files = [file("a.c", TWO), file("b.c", TWO)];
@@ -676,8 +634,7 @@ mod tests {
);
}
- /// A closed input is a reader who is no longer there to answer, which is
- /// the one answer that cannot be guessed at: it aborts.
+ /// A closed input is a reader who is no longer there to answer, which is the one answer that cannot be guessed at: it aborts.
#[test]
fn end_of_input_aborts_like_x() {
let files = [file("a.c", TWO)];
@@ -721,18 +678,10 @@ mod tests {
);
}
- /// Under `layout = "columns"` a removal is replaced by exactly as many
- /// display columns as the comment occupied, and every such replacement is
- /// measured from the *source*, not from whatever earlier removals left
- /// behind. That is what lets this command apply a subset of the edits a
- /// transform produced: a width-preserving replacement moves nothing, so
- /// each remaining comment still begins at the display column its own
- /// replacement was computed for.
+ /// Under `layout = "columns"` a removal is replaced by exactly as many display columns as the comment occupied, and every such replacement is measured from the *source*, not from whatever earlier removals left behind.
+ /// That is what lets this command apply a subset of the edits a transform produced: a width-preserving replacement moves nothing, so each remaining comment still begins at the display column its own replacement was computed for.
///
- /// Pinned by transforming the partially edited bytes again and requiring
- /// the replacement to come out byte-identical to the one the full transform
- /// computed — the tab inside the second comment makes that replacement
- /// depend on the column it starts at.
+ /// Pinned by transforming the partially edited bytes again and requiring the replacement to come out byte-identical to the one the full transform computed — the tab inside the second comment makes that replacement depend on the column it starts at.
#[test]
fn partial_column_edits_keep_the_replacement_the_transform_computed() {
let source = b"x/* one */y/* a\tb */z\n";
diff --git a/rust/ocomment/src/lsp.rs b/rust/ocomment/src/lsp.rs
index 4be747a..5c81227 100644
--- a/rust/ocomment/src/lsp.rs
+++ b/rust/ocomment/src/lsp.rs
@@ -1,7 +1,7 @@
use crate::{
config::{self, ResolvedConfig},
files,
- output::{kept_label, removable_label},
+ output::{kept_label, removable_label, rewritten_label},
plugin::PluginHost,
};
use anyhow::Result as AnyResult;
@@ -47,9 +47,8 @@ struct WorkspaceEditEntry {
}
struct WorkspaceContext {
- /// The workspace folder or standalone document directory this context was
- /// discovered from. It is distinct from `configuration.root`, which may be
- /// an ancestor containing `.ocomment.toml`.
+ /// The workspace folder or standalone document directory this context was discovered from.
+ /// It is distinct from `configuration.root`, which may be an ancestor containing `.ocomment.toml`.
scope_root: PathBuf,
configuration: ResolvedConfig,
plugins: PluginHost,
@@ -236,17 +235,42 @@ impl Backend {
.report
.comments
.iter()
- .filter(|comment| comment.disposition.is_remove())
+ .filter(|comment| comment.disposition().action().changes_bytes())
{
+ /* NOTE: A rewrite is not an unnecessary comment.
+ * `DiagnosticTag::UNNECESSARY` is what greys a span out in an editor, and greying out a paragraph that is staying -- and only being written differently -- tells the reader the opposite of what was decided. */
+ let rewrite = !comment.action().removes();
diagnostics.push(tower_lsp::lsp_types::Diagnostic {
range: span_to_range(document.text.as_bytes(), comment.span, &encoding),
severity: Some(DiagnosticSeverity::HINT),
- code: Some(NumberOrString::String("removable-comment".into())),
+ code: Some(NumberOrString::String(
+ if rewrite {
+ "restyled-comment"
+ } else {
+ "removable-comment"
+ }
+ .into(),
+ )),
+ code_description: None,
+ source: Some("ocomment".into()),
+ message: crate::output::finding_label(comment),
+ related_information: None,
+ tags: (!rewrite).then(|| vec![DiagnosticTag::UNNECESSARY]),
+ data: None,
+ });
+ }
+ /* NOTE: And the paragraphs, which are not any one comment's.
+ * A reflow is decided over a run of comments or over a document's own prose, and an editor that showed only the per-comment rules would show nothing at all for the rule this tool is usually run for. */
+ for run in &result.report.runs {
+ diagnostics.push(tower_lsp::lsp_types::Diagnostic {
+ range: span_to_range(document.text.as_bytes(), run.span, &encoding),
+ severity: Some(DiagnosticSeverity::HINT),
+ code: Some(NumberOrString::String("restyled-paragraph".into())),
code_description: None,
source: Some("ocomment".into()),
- message: removable_label(comment.kind),
+ message: crate::output::run_label(run),
related_information: None,
- tags: Some(vec![DiagnosticTag::UNNECESSARY]),
+ tags: None,
data: None,
});
}
@@ -319,9 +343,8 @@ impl Backend {
*self.default_context.write().await = default;
*self.workspace_contexts.write().await = workspaces;
self.standalone_contexts.write().await.clear();
- /* NOTE: A cached incremental scanner belongs to the options of the
- * generation that built it. Dropping the cache is enough; the next
- * document operation performs a full scan under the new context. */
+ /* NOTE: A cached incremental scanner belongs to the options of the generation that built it.
+ * Dropping the cache is enough; the next document operation performs a full scan under the new context. */
for document in self.documents.write().await.values_mut() {
document.incremental = None;
}
@@ -426,9 +449,8 @@ impl Backend {
.map(|(uri, document)| (uri.clone(), document.clone()))
.collect();
let contexts = self.workspace_contexts.read().await.clone();
- /* NOTE: With no folders there is no disk workspace to discover. The
- * protocol's folder-less mode defines the workspace as the open
- * documents, wherever those documents live. */
+ /* NOTE: With no folders there is no disk workspace to discover.
+ * The protocol's folder-less mode defines the workspace as the open documents, wherever those documents live. */
if contexts.is_empty() {
let mut snapshots: Vec<_> = open
.into_iter()
@@ -490,10 +512,8 @@ impl Backend {
.await;
}
for file in discovery.files {
- /* NOTE: Nested workspace folders own their subtree. Keeping a
- * copy discovered through an outer root would bypass the
- * inner context's file include/exclude and size policy before
- * the transform ever gets a chance to route by URI. */
+ /* NOTE: Nested workspace folders own their subtree.
+ * Keeping a copy discovered through an outer root would bypass the inner context's file include/exclude and size policy before the transform ever gets a chance to route by URI. */
let owned_by_more_specific_context = contexts.iter().any(|candidate| {
candidate.scope_root != context.scope_root
&& candidate.scope_root.components().count()
@@ -612,10 +632,9 @@ impl LanguageServer for Backend {
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::INCREMENTAL),
- /* NOTE: Capabilities cannot be withdrawn when live
- * configuration changes. Advertise the handler once;
- * it reads `lsp.on_save` for every request and becomes
- * a no-op while the setting is disabled. */
+ /* NOTE: Capabilities cannot be withdrawn when live configuration changes.
+ * Advertise the handler once;
+ * it reads `lsp.on_save` for every request and becomes a no-op while the setting is disabled. */
will_save: Some(true),
will_save_wait_until: Some(true),
save: Some(TextDocumentSyncSaveOptions::Supported(true)),
@@ -1005,10 +1024,13 @@ impl LanguageServer for Backend {
else {
return Ok(None);
};
- let text = match &comment.disposition {
+ let text = match comment.disposition() {
Disposition::Remove => format!("OComment: {}", removable_label(comment.kind)),
Disposition::Keep { reason } => {
- format!("OComment: {}", kept_label(comment.kind, reason))
+ format!("OComment: {}", kept_label(comment.kind, reason.as_str()))
+ }
+ Disposition::Rewrite { rule, .. } => {
+ format!("OComment: {}", rewritten_label(comment.kind, rule.detail()))
}
};
Ok(Some(Hover {
@@ -1050,10 +1072,30 @@ impl LanguageServer for Backend {
})
.map(|edit| edit.span)
.collect();
+ /* NOTE: What the edit does, and not what this server used to only do.
+ * An edit that rewrites a paragraph is offered under the same code action as an edit that removes a comment -- the plan holds both -- and a title that said "remove" would be the editor telling the reader their documentation is about to be deleted. */
+ let rewrites = |span: ByteSpan| -> bool {
+ result.report.runs.iter().any(|run| run.span == span)
+ || result
+ .report
+ .comments
+ .iter()
+ .any(|comment| comment.span == span && !comment.action().removes())
+ };
+ let anywhere = !result.report.runs.is_empty()
+ || result
+ .report
+ .comments
+ .iter()
+ .any(|comment| comment.action().changes_bytes() && !comment.action().removes());
let mut actions = Vec::new();
if let Some(span) = selected.first().copied() {
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
- title: "Remove this comment".into(),
+ title: if rewrites(span) {
+ "Tidy this paragraph".into()
+ } else {
+ "Remove this comment".to_owned()
+ },
kind: Some(CodeActionKind::QUICKFIX),
edit: Some(
self.document_workspace_edit(&uri, &document, Some(&[span]))
@@ -1065,7 +1107,11 @@ impl LanguageServer for Backend {
}
if selected.len() > 1 {
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
- title: "Remove comments in selection".into(),
+ title: if selected.iter().copied().any(rewrites) {
+ "Apply OComment to the selection".into()
+ } else {
+ "Remove comments in selection".to_owned()
+ },
kind: Some(CodeActionKind::QUICKFIX),
edit: Some(
self.document_workspace_edit(&uri, &document, Some(&selected))
@@ -1075,7 +1121,11 @@ impl LanguageServer for Backend {
}));
}
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
- title: "Remove all comments in document".into(),
+ title: if anywhere {
+ "Apply OComment to this document".into()
+ } else {
+ "Remove all comments in document".to_owned()
+ },
kind: Some(CodeActionKind::new("source.fixAll.ocomment")),
edit: Some(self.document_workspace_edit(&uri, &document, None).await),
..CodeAction::default()
@@ -1222,6 +1272,7 @@ fn failure_result(source: &[u8], code: &str, message: String) -> TransformResult
report: ScanReport {
language: Language::Unknown,
comments: Vec::new(),
+ runs: Vec::new(),
diagnostics: vec![CoreDiagnostic {
code: code.into(),
message,
@@ -1241,6 +1292,7 @@ fn unchanged_result(source: &[u8], language: Language) -> TransformResult {
report: ScanReport {
language,
comments: Vec::new(),
+ runs: Vec::new(),
diagnostics: Vec::new(),
valid: true,
},
@@ -1255,11 +1307,8 @@ fn language_from_lsp(id: &str, uri: &Url, source: &[u8]) -> (Language, Dialect)
"objective-c" => (Language::C, Dialect::ObjectiveC),
"objective-cpp" => (Language::Cpp, Dialect::ObjectiveCpp),
"cuda-cpp" => (Language::Cpp, Dialect::Cuda),
- /* NOTE: One editor id covers sh, Bash, and zsh alike, and the dialects
- * differ — `$'...'` is an ANSI-C quoted string in the last two only.
- * The id settles the language, so the dialect is taken from the path
- * and the bytes whenever they agree it is a shell script at all, and
- * falls back to the language default when a buffer offers neither. */
+ /* NOTE: One editor id covers sh, Bash, and zsh alike, and the dialects differ — `$'...'` is an ANSI-C quoted string in the last two only.
+ * The id settles the language, so the dialect is taken from the path and the bytes whenever they agree it is a shell script at all, and falls back to the language default when a buffer offers neither. */
"shellscript" => (
Language::Shell,
detected_dialect(uri, source, Language::Shell).unwrap_or(Dialect::Standard),
@@ -1276,8 +1325,7 @@ fn language_from_lsp(id: &str, uri: &Url, source: &[u8]) -> (Language, Dialect)
}
}
-/// The dialect the path and the bytes imply, when they agree with the language
-/// the client named.
+/// The dialect the path and the bytes imply, when they agree with the language the client named.
fn detected_dialect(uri: &Url, source: &[u8], language: Language) -> Option {
let path = uri.to_file_path().ok();
detect_language(path.as_deref(), source)
@@ -1475,13 +1523,10 @@ mod tests {
editor_ids: Vec,
}
- /// Every language identifier the VS Code extension attaches the server to
- /// has to reach a built-in language here.
+ /// Every language identifier the VS Code extension attaches the server to has to reach a built-in language here.
///
- /// This crate-local test reads only the packaged language asset, so it also
- /// runs after a `.crate` is expanded in an otherwise empty directory. The
- /// repository integration test separately proves that the VS Code manifest
- /// contains this exact canonical set.
+ /// This crate-local test reads only the packaged language asset, so it also runs after a `.crate` is expanded in an otherwise empty directory.
+ /// The repository integration test separately proves that the VS Code manifest contains this exact canonical set.
#[test]
fn every_editor_language_identifier_reaches_a_built_in_language() {
let table: EditorLanguageTable = toml::from_str(include_str!("../assets/languages.toml"))
diff --git a/rust/ocomment/src/main.rs b/rust/ocomment/src/main.rs
index 9495c1b..8efec36 100644
--- a/rust/ocomment/src/main.rs
+++ b/rust/ocomment/src/main.rs
@@ -1,8 +1,7 @@
//! The `ocomment` command.
//!
//! Every module below has a side effect: files, Git, plugins, output, hooks.
-//! The engine that decides what a comment is has none and lives in
-//! `ocomment-core`.
+//! The engine that decides what a comment is has none and lives in `ocomment-core`.
mod advice;
mod atomic;
@@ -32,16 +31,12 @@ use std::{
/// Whether the reader of the program's own output is what ended the run.
///
-/// `ocomment … | head` closes the pipe as soon as the reader has what it came
-/// for. That is the reader finishing, not the run failing, so — following the
-/// convention `rg` and `fd` set — the process ends quietly with status 0
-/// rather than reporting an I/O error to a terminal that may itself be gone.
-/// The failing write can be several layers down: the serializer wraps it, and
-/// the caller adds context on top.
+/// `ocomment … | head` closes the pipe as soon as the reader has what it came for.
+/// That is the reader finishing, not the run failing, so — following the convention `rg` and `fd` set — the process ends quietly with status 0 rather than reporting an I/O error to a terminal that may itself be gone.
+/// The failing write can be several layers down: the serializer wraps it, and the caller adds context on top.
///
-/// Only the writers of *our* report may claim this, and they say so by tagging
-/// the failure with [`output::OutputPipeClosed`]. A bare `BrokenPipe` from
-/// anywhere else — the write that feeds a rewritten blob to `git hash-object`,
+/// Only the writers of *our* report may claim this, and they say so by tagging the failure with [`output::OutputPipeClosed`].
+/// A bare `BrokenPipe` from anywhere else — the write that feeds a rewritten blob to `git hash-object`,
/// above all — is a real failure whose silent success would lose data.
fn output_pipe_closed(error: &anyhow::Error) -> bool {
error
@@ -54,9 +49,7 @@ fn main() -> ExitCode {
Ok(code) => ExitCode::from(code),
Err(error) if output_pipe_closed(&error) => ExitCode::SUCCESS,
Err(error) => {
- /* NOTE: Nothing is left to try if even the report cannot be written, and
- * `eprintln!` would panic there — an abort under the release
- * profile — so the failure of the last write is dropped. */
+ /* NOTE: Nothing is left to try if even the report cannot be written, and `eprintln!` would panic there — an abort under the release profile — so the failure of the last write is dropped. */
let message = output::sanitize_message(&format!("{error:#}"));
let _ = writeln!(io::stderr(), "ocomment: {message}");
ExitCode::from(2)
@@ -80,9 +73,8 @@ mod tests {
assert!(output_pipe_closed(&error));
}
- /// `git hash-object` exiting before it reads the blob raises a bare
- /// `BrokenPipe` that no output writer tagged. Ending quietly there would
- /// report a `fix --staged` that never happened.
+ /// `git hash-object` exiting before it reads the blob raises a bare `BrokenPipe` that no output writer tagged.
+ /// Ending quietly there would report a `fix --staged` that never happened.
#[test]
fn an_untagged_broken_pipe_is_not_an_output_pipe_closure() {
let error = Result::<()>::Err(Error::from(ErrorKind::BrokenPipe).into())
diff --git a/rust/ocomment/src/output.rs b/rust/ocomment/src/output.rs
index 92226c5..3dd0caa 100644
--- a/rust/ocomment/src/output.rs
+++ b/rust/ocomment/src/output.rs
@@ -7,9 +7,9 @@ use clap::ValueEnum;
#[cfg(test)]
use ocomment_core::TransformResult;
use ocomment_core::{
- ByteSpan, Comment, CommentKind, Diagnostic, Disposition, DispositionExplanation,
- DispositionPatterns, Edit, Language, Policy, Protection, ScanOptions, ScanReport, Severity,
- SourceMap, TransformPlan, explain_comment_with,
+ Action, ByteSpan, Comment, CommentKind, Diagnostic, Disposition, DispositionExplanation,
+ DispositionPatterns, Edit, Language, Policy, ProseOrigin, ProseRun, Protection, ScanOptions,
+ ScanReport, Severity, SourceMap, StyleRule, TransformPlan, explain_comment_with,
};
use serde::{Serialize, Serializer, ser::SerializeSeq};
use serde_json::{Value, json};
@@ -24,19 +24,15 @@ use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum OutputFormat {
- /// Every finding on one line, in the `path:line:column:` stream a pipeline
- /// greps. Kept because a pipeline written against it should not have to be
- /// rewritten, and because one line per finding is the right shape for
- /// counting even when it is the wrong shape for deciding.
+ /// Every finding on one line, in the `path:line:column:` stream a pipeline greps.
+ /// Kept because a pipeline written against it should not have to be rewritten, and because one line per finding is the right shape for counting even when it is the wrong shape for deciding.
Human,
- /// The findings grouped by the decision each one asks for, with the edit
- /// beside it. The default everywhere, terminal or pipe.
+ /// The findings grouped by the decision each one asks for, with the edit beside it.
+ /// The default everywhere, terminal or pipe.
///
- /// Not switched on by a terminal, which is what every neighbouring tool
- /// does and is wrong here. An agent reads this through a pipe and a person
- /// reads it on a screen, and the two are in the same conversation about the
- /// same run: a format that changes shape between them leaves each arguing
- /// from something the other cannot see. Colour still follows the terminal,
+ /// Not switched on by a terminal, which is what every neighbouring tool does and is wrong here.
+ /// An agent reads this through a pipe and a person reads it on a screen, and the two are in the same conversation about the same run: a format that changes shape between them leaves each arguing from something the other cannot see.
+ /// Colour still follows the terminal,
/// because colour is the one thing that carries no meaning of its own.
#[default]
Review,
@@ -49,15 +45,11 @@ pub enum OutputFormat {
}
impl OutputFormat {
- /// Whether this is a report a person reads, as opposed to one a program
- /// parses.
+ /// Whether this is a report a person reads, as opposed to one a program parses.
///
/// The two differ in layout and in nothing else that decides anything here:
- /// both carry their notes on standard error, both may show progress, and
- /// both are what `config` and `strip` write. Asked as one question so that
- /// a format added beside them is answered once -- which is how `review`
- /// reached CI having been taught about seven of the nine places that spell
- /// out `== Human` and not the other two.
+ /// both carry their notes on standard error, both may show progress, and both are what `config` and `strip` write.
+ /// Asked as one question so that a format added beside them is answered once -- which is how `review` reached CI having been taught about seven of the nine places that spell out `== Human` and not the other two.
#[must_use]
pub const fn for_a_person(self) -> bool {
matches!(self, Self::Human | Self::Review)
@@ -68,8 +60,51 @@ impl OutputFormat {
pub enum Operation {
Check,
Scan,
- Diff,
- Fix,
+ /// The patch a writing run would apply, carrying which half it would apply.
+ Diff(Writes),
+ /// A run that writes, carrying which half of the report it writes.
+ ///
+ /// The half is inside the variant rather than beside it so that the places that asked `== Operation::Fix` have to be read again.
+ /// Most of them mean "this run writes" and a few mean "this run removes", and the two were the same question until a tidying run existed; a new variant beside `Fix` would have left every one of them answering the old one.
+ Fix(Writes),
+}
+
+/// Which of the two axes a writing run puts on the disk.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Writes {
+ /// Every edit the report called for, removals included.
+ Everything,
+ /// What the style rules rewrote, and no removal.
+ /// The removals are still reported and still decide the exit code; they simply do not reach the file.
+ RewritesOnly,
+}
+
+impl Operation {
+ /// Whether this run puts bytes on the disk at all.
+ #[must_use]
+ pub const fn writes(self) -> bool {
+ matches!(self, Self::Fix(_))
+ }
+
+ /// Whether this run is one that takes comments away.
+ ///
+ /// Separate from [`Self::writes`] because a tidying run does the first and not the second, and the reports differ in every word that names what happened.
+ #[must_use]
+ pub const fn removes(self) -> bool {
+ matches!(
+ self,
+ Self::Fix(Writes::Everything) | Self::Diff(Writes::Everything)
+ )
+ }
+
+ /// Which half of the report this run acts on, for the two that act on one.
+ #[must_use]
+ pub const fn half(self) -> Option {
+ match self {
+ Self::Diff(writes) | Self::Fix(writes) => Some(writes),
+ Self::Check | Self::Scan => None,
+ }
+ }
}
#[derive(Clone, Copy, Debug, Default)]
@@ -80,21 +115,14 @@ pub struct Presentation {
/// How much of the human report a run is allowed to write.
///
-/// Deliberately opaque, and deliberately not comparable. The convention in
-/// CONTRIBUTING.md is that standard output carries the command's product and
-/// standard error carries the summary and the notes, and that `-q` drops the
-/// second — and that was a convention rather than a mechanism, so three
-/// separate tests of the quiet level grew on the product side. One of them
-/// left `ocomment check -q` exiting 1 having printed nothing at all, which is
-/// exactly the shape a pre-commit hook wants and the one thing it could not
-/// get.
+/// Deliberately opaque, and deliberately not comparable.
+/// The convention in CONTRIBUTING.md is that standard output carries the command's product and standard error carries the summary and the notes, and that `-q` drops the second — and that was a convention rather than a mechanism, so three separate tests of the quiet level grew on the product side.
+/// One of them left `ocomment check -q` exiting 1 having printed nothing at all, which is exactly the shape a pre-commit hook wants and the one thing it could not get.
///
-/// Every one of those was written by somebody asking "is this run quiet?" and
-/// deciding for themselves. There is now no way to ask. [`Level`] is private
-/// and this type has no `PartialEq`, so `verbosity == Verbosity::Quiet` does
-/// not compile; the only question available is [`Self::shows`], which answers
-/// for a [`Detail`] rather than for a level, and the only writer that consults
-/// it is [`note`].
+/// Every one of those was written by somebody asking "is this run quiet?"
+/// and deciding for themselves.
+/// There is now no way to ask.
+/// [`Level`] is private and this type has no `PartialEq`, so `verbosity == Verbosity::Quiet` does not compile; the only question available is [`Self::shows`], which answers for a [`Detail`] rather than for a level, and the only writer that consults it is [`note`].
#[derive(Clone, Copy, Debug, Default)]
pub struct Verbosity(Level);
@@ -112,8 +140,8 @@ enum Level {
/// How much a line of commentary is worth saying.
///
/// A note is `Normal` unless it is the kind of thing only a `-v` run wants,
-/// and saying which is the whole of what a caller has to decide. Whether the
-/// run is quiet is not their business.
+/// and saying which is the whole of what a caller has to decide.
+/// Whether the run is quiet is not their business.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Detail {
/// Said unless the run asked for quiet.
@@ -142,10 +170,7 @@ impl Verbosity {
/// This verbosity with quiet raised to normal.
///
- /// One caller: an editor asking for diagnostics is asking for the report
- /// in a machine format, not for commentary about it, and a client told to
- /// work quietly is still owed the notice for a path it named and the error
- /// for a file it could not read.
+ /// One caller: an editor asking for diagnostics is asking for the report in a machine format, not for commentary about it, and a client told to work quietly is still owed the notice for a path it named and the error for a file it could not read.
pub const fn at_least_normal(self) -> Self {
match self.0 {
Level::Quiet => Self(Level::Normal),
@@ -168,29 +193,23 @@ pub struct RenderOptions {
/// Human `check` and `scan` lines carry every comment, kept ones included,
/// each under an indented line naming the rule that decided it.
pub explain: bool,
- /// The run is `fix --dry-run`: it produces the diff but speaks the
- /// vocabulary of the `fix` it is standing in for.
+ /// The run is `fix --dry-run`: it produces the diff but speaks the vocabulary of the `fix` it is standing in for.
pub dry_run: bool,
- /// `--force-invalid` was in effect, so a file that fails to scan still had
- /// the edits of the part that scanned applied.
+ /// `--force-invalid` was in effect, so a file that fails to scan still had the edits of the part that scanned applied.
pub force_invalid: bool,
- /// The run reached the disk. A `fix` blocked by invalid syntax or an I/O
- /// error leaves this false and must not claim any removal.
+ /// The run reached the disk.
+ /// A `fix` blocked by invalid syntax or an I/O error leaves this false and must not claim any removal.
pub applied: bool,
- /// The policy the run was asked for. Only `all` promises to take every
- /// comment out, so only `all` owes an explanation for the ones it keeps.
+ /// The policy the run was asked for.
+ /// Only `all` promises to take every comment out, so only `all` owes an explanation for the ones it keeps.
pub policy: Policy,
- /// `--annotation-level`: the `::` level `--format github` reports a
- /// removable comment at, or `None` to take it from the exit status the
- /// operation will produce.
+ /// `--annotation-level`: the `::` level `--format github` reports a removable comment at, or `None` to take it from the exit status the operation will produce.
pub annotation_level: Option,
}
/// The three levels a GitHub Actions workflow command can carry.
///
-/// A diagnostic is always `::error` whatever this says: a file that would not
-/// scan is not a finding the run is offering an opinion about, it is a file
-/// the run could not read.
+/// A diagnostic is always `::error` whatever this says: a file that would not scan is not a finding the run is offering an opinion about, it is a file the run could not read.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AnnotationLevel {
Error,
@@ -199,8 +218,7 @@ pub enum AnnotationLevel {
}
impl AnnotationLevel {
- /// Every CLI-visible level, loudest first, which is the order a reader
- /// choosing one is deciding in.
+ /// Every CLI-visible level, loudest first, which is the order a reader choosing one is deciding in.
pub const ALL: [Self; 3] = [Self::Error, Self::Warning, Self::Notice];
/// The canonical name, which is also the workflow command GitHub reads.
@@ -212,9 +230,8 @@ impl AnnotationLevel {
}
}
- /// Accepted spellings besides [`Self::as_str`]. There are none: these three
- /// are GitHub's own words and renaming them would only invite a value that
- /// does not reach the log.
+ /// Accepted spellings besides [`Self::as_str`].
+ /// There are none: these three are GitHub's own words and renaming them would only invite a value that does not reach the log.
pub const fn aliases(self) -> &'static [&'static str] {
&[]
}
@@ -224,23 +241,27 @@ impl AnnotationLevel {
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Summary {
pub files_scanned: usize,
- pub files_with_removable: usize,
+ /// Files holding at least one comment this run would change, whether by removing it or by rewriting it.
+ ///
+ /// Was `files_with_removable`, which was already serialised under the name it has now: the report had been counting findings and calling them removals since before there was anything else to count.
+ pub files_with_findings: usize,
pub removable_comments: usize,
+ /// Comments a style rule would rewrite on their own.
+ /// Counted apart from the removals because the two ask a reader for different things: a removal is a decision they have to make, and a rewrite is one the tool has already made and is offering to apply.
+ pub rewritable_comments: usize,
+ /// Paragraphs a style rule would rewrite, which is a run of comments or a paragraph of a document.
+ /// Counted apart from the comments because it is not one of them: a paragraph of a Markdown document is prose the same rule reaches, and calling it a comment in a report would tell a reader something about their file that is not so.
+ pub rewritable_paragraphs: usize,
pub kept_comments: usize,
pub files_changed: usize,
pub comments_removed: usize,
pub invalid_files: usize,
- /// Files written from a scan that had failed, which only `--force-invalid`
- /// can reach. These are the writes no re-scan covered: the result of
- /// editing a file that does not lex does not lex either, so the check every
- /// other write passes has nothing to say about them. The summary has to
- /// name them, because the sentence it prints otherwise is a claim about
- /// evidence that was never collected.
+ /// Files written from a scan that had failed, which only `--force-invalid` can reach.
+ /// These are the writes no re-scan covered: the result of editing a file that does not lex does not lex either, so the check every other write passes has nothing to say about them.
+ /// The summary has to name them, because the sentence it prints otherwise is a claim about evidence that was never collected.
pub forced_files: usize,
- /// Non-error skips met while walking, counted under a short stable label
- /// rather than the raw reason, which can carry a configured byte limit.
- /// A path named on the command line is deliberately absent: it already has
- /// its own line on standard output and must not be counted twice.
+ /// Non-error skips met while walking, counted under a short stable label rather than the raw reason, which can carry a configured byte limit.
+ /// A path named on the command line is deliberately absent: it already has its own line on standard output and must not be counted twice.
pub skipped_by_reason: BTreeMap,
/// Non-error skips whose path was named on the command line.
pub named_skips: usize,
@@ -248,6 +269,30 @@ pub struct Summary {
}
impl Summary {
+ /// Every comment this run would change: the removals and the rewrites.
+ ///
+ /// The number every "is there anything to do" question wants, and the one that has to be asked rather than reading `removable_comments` — which is how a run with nothing but rewrites to its name came to report itself clean while exiting 1.
+ pub const fn findings(&self) -> usize {
+ self.removable_comments + self.rewritten()
+ }
+
+ /// Everything a style rule would rewrite, however it is counted.
+ pub const fn rewritten(&self) -> usize {
+ self.rewritable_comments + self.rewritable_paragraphs
+ }
+
+ /// What to call the things this run would rewrite.
+ ///
+ /// A run of comments and a paragraph of a document are both paragraphs; a comment a spacing rule reached on its own is a comment.
+ /// Where a run met both, the noun that covers them is the wider one.
+ pub const fn rewritten_noun(&self) -> &'static str {
+ if self.rewritable_paragraphs > 0 {
+ "paragraph"
+ } else {
+ "comment"
+ }
+ }
+
pub fn compute(files: &[ProcessedFile], skipped: &[SkippedFile], operation: Operation) -> Self {
let mut summary = Self {
files_scanned: files.len(),
@@ -255,10 +300,21 @@ impl Summary {
};
for file in files {
let removable = removable_count(file);
+ let rewritable = rewritable_count(file);
summary.removable_comments += removable;
- summary.kept_comments += file.result.report.comments.len() - removable;
- if removable > 0 {
- summary.files_with_removable += 1;
+ summary.rewritable_comments += rewritable_comments(file);
+ summary.rewritable_paragraphs += rewritable_paragraphs(file);
+ /* NOTE: Counted rather than subtracted.
+ * A rewritten run is a finding and is not a comment, so taking the findings away from the comments underflowed the moment a document's paragraph became one. */
+ summary.kept_comments += file
+ .result
+ .report
+ .comments
+ .iter()
+ .filter(|comment| !reported(comment))
+ .count();
+ if removable > 0 || rewritable > 0 {
+ summary.files_with_findings += 1;
}
if !file.result.report.valid {
summary.invalid_files += 1;
@@ -268,7 +324,7 @@ impl Summary {
if !file.result.report.valid {
summary.forced_files += 1;
}
- if operation == Operation::Fix {
+ if operation.removes() {
summary.comments_removed += removed_count(file);
}
}
@@ -298,55 +354,103 @@ fn removable_count(file: &ProcessedFile) -> usize {
.report
.comments
.iter()
- .filter(|comment| comment.disposition.is_remove())
+ .filter(|comment| comment.action().removes())
.count()
}
+/// Whether a comment is one this run has something to say about.
+///
+/// The question nearly every predicate in this file is really asking, and the question that used to be spelled `is_remove()` because removal was the only answer.
+/// A rewrite is a finding too: it appears in the report, it changes the bytes on disk, and it makes `check` exit non-zero.
+/// What it is not is a removal, and the handful of places that genuinely mean removal still say so.
+fn reported(comment: &Comment) -> bool {
+ comment.disposition().action().changes_bytes()
+}
+
+/// How many comments this run would rewrite on their own.
+fn rewritable_comments(file: &ProcessedFile) -> usize {
+ file.result
+ .report
+ .comments
+ .iter()
+ .filter(|comment| comment.action() == Action::Rewrite)
+ .count()
+}
+
+/// How many paragraphs this run would rewrite.
+///
+/// A rewritten run counts once.
+/// It covers several comments — or several lines of a document — and asks one question about them, which is where the paragraph breaks, and counting it per line would report a number nobody could act on one line at a time.
+fn rewritable_paragraphs(file: &ProcessedFile) -> usize {
+ file.result.report.runs.len()
+}
+
+/// Everything this run would rewrite rather than remove.
+fn rewritable_count(file: &ProcessedFile) -> usize {
+ rewritable_comments(file) + rewritable_paragraphs(file)
+}
+
+/// Whether a paragraph rule's verdict covers this comment.
+///
+/// A [`ProseRun`] spans the comments it reflows and records nothing on any of them, so a caller asking a comment what happened to it would be told "nothing" about a line the run is about to rewrite.
+fn covered_by_a_run(report: &ScanReport, comment: &Comment) -> bool {
+ report
+ .runs
+ .iter()
+ .any(|run| run.span.start <= comment.span.start && comment.span.end <= run.span.end)
+}
+
+/// How many of this file's comments and paragraphs a tidying run actually rewrote.
+///
+/// [`rewritable_count`] with the establishment filter [`removed_count`] carries, and for the same reason: a scan that failed part-way leaves the rest of the file unplanned,
+/// and a count that ignored that would report rewrites that did not happen.
+fn rewritten_count(file: &ProcessedFile) -> usize {
+ let report = &file.result.report;
+ let comments = report
+ .comments
+ .iter()
+ .filter(|comment| comment.action() == Action::Rewrite && report.established(comment.span))
+ .count();
+ let paragraphs = report
+ .runs
+ .iter()
+ .filter(|run| report.established(run.span))
+ .count();
+ comments + paragraphs
+}
+
/// How many comments a `fix` over this file actually took out.
///
-/// The same as [`removable_count`] whenever the scan succeeded, and smaller
-/// when it did not: a failed scan establishes only the part before the failure,
-/// `plan_report` edits only that part, and the removable comments past it are
-/// still removable and still in the file. Reporting what was removable would
-/// report removals that did not happen, which is the one number a deletion tool
-/// must not get wrong in the reassuring direction.
+/// The same as [`removable_count`] whenever the scan succeeded, and smaller when it did not: a failed scan establishes only the part before the failure,
+/// `plan_report` edits only that part, and the removable comments past it are still removable and still in the file.
+/// Reporting what was removable would report removals that did not happen, which is the one number a deletion tool must not get wrong in the reassuring direction.
fn removed_count(file: &ProcessedFile) -> usize {
let report = &file.result.report;
report
.comments
.iter()
- .filter(|comment| comment.disposition.is_remove() && report.established(comment.span))
+ .filter(|comment| comment.action().removes() && report.established(comment.span))
.count()
}
/// Say which keep or remove settings this run never used.
///
-/// A setting that does nothing is the one failure a protection tool must not
-/// keep to itself, because it fails in the direction that looks like success:
-/// a `keep_regex` you believe is holding a comment back, which is not, and
-/// which `fix` therefore removes. The pattern in this project's own reports
-/// was `^\s*swiftlint:` — written against the text of the comment, matched
-/// against the whole token, and so anchored in front of a `//` that is always
-/// there. Nothing said a word about it.
+/// A setting that does nothing is the one failure a protection tool must not keep to itself, because it fails in the direction that looks like success:
+/// a `keep_regex` you believe is holding a comment back, which is not, and which `fix` therefore removes.
+/// The pattern in this project's own reports was `^\s*swiftlint:` — written against the text of the comment, matched against the whole token, and so anchored in front of a `//` that is always there.
+/// Nothing said a word about it.
///
-/// So the rule is that no setting is silently ignored: it works, or the run
-/// says it did not. The report goes to standard error beside the summary,
-/// because it is commentary about the run rather than the run's product, and
-/// `-q` drops it with the rest of the commentary.
+/// So the rule is that no setting is silently ignored: it works, or the run says it did not.
+/// The report goes to standard error beside the summary,
+/// because it is commentary about the run rather than the run's product, and `-q` drops it with the rest of the commentary.
///
/// Report the `[[overrides]]` blocks that did nothing.
///
-/// The report beside this one catches a `keep_regex` written against text the
-/// comment does not hold. A path glob written against a path no file has is
-/// the same mistake one level up, and a worse one to make quietly: an override
-/// is how a project exempts files from a rule it keeps everywhere else, so a
-/// glob that matches nothing leaves that rule in force over exactly the files
-/// somebody had decided it should not apply to. The settings look present and
-/// the behaviour is as though they were never written.
+/// The report beside this one catches a `keep_regex` written against text the comment does not hold.
+/// A path glob written against a path no file has is the same mistake one level up, and a worse one to make quietly: an override is how a project exempts files from a rule it keeps everywhere else, so a glob that matches nothing leaves that rule in force over exactly the files somebody had decided it should not apply to.
+/// The settings look present and the behaviour is as though they were never written.
///
-/// Said with the count it was measured against, because it is a statement
-/// about this run and not about the repository: a glob for `.gitignore` is
-/// right to match nothing in a walk that met no `.gitignore`.
+/// Said with the count it was measured against, because it is a statement about this run and not about the repository: a glob for `.gitignore` is right to match nothing in a walk that met no `.gitignore`.
pub fn report_unused_overrides(
unused: &[(usize, &[String])],
reached: usize,
@@ -377,10 +481,8 @@ pub fn report_unused_overrides(
Ok(())
}
-/// It is written from the comments the run actually scanned, so it says "this
-/// run" and means it. A run narrowed to a handful of paths is expected to meet
-/// fewer patterns than a walk of the repository, which is why the caller only
-/// asks for this where the run walked a directory.
+/// It is written from the comments the run actually scanned, so it says "this run" and means it.
+/// A run narrowed to a handful of paths is expected to meet fewer patterns than a walk of the repository, which is why the caller only asks for this where the run walked a directory.
pub fn report_unused_settings(
files: &[ProcessedFile],
options: &ScanOptions,
@@ -394,10 +496,8 @@ pub fn report_unused_settings(
{
return Ok(());
}
- /* NOTE: A pattern list that will not compile is already a diagnostic, and
- * the scanner went on as though the list were empty. Reporting every
- * pattern in it as unused would bury that diagnostic under its own
- * consequences. */
+ /* NOTE: A pattern list that will not compile is already a diagnostic, and the scanner went on as though the list were empty.
+ * Reporting every pattern in it as unused would bury that diagnostic under its own consequences. */
let Ok(patterns) = DispositionPatterns::compile(options) else {
return Ok(());
};
@@ -471,10 +571,8 @@ pub fn report_unused_settings(
)?;
}
}
- /* NOTE: The one sentence that turns the report into a fix. Every pattern is
- * tried against the comment as it is written, opener and all, and a
- * pattern written against the text inside it is the mistake this whole
- * report exists to catch. */
+ /* NOTE: The one sentence that turns the report into a fix.
+ * Every pattern is tried against the comment as it is written, opener and all, and a pattern written against the text inside it is the mistake this whole report exists to catch. */
if unmatched_pattern {
note(
&mut report,
@@ -487,8 +585,7 @@ pub fn report_unused_settings(
Ok(())
}
-/// `([policy] in .ocomment.toml)`, or the shorter phrasing for a setting the
-/// trace cannot place.
+/// `([policy] in .ocomment.toml)`, or the shorter phrasing for a setting the trace cannot place.
fn origin_clause(trace: &PolicyTrace, key: &str, index: usize) -> String {
match trace.origin_at(key, index) {
Some(origin) => format!("it is set in {origin}"),
@@ -501,22 +598,18 @@ fn origin_clause(trace: &PolicyTrace, key: &str, index: usize) -> String {
/// The per-file line says what to do about one file; the summary counts many,
/// so it trades the sentence for a key short enough to sit in a list of them.
///
-/// Visible to the crate so the modules that *produce* the reasons — `files`
-/// and `git` — can name this function in their own documentation rather than
-/// describing a rule they do not own.
+/// Visible to the crate so the modules that *produce* the reasons — `files` and `git` — can name this function in their own documentation rather than describing a rule they do not own.
/// A reason a caller can refuse with `--deny-skipped`.
///
/// Closed, and spelled the way every other value this tool takes is spelled.
-/// The flag used to accept free text, matched against the label
-/// [`skip_label`] produces — which contains a space. So `unknown-language`,
-/// the spelling anyone would type and the one the help implies, matched
-/// nothing, was accepted without a word, and left the gate open. A gate that
-/// is off because of a typo is the exact failure this flag exists to prevent,
+/// The flag used to accept free text, matched against the label [`skip_label`] produces — which contains a space.
+/// So `unknown-language`,
+/// the spelling anyone would type and the one the help implies, matched nothing, was accepted without a word, and left the gate open.
+/// A gate that is off because of a typo is the exact failure this flag exists to prevent,
/// one level up from where it prevents it.
///
-/// A generated file is deliberately absent. Being passed over is what should
-/// happen to one, which `docs/configuration.md` says in as many words; this
-/// list is where that sentence is enforced rather than merely written.
+/// A generated file is deliberately absent.
+/// Being passed over is what should happen to one, which `docs/configuration.md` says in as many words; this list is where that sentence is enforced rather than merely written.
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum SkipReason {
/// Nothing here reads this kind of file: no built-in language claimed it,
@@ -535,8 +628,7 @@ pub enum SkipReason {
impl SkipReason {
/// The label [`skip_label`] gives the same skip.
///
- /// The two spellings have to agree, and they are in one file so that a
- /// change to either is a change a reader sees beside the other.
+ /// The two spellings have to agree, and they are in one file so that a change to either is a change a reader sees beside the other.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
@@ -563,48 +655,34 @@ pub(crate) fn skip_label(reason: &str) -> &str {
}
}
-/// The `Keep` reason the core scanner gives a shebang or encoding line that
-/// `--force-protected` would have removed. It is one of the six reasons the
-/// differential protocol freezes, so matching on it is stable; the end-to-end
-/// test `policy_all_says_how_to_remove_a_kept_preamble` is what would catch it
-/// drifting apart from the scanner.
+/// The `Keep` reason the core scanner gives a shebang or encoding line that `--force-protected` would have removed.
+/// It is one of the six reasons the differential protocol freezes, so matching on it is stable; the end-to-end test `policy_all_says_how_to_remove_a_kept_preamble` is what would catch it drifting apart from the scanner.
const PROTECTED_PREAMBLE: &str = "required source preamble";
-/// The `Keep` reason the core scanner gives a directive the language or its
-/// build reads, which `--force-protected` would likewise have removed. It is
-/// frozen by the differential protocol beside [`PROTECTED_PREAMBLE`], and for
-/// the same reason: the summary matches on it to name what `all` left behind.
+/// The `Keep` reason the core scanner gives a directive the language or its build reads, which `--force-protected` would likewise have removed.
+/// It is frozen by the differential protocol beside [`PROTECTED_PREAMBLE`], and for the same reason: the summary matches on it to name what `all` left behind.
const LOAD_BEARING: &str = "required by the language or its build";
-/// How many comments carry `protection`, the `Keep` reason of one of the two
-/// tiers `--force-protected` would have given up.
+/// How many comments carry `protection`, the `Keep` reason of one of the two tiers `--force-protected` would have given up.
///
-/// Counted from the disposition rather than from the comment kind: a shebang
-/// held back by `--keep-kind shebang` stays kept whatever `--force-protected`
-/// says, and advertising the flag for it would be a lie.
+/// Counted from the disposition rather than from the comment kind: a shebang held back by `--keep-kind shebang` stays kept whatever `--force-protected` says, and advertising the flag for it would be a lie.
fn kept_for(files: &[ProcessedFile], protection: &str) -> usize {
files
.iter()
.flat_map(|file| &file.result.report.comments)
.filter(|comment| {
- matches!(&comment.disposition, Disposition::Keep { reason } if reason == protection)
+ matches!(comment.disposition(), Disposition::Keep { reason } if reason == protection)
})
.count()
}
/// The headline's coverage clause: how much of what the walk reached was read.
///
-/// A skipped file was reached and *not* read, so adding the skips to the files
-/// that were scanned and calling the sum `scanned` says the opposite of what
-/// happened — and says it in the one direction that matters, making a gate
-/// look wider than it is. A run over seven files that could read two of them
-/// headlined `7 scanned` while `ocomment coverage` said `28.5%`, with the
-/// honest number in a clause at the end of the run that the headline
-/// contradicted three lines above it.
+/// A skipped file was reached and *not* read, so adding the skips to the files that were scanned and calling the sum `scanned` says the opposite of what happened — and says it in the one direction that matters, making a gate look wider than it is.
+/// A run over seven files that could read two of them headlined `7 scanned` while `ocomment coverage` said `28.5%`, with the honest number in a clause at the end of the run that the headline contradicted three lines above it.
///
/// When nothing was skipped the two numbers are equal and only one is printed:
-/// a denominator that always matches the numerator teaches a reader to stop
-/// reading it, which is exactly when it stops working.
+/// a denominator that always matches the numerator teaches a reader to stop reading it, which is exactly when it stops working.
fn scanned_clause(scanned: usize, skipped: usize) -> String {
let reached = scanned + skipped;
if skipped == 0 {
@@ -614,14 +692,13 @@ fn scanned_clause(scanned: usize, skipped: usize) -> String {
}
}
-/// `1 file` / `2 files`: the count and its noun, pluralized by the regular
-/// rule. Every noun the summary counts goes through this.
+/// `1 file` / `2 files`: the count and its noun, pluralized by the regular rule.
+/// Every noun the summary counts goes through this.
pub(crate) fn plural(count: usize, noun: &str) -> String {
format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
}
-/// `1 comment` / `2 removable comments`: the noun is pluralized and an
-/// optional adjective is placed in front of it.
+/// `1 comment` / `2 removable comments`: the noun is pluralized and an optional adjective is placed in front of it.
fn comments(count: usize, adjective: &str) -> String {
let space = if adjective.is_empty() { "" } else { " " };
plural(count, &format!("{adjective}{space}comment"))
@@ -629,14 +706,8 @@ fn comments(count: usize, adjective: &str) -> String {
/// What read the file.
///
-/// `Language` answers "which built-in language is this", and for a file a
-/// profile or a plugin read, that question has no answer: `Language::Unknown`
-/// is what detection returns, and every report that carried only the language
-/// said `unknown` about a file the run had just read completely and on
-/// purpose. A count of what was scanned that cannot name the reader also
-/// cannot tell a release that taught the tool a new format from a repository
-/// that grew one, which is the difference between an upgrade a reader can
-/// follow and a wall of findings that appeared overnight.
+/// `Language` answers "which built-in language is this", and for a file a profile or a plugin read, that question has no answer: `Language::Unknown` is what detection returns, and every report that carried only the language said `unknown` about a file the run had just read completely and on purpose.
+/// A count of what was scanned that cannot name the reader also cannot tell a release that taught the tool a new format from a repository that grew one, which is the difference between an upgrade a reader can follow and a wall of findings that appeared overnight.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReadBy {
/// A built-in language, which the file's `language` names.
@@ -648,8 +719,7 @@ pub enum ReadBy {
}
impl ReadBy {
- /// The reader as a machine format carries it: what kind of reader, and
- /// which one.
+ /// The reader as a machine format carries it: what kind of reader, and which one.
#[must_use]
pub fn as_json(&self, language: Language) -> Value {
match *self {
@@ -665,16 +735,16 @@ pub struct ProcessedFile {
pub path: PathBuf,
pub source: Vec,
pub language: Language,
- /// What read this file. Dropped for long enough that `--format json`
- /// reported `"language": "unknown"` for files a profile had read in full.
+ /// What read this file.
+ /// Dropped for long enough that `--format json` reported `"language": "unknown"` for files a profile had read in full.
pub read_by: ReadBy,
pub result: ProcessedResult,
}
/// The stages of a core transformation retained by the CLI.
///
-/// Reports are always present. Edits, a source map, and transformed bytes are
-/// materialized only for the commands and output formats that consume them.
+/// Reports are always present.
+/// Edits, a source map, and transformed bytes are materialized only for the commands and output formats that consume them.
#[derive(Clone, Debug)]
pub struct ProcessedResult {
pub report: ScanReport,
@@ -750,11 +820,9 @@ struct JsonFile<'a> {
language: Language,
/// Which reader answered for this file.
///
- /// `language` alone cannot say. A file a declarative profile read carries
- /// `Language::Unknown`, because no built-in language claimed it, and a
- /// reader given only that field was told `unknown` about a file the run
- /// had read from end to end. The two fields answer different questions and
- /// both are kept: `language` is which scanner's grammar applied,
+ /// `language` alone cannot say.
+ /// A file a declarative profile read carries `Language::Unknown`, because no built-in language claimed it, and a reader given only that field was told `unknown` about a file the run had read from end to end.
+ /// The two fields answer different questions and both are kept: `language` is which scanner's grammar applied,
/// `read_by` is what did the reading.
read_by: Value,
changed: bool,
@@ -762,42 +830,49 @@ struct JsonFile<'a> {
edits: &'a [ocomment_core::Edit],
/// The byte-for-byte mapping from the output back to the source.
///
- /// Left out unless `--source-map` asks for it. It is one segment per
- /// unchanged run, so a file with twenty-five comments in it produced
- /// several hundred lines of a report the caller had asked for because it
- /// was the machine format — and the thing a machine format is for is being
- /// read, not scrolled past.
+ /// Left out unless `--source-map` asks for it.
+ /// It is one segment per unchanged run, so a file with twenty-five comments in it produced several hundred lines of a report the caller had asked for because it was the machine format — and the thing a machine format is for is being read, not scrolled past.
#[serde(skip_serializing_if = "Option::is_none")]
source_map: Option<&'a SourceMap>,
}
-/// The scan report as a machine format writes it: everything
-/// [`ScanReport`] holds, and where each comment and diagnostic *is* besides.
+/// The scan report as a machine format writes it: everything [`ScanReport`] holds, and where each comment and diagnostic *is* besides.
///
-/// A byte span is the right primitive for a patcher and the wrong one for a
-/// reporter. Turning `0..27` into `1:1` means reopening the file and counting
-/// line breaks, and that is work this run has already done — the human report
-/// has printed `path:line:column` and the comment text since the beginning, so
-/// a caller that chose JSON because it was the machine format was handed less
-/// than the caller that chose prose. Every position here is derived from the
-/// span beside it, so the two cannot come apart.
+/// A byte span is the right primitive for a patcher and the wrong one for a reporter.
+/// Turning `0..27` into `1:1` means reopening the file and counting line breaks, and that is work this run has already done — the human report has printed `path:line:column` and the comment text since the beginning, so a caller that chose JSON because it was the machine format was handed less than the caller that chose prose.
+/// Every position here is derived from the span beside it, so the two cannot come apart.
#[derive(Serialize)]
struct JsonReport<'a> {
language: Language,
comments: Vec>,
+ /// The paragraphs a style rule would write differently, in source order.
+ ///
+ /// Beside the comments rather than among them: a run's bytes belong to no single comment, and a caller that read only `comments` would find every removal and no reflow while the exit code said there was something to do.
+ /// Absent where a run asked for no rule about how a paragraph is broken, which is every run that set none.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ runs: Vec>,
diagnostics: Vec>,
valid: bool,
}
-/// Where something the scanner reported sits, in the spelling every other
-/// OComment report uses.
+/// One rewritten paragraph, in the spelling the rest of this format uses.
+#[derive(Serialize)]
+struct JsonRun<'a> {
+ span: ByteSpan,
+ /* NOTE: Flattened, as it is on a comment and on a diagnostic.
+ * This was the one place in the format that nested it, which left a caller reading positions one way for two thirds of a report and another way for the rest. */
+ #[serde(flatten)]
+ position: JsonPosition,
+ origin: ProseOrigin,
+ rule: StyleRule,
+ /// What would replace the span, as text.
+ replacement: std::borrow::Cow<'a, str>,
+}
+
+/// Where something the scanner reported sits, in the spelling every other OComment report uses.
///
-/// Lines and columns are one-based and columns are counted in bytes, which is
-/// what the human report prints and what `--format github` puts in an
-/// annotation. `end_line` and `end_column` address the byte *after* the last
-/// one, matching the half-open [`ByteSpan`] they come from: a comment that
-/// ends at the end of its line has an `end_column` one past its last byte
-/// rather than a position on the next line.
+/// Lines and columns are one-based and columns are counted in bytes, which is what the human report prints and what `--format github` puts in an annotation.
+/// `end_line` and `end_column` address the byte *after* the last one, matching the half-open [`ByteSpan`] they come from: a comment that ends at the end of its line has an `end_column` one past its last byte rather than a position on the next line.
#[derive(Serialize)]
struct JsonPosition {
line: usize,
@@ -827,30 +902,22 @@ struct JsonComment<'a> {
kind: CommentKind,
/// `false` on a comment the scan did not establish, and absent otherwise.
///
- /// `valid` says whether the lex failed; it cannot say where, and a caller
- /// acting on a verdict needs that. A scanner that cannot find the end of a
- /// token does not know where the next one starts, so an unterminated block
- /// opener is reported as a comment running to the end of the file — and the
- /// code under it is not a comment. The verdict is here because it is what
- /// the scanner concluded; this field is here because acting on it would
- /// delete code. `fix --force-invalid` skips exactly these.
+ /// `valid` says whether the lex failed; it cannot say where, and a caller acting on a verdict needs that.
+ /// A scanner that cannot find the end of a token does not know where the next one starts, so an unterminated block opener is reported as a comment running to the end of the file — and the code under it is not a comment.
+ /// The verdict is here because it is what the scanner concluded; this field is here because acting on it would delete code.
+ /// `fix --force-invalid` skips exactly these.
///
- /// Written only when it is `false`, so a report from a source that lexed is
- /// the same bytes it has always been.
+ /// Written only when it is `false`, so a report from a source that lexed is the same bytes it has always been.
#[serde(skip_serializing_if = "Option::is_none")]
established: Option,
/// Which rule decided it, and where that rule was written.
///
- /// Present when `--explain` asked for it. The human report has printed
- /// this under each finding since the rule table was written down, and a
- /// caller that chose a machine format was handed the verdict without the
- /// reason — so the reason arrives here in fields rather than in the
- /// sentence the human report composes from them.
+ /// Present when `--explain` asked for it.
+ /// The human report has printed this under each finding since the rule table was written down, and a caller that chose a machine format was handed the verdict without the reason — so the reason arrives here in fields rather than in the sentence the human report composes from them.
#[serde(skip_serializing_if = "Option::is_none")]
explanation: Option,
- /// The comment's own bytes, decoded lossily the way every other text this
- /// tool serialises is. `--no-preview` leaves it out, which is the way to
- /// keep a report over a large tree small.
+ /// The comment's own bytes, decoded lossily the way every other text this tool serialises is.
+ /// `--no-preview` leaves it out, which is the way to keep a report over a large tree small.
#[serde(skip_serializing_if = "Option::is_none")]
text: Option>,
disposition: &'a Disposition,
@@ -864,8 +931,7 @@ struct JsonExplanation {
rule: String,
/// The same rule as the human report words it.
detail: String,
- /// Where the setting behind it was written — a table and a file — or
- /// absent when a built-in rule decided and there is no table to point at.
+ /// Where the setting behind it was written — a table and a file — or absent when a built-in rule decided and there is no table to point at.
#[serde(skip_serializing_if = "Option::is_none")]
setting: Option,
/// The flag that would overrule it, when one would.
@@ -906,7 +972,18 @@ fn json_report<'a>(
explanation: explainer
.map(|explainer| json_explanation(explainer, comment, source, language)),
text: preview.then(|| slice_text(source, comment.span)),
- disposition: &comment.disposition,
+ disposition: comment.disposition(),
+ })
+ .collect(),
+ runs: report
+ .runs
+ .iter()
+ .map(|run| JsonRun {
+ span: run.span,
+ position: JsonPosition::of(&lines, run.span),
+ origin: run.origin,
+ rule: run.rule,
+ replacement: String::from_utf8_lossy(&run.replacement),
})
.collect(),
diagnostics: report
@@ -953,8 +1030,7 @@ fn json_explanation(
/// The rule's own name, as a machine reads it.
///
-/// Exhaustive, so a verdict added later has to be named rather than falling
-/// into a bucket a caller would then be matching against forever.
+/// Exhaustive, so a verdict added later has to be named rather than falling into a bucket a caller would then be matching against forever.
fn explanation_rule(verdict: &DispositionExplanation) -> String {
match verdict {
DispositionExplanation::KeptByKind(_) => "kept-by-kind",
@@ -974,16 +1050,21 @@ fn explanation_rule(verdict: &DispositionExplanation) -> String {
DispositionExplanation::RemovedAsTrailing => "removed-as-trailing",
DispositionExplanation::RemovedAsExpired { .. } => "removed-as-expired",
DispositionExplanation::RemovedByLength { .. } => "removed-by-length",
+ DispositionExplanation::KeptByPolicy { .. } => "kept-by-policy",
+ /* NOTE: The rule's own name is part of the answer here, and not for symmetry: a caller matching on `rewritten-by-style` would be told that a comment is being rewritten without being told what about it was wrong, which is the only thing they could act on.
+ * The other verdicts carry that in a field; this one carries it in the name,
+ * because the rule *is* the verdict. */
+ DispositionExplanation::RewrittenByStyle { rule } => {
+ return format!("rewritten-by-{rule}");
+ }
}
.to_owned()
}
/// The bytes of `span`, decoded lossily and left whole.
///
-/// The human preview folds a comment onto one line and cuts it to a terminal
-/// width; neither is done here. A machine format that truncated would be
-/// handing its caller a comment that is not the comment in the file, and a
-/// caller that wants it shorter can cut it itself.
+/// The human preview folds a comment onto one line and cuts it to a terminal width; neither is done here.
+/// A machine format that truncated would be handing its caller a comment that is not the comment in the file, and a caller that wants it shorter can cut it itself.
fn slice_text(source: &[u8], span: ByteSpan) -> Cow<'_, str> {
let start = span.start.min(source.len());
let end = span.end.clamp(start, source.len());
@@ -995,33 +1076,79 @@ pub fn removable_label(kind: CommentKind) -> String {
format!("removable {kind} comment")
}
+/// The one-line label for a comment a report names, whichever answer it reached.
+///
+/// A rewrite is not a removal and a format that called it one would be telling a reader their comment is about to be deleted.
+pub fn finding_label(comment: &Comment) -> String {
+ match comment.disposition() {
+ Disposition::Rewrite { rule, .. } => format!("{} {}", rewrite_label(*rule), comment.kind),
+ Disposition::Remove | Disposition::Keep { .. } => removable_label(comment.kind),
+ }
+}
+
+/// What a style rule would do, as the opening of a label.
+fn rewrite_label(rule: StyleRule) -> &'static str {
+ match rule {
+ StyleRule::Wrap => "reflowed",
+ StyleRule::SpaceAfterMarker => "respaced",
+ StyleRule::TrailingWhitespace => "trimmed",
+ }
+}
+
+/// The one-line label for a paragraph a style rule would write differently.
+pub fn run_label(run: &ProseRun) -> String {
+ let what = match run.origin {
+ ProseOrigin::Comments => "comment paragraph",
+ ProseOrigin::Document => "paragraph",
+ };
+ format!("{} {what}", rewrite_label(run.rule))
+}
+
+/// The SARIF rule identifier for a paragraph a style rule would write differently.
+fn run_rule_id(run: &ProseRun) -> String {
+ let origin = match run.origin {
+ ProseOrigin::Comments => "comments",
+ ProseOrigin::Document => "document",
+ };
+ format!("restyle-{origin}-{}", run.rule)
+}
+
/// The one-line label for a comment OComment deliberately protects.
pub fn kept_label(kind: CommentKind, reason: &str) -> String {
format!("{}: {reason}", kept_prefix(kind))
}
-/// The same label without a reason, for a report that gives the reason on a
-/// line of its own.
+/// The same label without a reason, for a report that gives the reason on a line of its own.
fn kept_prefix(kind: CommentKind) -> String {
format!("kept {kind} comment")
}
-/// What `--explain` needs to account for one file's comments: the options its
-/// scan actually ran with, and where each of their settings came from.
+/// The one-line label for a comment OComment would rewrite.
+///
+/// Worded as what is wrong rather than as what will happen, the way a kept comment's label is: "rewritable" would be a word about the tool, and the reader is being told something about their comment.
+pub fn rewritten_label(kind: CommentKind, reason: &str) -> String {
+ format!("{}: {reason}", rewritten_prefix(kind))
+}
+
+/// The same label without a reason, for a report that gives the reason on a line of its own.
+fn rewritten_prefix(kind: CommentKind) -> String {
+ format!("rewritten {kind} comment")
+}
+
+/// What `--explain` needs to account for one file's comments: the options its scan actually ran with, and where each of their settings came from.
#[derive(Clone, Debug)]
pub struct FileExplanation {
pub options: ScanOptions,
pub trace: PolicyTrace,
}
-/// That material for the files of one run, under the path the run reports each
-/// file by. A run that was not asked to explain anything carries none.
+/// That material for the files of one run, under the path the run reports each file by.
+/// A run that was not asked to explain anything carries none.
pub type Explanations = BTreeMap;
/// One file's explanation material with its policy patterns already compiled.
///
-/// The two regex sets are the same for every comment in the file, so they are
-/// built once when the file is reached rather than once per reported line.
+/// The two regex sets are the same for every comment in the file, so they are built once when the file is reached rather than once per reported line.
struct Explainer<'a> {
material: &'a FileExplanation,
patterns: DispositionPatterns,
@@ -1039,15 +1166,10 @@ impl<'a> Explainer<'a> {
}
}
-/// The indented line under one reported comment: the rule that decided its
-/// fate, and either the setting behind that rule or the flag that would
-/// overrule it.
+/// The indented line under one reported comment: the rule that decided its fate, and either the setting behind that rule or the flag that would overrule it.
///
-/// The pattern a regex explanation quotes and the globs a source names were
-/// both written by whoever wrote the configuration, so the composed line gets a
-/// comment preview's treatment before it reaches a terminal: one line, no
-/// control sequences. The width is not capped — a line that ends in an ellipsis
-/// where the pattern was answers nothing.
+/// The pattern a regex explanation quotes and the globs a source names were both written by whoever wrote the configuration, so the composed line gets a comment preview's treatment before it reaches a terminal: one line, no control sequences.
+/// The width is not capped — a line that ends in an ellipsis where the pattern was answers nothing.
fn explanation_line(
file: &ProcessedFile,
comment: &Comment,
@@ -1064,10 +1186,8 @@ fn explanation_line(
file.language,
&material.options,
);
- /* NOTE: These were exclusive, which left a removal saying which setting
- * took the comment out and never saying how to get it back. The setting
- * and the way back answer different questions, so a verdict that has both
- * gets both. */
+ /* NOTE: These were exclusive, which left a removal saying which setting took the comment out and never saying how to get it back.
+ * The setting and the way back answer different questions, so a verdict that has both gets both. */
let step = next_step(&verdict);
let tail = match material.trace.origin_of(&verdict, &material.options) {
Some(origin) => format!(" ({origin}){step}"),
@@ -1081,8 +1201,7 @@ fn explanation_line(
)
}
-/// Write that line under the comment it is about, when the run has the
-/// material to account for it.
+/// Write that line under the comment it is about, when the run has the material to account for it.
fn write_explanation(
output: &mut impl Write,
file: &ProcessedFile,
@@ -1102,23 +1221,18 @@ fn write_explanation(
/// The flag that would overrule this verdict, for the verdicts a flag can.
///
-/// A keep needs this when no setting decided it and no table can be pointed
-/// at. A removal needs it for a different reason: naming the setting that took
-/// a comment out does not tell a reader how to get it back, and the removals
-/// worth getting back — a license notice, a doc comment — each have a
-/// different answer. The policy is spelled through [`Policy`] rather than
-/// written out, so renaming a policy renames it here too.
+/// A keep needs this when no setting decided it and no table can be pointed at.
+/// A removal needs it for a different reason: naming the setting that took a comment out does not tell a reader how to get it back, and the removals worth getting back — a license notice, a doc comment — each have a different answer.
+/// The policy is spelled through [`Policy`] rather than written out, so renaming a policy renames it here too.
fn next_step(verdict: &DispositionExplanation) -> String {
match verdict {
DispositionExplanation::ProtectedPreamble
| DispositionExplanation::KeptLoadBearing { .. } => {
"; add --force-protected to remove it".to_owned()
}
- /* NOTE: The removals a reader is most likely to have wanted kept. A
- * license notice is the one with a legal cost to losing, and a doc
- * comment is the one a policy takes wholesale from a repository that
- * publishes documentation. Both are recoverable, and neither is
- * recoverable by the same flag. */
+ /* NOTE: The removals a reader is most likely to have wanted kept.
+ * A license notice is the one with a legal cost to losing, and a doc comment is the one a policy takes wholesale from a repository that publishes documentation.
+ * Both are recoverable, and neither is recoverable by the same flag. */
DispositionExplanation::RemovedByDefault {
kind: CommentKind::License,
..
@@ -1134,9 +1248,7 @@ fn next_step(verdict: &DispositionExplanation) -> String {
DispositionExplanation::KeptDirective { kind, .. } => {
format!("; use --remove-kind {kind} or --policy all to remove it")
}
- /* NOTE: The two shape rules, and the only removals whose way out is an
- * edit to the comment rather than a flag: both are satisfied by
- * rewriting it, and neither has a flag that would keep it as it is. */
+ /* NOTE: The two shape rules, and the only removals whose way out is an edit to the comment rather than a flag: both are satisfied by rewriting it, and neither has a flag that would keep it as it is. */
DispositionExplanation::RemovedAsTrailing => {
"; move it onto a line of its own above the code".to_owned()
}
@@ -1146,9 +1258,13 @@ fn next_step(verdict: &DispositionExplanation) -> String {
DispositionExplanation::RemovedByLength { limit, .. } => {
format!("; cut the run to {}", plural(*limit, "line"))
}
- /* NOTE: The one keep with no flag behind it. `--policy all` does not
- * reach it either: what holds the body open is whatever comment is
- * still standing under this one, so that is the line to take first. */
+ /* NOTE: The one verdict whose way out is to let the tool do it.
+ * Every other line here tells a reader what to change; this one tells them the change is already written and waiting. */
+ DispositionExplanation::RewrittenByStyle { .. } => {
+ "; run `ocomment fix` to apply it".to_owned()
+ }
+ /* NOTE: The one keep with no flag behind it.
+ * `--policy all` does not reach it either: what holds the body open is whatever comment is still standing under this one, so that is the line to take first. */
DispositionExplanation::KeptStructural { .. } => {
"; the comment under it has to go first".to_owned()
}
@@ -1160,6 +1276,8 @@ fn next_step(verdict: &DispositionExplanation) -> String {
| DispositionExplanation::RemovedByRegex { .. }
| DispositionExplanation::RemovedByPolicy { .. }
| DispositionExplanation::RemovedByDefault { .. }
+ /* NOTE: `none` is the mode somebody chose on purpose, so there is nothing to suggest: a reader who set it is not looking for the flag that would undo it. */
+ | DispositionExplanation::KeptByPolicy { .. }
| DispositionExplanation::KeptByTag { .. } => String::new(),
}
}
@@ -1171,8 +1289,7 @@ const PREVIEW_COLUMNS: usize = 72;
///
/// Comment text is untrusted input that is about to be written to a terminal,
/// so the whole comment is folded onto one line, every control character —
-/// `ESC` above all — is replaced with U+FFFD instead of being forwarded, and
-/// the result is cut to `max_columns` display columns.
+/// `ESC` above all — is replaced with U+FFFD instead of being forwarded, and the result is cut to `max_columns` display columns.
fn preview(source: &[u8], span: ByteSpan, max_columns: usize) -> String {
let start = span.start.min(source.len());
let end = span.end.clamp(start, source.len());
@@ -1184,41 +1301,28 @@ fn preview(source: &[u8], span: ByteSpan, max_columns: usize) -> String {
/// The same treatment for a line that did not come out of a source file.
///
-/// What an external tool on `PATH` says about itself is untrusted for exactly
-/// the reason a comment is: `doctor` prints it to the same terminal, and a
-/// tool planted there could otherwise clear the screen or repaint the report
-/// from its own version line.
+/// What an external tool on `PATH` says about itself is untrusted for exactly the reason a comment is: `doctor` prints it to the same terminal, and a tool planted there could otherwise clear the screen or repaint the report from its own version line.
pub(crate) fn sanitize_line(text: &str) -> String {
truncate(fold(text), PREVIEW_COLUMNS)
}
/// The same treatment for a message that must not be cut short.
///
-/// A comment preview is commentary and can be trusted to a fixed width, but a
-/// diagnostic is the whole answer to a run that produced nothing else. The
-/// `regex` crate writes a parse error over several lines, with a caret under
-/// the byte it stopped at; the caret means nothing once the lines are joined,
-/// yet the sentence after it names what is actually wrong with the pattern. So
-/// this one folds — one line, no control characters — and keeps every word.
+/// A comment preview is commentary and can be trusted to a fixed width, but a diagnostic is the whole answer to a run that produced nothing else.
+/// The `regex` crate writes a parse error over several lines, with a caret under the byte it stopped at; the caret means nothing once the lines are joined,
+/// yet the sentence after it names what is actually wrong with the pattern.
+/// So this one folds — one line, no control characters — and keeps every word.
pub(crate) fn sanitize_message(text: &str) -> String {
fold(text)
}
/// The same treatment for a name that must not be cut short — or reworded.
///
-/// A directory name is chosen by whoever made the directory, so the rows
-/// `doctor` prints one on are untrusted for the same reason a version line is.
-/// What they are not is commentary: an absolute path is easily longer than a
-/// comment preview may be, and a row that ends in an ellipsis where the reader
-/// was looking for the rest of the path answers nothing.
+/// A directory name is chosen by whoever made the directory, so the rows `doctor` prints one on are untrusted for the same reason a version line is.
+/// What they are not is commentary: an absolute path is easily longer than a comment preview may be, and a row that ends in an ellipsis where the reader was looking for the rest of the path answers nothing.
///
-/// Neither is the whitespace in a path commentary, which is why this does not
-/// borrow [`fold`]: a name may begin with a space or carry a tab, and a reader
-/// who is shown neither cannot type the name back, nor find it in a checkout
-/// that has it. So the spacing is left exactly as it was given and every
-/// control character — the tab among them — is replaced with U+FFFD, which
-/// keeps the promise `fold` was borrowed for in the first place: whatever the
-/// name holds, the row stays one row.
+/// Neither is the whitespace in a path commentary, which is why this does not borrow [`fold`]: a name may begin with a space or carry a tab, and a reader who is shown neither cannot type the name back, nor find it in a checkout that has it.
+/// So the spacing is left exactly as it was given and every control character — the tab among them — is replaced with U+FFFD, which keeps the promise `fold` was borrowed for in the first place: whatever the name holds, the row stays one row.
pub(crate) fn sanitize_path(text: &str) -> String {
text.chars()
.map(|character| {
@@ -1233,14 +1337,8 @@ pub(crate) fn sanitize_path(text: &str) -> String {
/// The same treatment for a line of source a prompt has to show as code.
///
-/// A hunk is read for its shape as much as for its text — indentation says
-/// what a line belongs to — so unlike a comment preview this one keeps the
-/// spaces it was given and expands a tab onto the same eight-column stop the
-/// `columns` layout measures a replacement by. What it does not keep is
-/// anything that drives the terminal: every control character, `ESC` and the
-/// bidirectional overrides above all, still becomes U+FFFD, and the result is
-/// still one line cut to a fixed width, because the question underneath it has
-/// to stay on the screen with it.
+/// A hunk is read for its shape as much as for its text — indentation says what a line belongs to — so unlike a comment preview this one keeps the spaces it was given and expands a tab onto the same eight-column stop the `columns` layout measures a replacement by.
+/// What it does not keep is anything that drives the terminal: every control character, `ESC` and the bidirectional overrides above all, still becomes U+FFFD, and the result is still one line cut to a fixed width, because the question underneath it has to stay on the screen with it.
pub(crate) fn sanitize_source_line(text: &str) -> String {
let mut line = String::with_capacity(text.len());
let mut column = 0usize;
@@ -1260,8 +1358,7 @@ pub(crate) fn sanitize_source_line(text: &str) -> String {
truncate(line, PREVIEW_COLUMNS)
}
-/// The tab stop `sanitize_source_line` expands to, the one the `columns`
-/// layout already measures a tab by.
+/// The tab stop `sanitize_source_line` expands to, the one the `columns` layout already measures a tab by.
const TAB_WIDTH: usize = 8;
/// Fold `text` onto one control-free line.
@@ -1270,8 +1367,7 @@ fn fold(text: &str) -> String {
let mut pending_space = false;
for character in text.chars() {
if matches!(character, ' ' | '\t' | '\r' | '\n' | '\u{c}') {
- /* NOTE: Leading whitespace is dropped, and a run only becomes a space
- * once something else follows it, so the tail is trimmed too. */
+ /* NOTE: Leading whitespace is dropped, and a run only becomes a space once something else follows it, so the tail is trimmed too. */
pending_space = !folded.is_empty();
continue;
}
@@ -1288,11 +1384,9 @@ fn fold(text: &str) -> String {
folded
}
-/// C0, DEL, C1, and the bidirectional and separator format controls. None of
-/// these may reach the terminal verbatim: C0 drives it, the bidi overrides and
-/// isolates can make a comment render as its own reverse, and U+2028/U+2029
-/// break the promise that a preview is one line. U+061C joins the marks it
-/// belongs with, and U+FEFF is invisible wherever it lands.
+/// C0, DEL, C1, and the bidirectional and separator format controls.
+/// None of these may reach the terminal verbatim: C0 drives it, the bidi overrides and isolates can make a comment render as its own reverse, and U+2028/U+2029 break the promise that a preview is one line.
+/// U+061C joins the marks it belongs with, and U+FEFF is invisible wherever it lands.
fn is_control(character: char) -> bool {
matches!(
character,
@@ -1312,13 +1406,11 @@ fn columns(character: char) -> usize {
}
/// How many characters a preview may carry for each column it may occupy.
-/// Zero-width and combining characters cost no columns, so the width budget on
-/// its own cannot bound the line a terminal has to hold.
+/// Zero-width and combining characters cost no columns, so the width budget on its own cannot bound the line a terminal has to hold.
const PREVIEW_CHARS_PER_COLUMN: usize = 4;
/// Cut `text` to `max_columns` display columns and to a hard character cap,
-/// never inside a wide character, leaving room for the ellipsis that marks the
-/// cut.
+/// never inside a wide character, leaving room for the ellipsis that marks the cut.
fn truncate(text: String, max_columns: usize) -> String {
let max_chars = max_columns.saturating_mul(PREVIEW_CHARS_PER_COLUMN);
if text.chars().map(columns).sum::() <= max_columns && text.chars().count() <= max_chars
@@ -1359,13 +1451,10 @@ fn preview_suffix(source: &[u8], span: ByteSpan, options: &RenderOptions) -> Str
)
}
-/// The handle every path that writes the product of a run takes: standard
-/// output, locked once for the whole run and buffered.
+/// The handle every path that writes the product of a run takes: standard output, locked once for the whole run and buffered.
///
-/// `println!` panics when its write fails, and the release profile aborts on
-/// panic, so a reader that stops early — `ocomment … | head` — would end the
-/// process with SIGABRT. Writing through a handle that returns its errors lets
-/// the caller decide instead, and `main` ends a closed pipe quietly.
+/// `println!` panics when its write fails, and the release profile aborts on panic, so a reader that stops early — `ocomment … | head` — would end the process with SIGABRT.
+/// Writing through a handle that returns its errors lets the caller decide instead, and `main` ends a closed pipe quietly.
pub type Stdout = BufWriter>;
/// Lock standard output for the rest of the run and buffer it.
@@ -1375,12 +1464,8 @@ pub fn stdout() -> Stdout {
/// The reader of the program's own output went away mid-run.
///
-/// A broken pipe is only benign when it is *our* report that could not be
-/// written; `ocomment … | head` is a reader that finished, not a run that
-/// failed. Every other broken pipe — writing a rewritten blob into
-/// `git hash-object`, for one — is a real failure, so the benign case is
-/// tagged with this marker at the write that raised it instead of being
-/// recognized by error kind anywhere in the chain.
+/// A broken pipe is only benign when it is *our* report that could not be written; `ocomment … | head` is a reader that finished, not a run that failed.
+/// Every other broken pipe — writing a rewritten blob into `git hash-object`, for one — is a real failure, so the benign case is tagged with this marker at the write that raised it instead of being recognized by error kind anywhere in the chain.
#[derive(Debug)]
pub struct OutputPipeClosed;
@@ -1400,8 +1485,7 @@ pub fn finish(writer: &mut impl Write) -> Result<()> {
wrote(writer.flush())
}
-/// Raise one write to the program's own output, tagging the reader that closed
-/// the pipe so `main` can end quietly for that case alone.
+/// Raise one write to the program's own output, tagging the reader that closed the pipe so `main` can end quietly for that case alone.
pub fn wrote(result: io::Result<()>) -> Result<()> {
result.map_err(output_failure)
}
@@ -1416,11 +1500,9 @@ fn output_failure(error: io::Error) -> anyhow::Error {
/// Write one line of commentary to standard error.
///
-/// Commentary — the `-v` trace, the end-of-run summary — is not the product of
-/// the run, so a reader that has already gone away is not a failure to report:
-/// a closed pipe is dropped and only a real write failure is raised. What must
-/// not happen is what `eprintln!` does, which is panic, and so abort under the
-/// release profile.
+/// Commentary — the `-v` trace, the end-of-run summary — is not the product of the run, so a reader that has already gone away is not a failure to report:
+/// a closed pipe is dropped and only a real write failure is raised.
+/// What must not happen is what `eprintln!` does, which is panic, and so abort under the release profile.
pub fn note(
writer: &mut impl Write,
verbosity: Verbosity,
@@ -1440,10 +1522,8 @@ pub fn note(
/// Turn a serialization failure back into the I/O error it usually is.
///
-/// `serde_json` reports a failed write as an error of its own whose `source`
-/// is the *source* of the I/O error rather than the I/O error itself, so a
-/// closed pipe would be invisible to anything walking the chain. Its `From`
-/// conversion hands the original error back.
+/// `serde_json` reports a failed write as an error of its own whose `source` is the *source* of the I/O error rather than the I/O error itself, so a closed pipe would be invisible to anything walking the chain.
+/// Its `From` conversion hands the original error back.
fn write_error(error: serde_json::Error) -> anyhow::Error {
output_failure(io::Error::from(error))
}
@@ -1456,8 +1536,8 @@ pub fn render(
render_explained(files, skipped, options, &Explanations::new())
}
-/// The same report, with the material `--explain` needs for the files it has
-/// it for. A file with none is reported exactly as `render` reports it.
+/// The same report, with the material `--explain` needs for the files it has it for.
+/// A file with none is reported exactly as `render` reports it.
pub fn render_explained(
files: &[ProcessedFile],
skipped: &[SkippedFile],
@@ -1486,28 +1566,19 @@ pub fn render_explained(
finish(&mut output)
}
-/// The report as the decisions it asks for, which is what a person reading it
-/// on a terminal is there to make.
+/// The report as the decisions it asks for, which is what a person reading it on a terminal is there to make.
///
-/// `human` answers "where are they", one grep-able line at a time, and that is
-/// the right answer for a pipe. It is the wrong shape for the question its
-/// reader actually has, which is "and then what": nine findings under one rule
-/// are not nine questions, they are one question asked nine times, and the
-/// answer to each is decided by the code the comment sits on -- which `human`
-/// does not show, so the reader opens the file.
+/// `human` answers "where are they", one grep-able line at a time, and that is the right answer for a pipe.
+/// It is the wrong shape for the question its reader actually has, which is "and then what": nine findings under one rule are not nine questions, they are one question asked nine times, and the answer to each is decided by the code the comment sits on -- which `human` does not show, so the reader opens the file.
///
-/// So: grouped by the decision rather than by the rule or the file, the edit
-/// shown beside each rather than its neighbourhood, the count on every group
-/// because a classification without counts cannot set an order, and the way to
-/// *keep* the comments as visible as the way to remove them. That last one is
-/// not symmetry for its own sake. A gate that can only say "delete it" is a
-/// gate somebody turns off the first time it is wrong about one comment.
+/// So: grouped by the decision rather than by the rule or the file, the edit shown beside each rather than its neighbourhood, the count on every group because a classification without counts cannot set an order, and the way to *keep* the comments as visible as the way to remove them.
+/// That last one is not symmetry for its own sake.
+/// A gate that can only say "delete it" is a gate somebody turns off the first time it is wrong about one comment.
/// The file holding the most of what this run found, and how many.
///
/// The one thing a reader of a large report wants that no count gives them:
-/// somewhere to start. `None` when the findings are spread evenly enough that
-/// naming one file would be arbitrary -- under a twentieth of the total is not
-/// a place to start, it is a place that happens to be first.
+/// somewhere to start.
+/// `None` when the findings are spread evenly enough that naming one file would be arbitrary -- under a twentieth of the total is not a place to start, it is a place that happens to be first.
fn busiest(groups: &[crate::advice::Group]) -> Option<(String, usize)> {
let mut counts: BTreeMap = BTreeMap::new();
let mut total = 0usize;
@@ -1525,16 +1596,11 @@ fn busiest(groups: &[crate::advice::Group]) -> Option<(String, usize)> {
(count * 20 >= total).then_some((path, count))
}
-/// The comment one finding was built from, so that the engine's verdict can be
-/// asked for again.
+/// The comment one finding was built from, so that the engine's verdict can be asked for again.
///
-/// The verdict belongs to the first comment of the run, which is the one whose
-/// rule decided the rest, and the finding names it by the byte it starts at.
-/// A line does not name it: two removable comments share a line whenever one
-/// of them sits beside code, and matching on the line returned the first of
-/// them for both findings — so a plain comment beside a directive was
-/// explained as `this one a \`directive\``. Everything around that line was
-/// right, which is what kept it standing.
+/// The verdict belongs to the first comment of the run, which is the one whose rule decided the rest, and the finding names it by the byte it starts at.
+/// A line does not name it: two removable comments share a line whenever one of them sits beside code, and matching on the line returned the first of them for both findings — so a plain comment beside a directive was explained as `this one a \`directive\``.
+/// Everything around that line was right, which is what kept it standing.
fn found_at<'a>(
files: &'a [ProcessedFile],
item: &crate::advice::Item,
@@ -1551,15 +1617,13 @@ fn found_at<'a>(
/// How many findings a report shows in full before it starts summarising.
///
-/// Above this the report stops being something a reader reads and becomes
-/// something they scroll: this repository under `--policy all` produces 9,139
-/// findings and, printed in full, 17,902 lines. Nobody reads the ten thousandth
-/// one. At that size what is needed is the shape -- which decision, how many,
+/// Above this the report stops being something a reader reads and becomes something they scroll: this repository under `--policy all` produces 9,139 findings and, printed in full, 17,902 lines.
+/// Nobody reads the ten thousandth one.
+/// At that size what is needed is the shape -- which decision, how many,
/// where they are concentrated -- and a way to narrow.
const FINDINGS_SHOWN_IN_FULL: usize = 20;
-/// How many findings a summarised group still shows, so that the shape has an
-/// example under it rather than only a number.
+/// How many findings a summarised group still shows, so that the shape has an example under it rather than only a number.
const FINDINGS_PER_SUMMARISED_GROUP: usize = 2;
/// How many files a summarised group names before it counts the rest.
@@ -1568,9 +1632,7 @@ const FILES_PER_SUMMARISED_GROUP: usize = 5;
/// Where a decision's comments are, most first.
///
/// The table a reader writes by hand the first time they meet a large report,
-/// which is the reason to write it for them: a count with no location cannot
-/// set an order, and "1,204 of these are in one file" is the difference between
-/// a project-wide problem and an afternoon.
+/// which is the reason to write it for them: a count with no location cannot set an order, and "1,204 of these are in one file" is the difference between a project-wide problem and an afternoon.
fn concentration_of(group: &crate::advice::Group) -> Vec<(String, usize)> {
let mut counts: BTreeMap = BTreeMap::new();
for item in &group.items {
@@ -1579,8 +1641,7 @@ fn concentration_of(group: &crate::advice::Group) -> Vec<(String, usize)> {
.or_default() += item.comments;
}
let mut rows: Vec<(String, usize)> = counts.into_iter().collect();
- /* NOTE: Most first, then by path, so two runs over one tree print the same
- * table. */
+ /* NOTE: Most first, then by path, so two runs over one tree print the same table. */
rows.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
rows
}
@@ -1589,8 +1650,8 @@ fn concentration_of(group: &crate::advice::Group) -> Vec<(String, usize)> {
///
/// The count of what went is on standard error with the rest of the commentary.
/// Here is what is still in the files: every comment the run decided to keep,
-/// so that a reader can check the keeping rather than take it on trust. A run
-/// that says only what it removed is a run whose judgement nobody can audit.
+/// so that a reader can check the keeping rather than take it on trust.
+/// A run that says only what it removed is a run whose judgement nobody can audit.
fn render_fixed(
output: &mut impl Write,
files: &[ProcessedFile],
@@ -1603,28 +1664,84 @@ fn render_fixed(
color("\x1b[1m", paint),
color("\x1b[0m", paint),
);
- let (green, blue) = (
+ let (green, blue, yellow) = (
color("\x1b[38;5;114m", paint),
color("\x1b[38;5;75m", paint),
+ color("\x1b[38;5;179m", paint),
);
- let removed: usize = files.iter().map(removed_count).sum();
+ let summary = Summary::compute(files, skipped, options.operation);
let changed = files.iter().filter(|file| file.result.changed()).count();
+ /* NOTE: The headline names what the run did, in the words for what it did.
+ * A tidying run took nothing away, and a line reading "removed" over one would be describing a different run than the one that just finished. */
+ let (headline, preposition) = if options.operation.removes() {
+ (
+ format!(
+ "{} removed",
+ comments(files.iter().map(removed_count).sum(), "")
+ ),
+ "from",
+ )
+ } else {
+ (
+ format!(
+ "{} rewritten",
+ plural(
+ files.iter().map(rewritten_count).sum(),
+ summary.rewritten_noun()
+ )
+ ),
+ "in",
+ )
+ };
wrote(writeln!(output))?;
wrote(writeln!(
output,
- " {green}OK{reset} {bold}{} removed{reset}{dim} from {} · {}{reset}",
- comments(removed, ""),
+ " {green}OK{reset} {bold}{headline}{reset}{dim} {preposition} {} · {}{reset}",
plural(changed, "file"),
scanned_clause(files.len(), skipped.len()),
))?;
+ /* NOTE: What a tidying run was told not to touch, listed rather than counted.
+ * The run exits 1 for these, and a reader looking at why has to be able to see which comments they were without running a second command. */
+ if !options.operation.removes() {
+ let left: Vec<(&ProcessedFile, &Comment)> = files
+ .iter()
+ .flat_map(|file| {
+ file.result
+ .report
+ .comments
+ .iter()
+ .filter(|comment| comment.action().removes())
+ .map(move |comment| (file, comment))
+ })
+ .collect();
+ if !left.is_empty() {
+ wrote(writeln!(output))?;
+ wrote(writeln!(
+ output,
+ " {bold}{yellow}DECIDE{reset} {}{dim}, left for you{reset}",
+ comments(left.len(), "")
+ ))?;
+ for (file, comment) in left {
+ let index = LineIndex::new(&file.source);
+ let (line, _) = index.line_column(comment.span.start);
+ wrote(writeln!(
+ output,
+ " {blue}{}:{line}{reset} {dim}{}{reset}",
+ display_path(&file.path, options.presentation.hyperlinks),
+ preview(&file.source, comment.span, PREVIEW_COLUMNS)
+ ))?;
+ }
+ }
+ }
+ /* NOTE: A comment a run rewrote is not one it kept, and the paragraph rules record their verdict beside the comments rather than on them -- so "untouched" has to ask the runs too, or every reflowed line would be listed here as one nothing happened to. */
let kept: Vec<(&ProcessedFile, &Comment)> = files
.iter()
.flat_map(|file| {
- file.result
- .report
+ let report = &file.result.report;
+ report
.comments
.iter()
- .filter(|comment| !comment.disposition.is_remove())
+ .filter(|comment| !reported(comment) && !covered_by_a_run(report, comment))
.map(move |comment| (file, comment))
})
.collect();
@@ -1657,18 +1774,14 @@ fn render_review(
options: &RenderOptions,
explanations: &Explanations,
) -> Result<()> {
- /* NOTE: `diff` writes a patch, and a patch is the product rather than a
- * report about one: a reader pipes it into `git apply`, and anything else
- * on that stream is corruption. There is no decision view of a patch, so
- * this is the one operation where the two person-facing formats are the
- * same bytes. */
- if options.operation == Operation::Diff {
+ /* NOTE: `diff` writes a patch, and a patch is the product rather than a report about one: a reader pipes it into `git apply`, and anything else on that stream is corruption.
+ * There is no decision view of a patch, so this is the one operation where the two person-facing formats are the same bytes. */
+ if matches!(options.operation, Operation::Diff(_)) {
return render_human(output, files, skipped, options, explanations);
}
- if options.operation == Operation::Fix && options.applied {
- /* NOTE: After a fix the decisions are answered and the comments are
- * gone, so asking for them again would be a report about a file that no
- * longer holds them. What a reader has not seen is the other half. */
+ if options.operation.writes() && options.applied {
+ /* NOTE: After a fix the decisions are answered and the comments are gone, so asking for them again would be a report about a file that no longer holds them.
+ * What a reader has not seen is the other half. */
return render_fixed(output, files, skipped, options);
}
let paint = options.presentation.color;
@@ -1685,7 +1798,13 @@ fn render_review(
color("\x1b[38;5;80m", paint),
);
let groups = crate::advice::plan(files, options.policy);
- let removable: usize = groups.iter().map(crate::advice::Group::comments).sum();
+ /* NOTE: What a reader has to decide, which is not everything in the plan.
+ * A rewrite is in the plan so that a caller parsing the report finds every change in one place, and it is not counted here because the two halves of this report ask different things: the headline asks a reader for a decision, and a rewrite is the one answer the tool already has. */
+ let removable: usize = groups
+ .iter()
+ .filter(|group| !matches!(group.decision, crate::advice::Decision::Restyle { .. }))
+ .map(crate::advice::Group::comments)
+ .sum();
let kept: usize = files
.iter()
.map(|file| {
@@ -1693,7 +1812,7 @@ fn render_review(
.report
.comments
.iter()
- .filter(|comment| !comment.disposition.is_remove())
+ .filter(|comment| !reported(comment))
.count()
})
.sum();
@@ -1704,35 +1823,68 @@ fn render_review(
.collect::>()
.len();
- let mark = if removable == 0 {
- format!("{green}OK{reset}")
+ let restyled: usize = files.iter().map(rewritable_count).sum();
+ /* NOTE: What to call them.
+ * A run of comments and a paragraph of a document are both paragraphs; a comment a spacing rule reached on its own is a comment.
+ * Where a run met both, the noun that covers them is the wider one. */
+ let restyled_noun = if files.iter().any(|file| rewritable_paragraphs(file) > 0) {
+ "paragraph"
} else {
+ "comment"
+ };
+ /* NOTE: Three marks for three kinds of answer.
+ * A removal is a decision the reader has to make and the run is not clean until they make it; a rewrite is one the tool has already made and is offering to apply.
+ * A report that called both `NO` would be asking for a decision that has been taken. */
+ let mark = if removable > 0 {
format!("{red}NO{reset}")
+ } else if restyled > 0 {
+ format!("{blue}TIDY{reset}")
+ } else {
+ format!("{green}OK{reset}")
};
wrote(writeln!(output))?;
wrote(writeln!(
output,
" {mark} {bold}{}{reset}{dim} in {} · {} · policy {}{reset}",
- comments(removable, ""),
- plural(touched, "file"),
+ headline_count(removable, restyled, restyled_noun),
+ plural(
+ touched.max(
+ files
+ .iter()
+ .filter(|file| rewritable_count(file) > 0)
+ .count()
+ ),
+ "file"
+ ),
scanned_clause(files.len(), skipped.len()),
options.policy,
))?;
- /* NOTE: Decided once for the whole report rather than per group, so that a
- * reader learns one layout: either every group shows its shape and then an
- * example, or every group shows everything. A report where some groups are
- * summarised and others are not reads as though the tool ran out of
- * patience partway down. */
+ /* NOTE: Decided once for the whole report rather than per group, so that a reader learns one layout: either every group shows its shape and then an example, or every group shows everything.
+ * A report where some groups are summarised and others are not reads as though the tool ran out of patience partway down. */
let findings: usize = groups.iter().map(|group| group.items.len()).sum();
let summarise = findings > FINDINGS_SHOWN_IN_FULL;
for group in &groups {
let instruction = group.decision.instruction();
- let count = comments(group.comments(), "");
+ /* NOTE: A paragraph, where the decision is about one.
+ * A reflow is decided over a run and a report that called it a comment would be counting a different thing from the line above it. */
+ /* NOTE: The marker says which kind of answer this group is.
+ * `DECIDE` asks the reader for one; `TIDY` says the tool has it, and the same word heads the status line above so the two agree about what the run found. */
+ let restyle = matches!(group.decision, crate::advice::Decision::Restyle { .. });
+ let count = if restyle {
+ plural(group.comments(), restyled_noun)
+ } else {
+ comments(group.comments(), "")
+ };
+ let marker = if restyle {
+ format!("{blue}TIDY{reset} ")
+ } else {
+ format!("{yellow}DECIDE{reset}")
+ };
wrote(writeln!(output))?;
wrote(writeln!(
output,
- " {bold}{yellow}DECIDE{reset} {bold}{instruction}{reset}{dim}{}{count}{reset}",
+ " {bold}{marker}{reset} {bold}{instruction}{reset}{dim}{}{count}{reset}",
" ".repeat(
58usize
.saturating_sub(instruction.chars().count() + count.chars().count())
@@ -1798,9 +1950,8 @@ fn render_review(
))?;
}
/* NOTE: The decision above is read from where the comment sits;
- * this is the rule the engine actually applied and the setting it
- * came from. They answer different questions -- what to do, and why
- * it is being asked -- and `--explain` is the second one. */
+ * this is the rule the engine actually applied and the setting it came from.
+ * They answer different questions -- what to do, and why it is being asked -- and `--explain` is the second one. */
if let Some((file, comment)) = found_at(files, item) {
let explainer = explanations.get(&file.path).map(Explainer::new);
if let Some(explainer) = explainer.as_ref() {
@@ -1829,9 +1980,8 @@ fn render_review(
if kept > 0 {
wrote(writeln!(output))?;
if options.explain {
- /* NOTE: The count is a promise that somebody checked; the list is
- * what lets a reader check the checker. A gate nobody can audit
- * when it is green is a gate whose green means nothing. */
+ /* NOTE: The count is a promise that somebody checked; the list is what lets a reader check the checker.
+ * A gate nobody can audit when it is green is a gate whose green means nothing. */
wrote(writeln!(
output,
" {bold}{green}ALLOWED{reset} {dim}{} this run did not report{reset}",
@@ -1845,7 +1995,7 @@ fn render_review(
.report
.comments
.iter()
- .filter(|comment| !comment.disposition.is_remove())
+ .filter(|comment| !reported(comment))
{
let (line, _) = index.line_column(comment.span.start);
wrote(writeln!(
@@ -1872,13 +2022,11 @@ fn render_review(
))?;
}
}
- if removable > 0 && options.operation != Operation::Fix {
+ if removable > 0 && !options.operation.removes() {
wrote(writeln!(output))?;
wrote(writeln!(output, " {dim}{}{reset}", "─".repeat(70)))?;
- /* NOTE: Where to start, before what to run. A report this size is read
- * by somebody deciding where an afternoon goes, and the answer to that
- * is a path rather than a verb: the file holding the most of this is
- * the one where the most of it stops. */
+ /* NOTE: Where to start, before what to run.
+ * A report this size is read by somebody deciding where an afternoon goes, and the answer to that is a path rather than a verb: the file holding the most of this is the one where the most of it stops. */
if summarise && let Some((path, count)) = busiest(&groups) {
wrote(writeln!(
output,
@@ -1904,9 +2052,8 @@ fn render_human(
let operation = options.operation;
let presentation = options.presentation;
for file in files {
- if operation == Operation::Diff && file.result.changed() {
- /* NOTE: The patch is the product of `diff`, so `-q` keeps it and drops
- * only the summary that follows on standard error. */
+ if matches!(operation, Operation::Diff(_)) && file.result.changed() {
+ /* NOTE: The patch is the product of `diff`, so `-q` keeps it and drops only the summary that follows on standard error. */
wrote(output.write_all(&unified_diff(
&file.path,
&file.source,
@@ -1916,17 +2063,15 @@ fn render_human(
}
let reports_comments = match operation {
Operation::Scan => !file.result.report.comments.is_empty(),
- Operation::Fix => false,
+ Operation::Fix(_) => false,
// NOTE: The findings are the product of `check`, as the patch is of `diff`.
- Operation::Check | Operation::Diff if options.explain => {
+ Operation::Check | Operation::Diff(_) if options.explain => {
!file.result.report.comments.is_empty()
}
- Operation::Check | Operation::Diff => file
- .result
- .report
- .comments
- .iter()
- .any(|comment| comment.disposition.is_remove()),
+ Operation::Check | Operation::Diff(_) => {
+ file.result.report.comments.iter().any(reported)
+ || !file.result.report.runs.is_empty()
+ }
};
let lines = (!file.result.report.diagnostics.is_empty() || reports_comments)
.then(|| LineIndex::new(&file.source));
@@ -1964,66 +2109,93 @@ fn render_human(
"{}:{line}:{column}: {} {} {}..{}{}",
display_path(&file.path, presentation.hyperlinks),
comment.kind,
- comment.disposition,
+ comment.disposition(),
comment.span.start,
comment.span.end,
preview_suffix(&file.source, comment.span, options)
))?;
write_explanation(output, file, comment, explainer, options)?;
}
- } else if operation == Operation::Fix {
+ } else if operation.writes() {
if options.applied && file.result.changed() {
+ /* NOTE: A tidying run took nothing away, so it does not say it did.
+ * The line names what reached the file, and for that run what reached it was the rewrites. */
+ let done = if operation.removes() {
+ format!("removed {}", comments(removed_count(file), ""))
+ } else {
+ format!("rewrote {}", comments(rewritten_count(file), ""))
+ };
wrote(writeln!(
output,
- "fixed {}: removed {}",
+ "fixed {}: {done}",
display_path(&file.path, presentation.hyperlinks),
- comments(removed_count(file), "")
))?;
}
} else {
- /* NOTE: `check` reports what it would remove. Asked to explain itself it
- * reports the rest too, because a comment it left alone is exactly
- * the one the reader is asking about. */
+ /* NOTE: `check` reports what it would change, which is what it would remove and what it would rewrite.
+ * Asked to explain itself it reports the rest too, because a comment it left alone is exactly the one the reader is asking about. */
for comment in &file.result.report.comments {
- let removable = comment.disposition.is_remove();
- if !options.explain && !removable {
+ let action = comment.disposition().action();
+ if !options.explain && !reported(comment) {
continue;
}
let (line, column) = lines
.as_ref()
.expect("a finding requested a line index")
.line_column(comment.span.start);
+ /* NOTE: Three colours for three verdicts.
+ * A rewrite is blue rather than the removal's yellow because it is not a warning:
+ * nothing is being taken away and the reader has nothing to decide. */
+ let (escape, label) = match action {
+ Action::Remove => ("\x1b[33m", removable_label(comment.kind)),
+ Action::Rewrite => ("\x1b[34m", rewritten_prefix(comment.kind)),
+ Action::Keep => ("\x1b[32m", kept_prefix(comment.kind)),
+ };
wrote(writeln!(
output,
- "{}:{line}:{column}: {}{}{}{}",
+ "{}:{line}:{column}: {}{label}{}{}",
display_path(&file.path, presentation.hyperlinks),
- color(
- if removable { "\x1b[33m" } else { "\x1b[32m" },
- presentation.color
- ),
- if removable {
- removable_label(comment.kind)
- } else {
- kept_prefix(comment.kind)
- },
+ color(escape, presentation.color),
color("\x1b[0m", presentation.color),
preview_suffix(&file.source, comment.span, options)
))?;
write_explanation(output, file, comment, explainer, options)?;
}
+ /* NOTE: A run is reported where it begins and as one finding.
+ * It covers several comments and asks one question about them -- where the paragraph breaks -- and a reader cannot answer that one comment at a time. */
+ for run in &file.result.report.runs {
+ let (line, column) = lines
+ .as_ref()
+ .expect("a finding requested a line index")
+ .line_column(run.span.start);
+ /* NOTE: Named for where the prose was found.
+ * A paragraph of a Markdown document is not a comment, and a report that called it one would be telling a reader something about their file that is not so. */
+ let what = match run.origin {
+ ProseOrigin::Comments => "rewritten comment paragraph",
+ ProseOrigin::Document => "rewritten paragraph",
+ };
+ wrote(writeln!(
+ output,
+ "{}:{line}:{column}: {}{what}{}{}",
+ display_path(&file.path, presentation.hyperlinks),
+ color("[34m", presentation.color),
+ color("[0m", presentation.color),
+ preview_suffix(&file.source, run.span, options)
+ ))?;
+ if options.explain {
+ wrote(writeln!(output, " rewritten: {}", run.rule.detail()))?;
+ }
+ }
}
}
write_commentary(output, files, skipped, options)
}
-/// The commentary a run writes to standard error, whichever way it wrote its
-/// product.
+/// The commentary a run writes to standard error, whichever way it wrote its product.
///
-/// The count, the skips, where the findings are concentrated, the settings that
-/// matched nothing. None of it depends on the layout of the report above it,
-/// and it went missing from `review` for exactly as long as it lived inside
-/// `render_human` -- a summary a CI job greps for, gone because a second format
-/// was added beside the one that owned it.
+/// The count, the skips, where the findings are concentrated, the settings that matched nothing.
+/// None of it depends on the layout of the report above it,
+/// and it went missing from `review` for exactly as long as it lived inside `render_human` -- a summary a CI job greps for, gone because a second format was added beside the one that owned it.
fn write_commentary(
output: &mut impl Write,
files: &[ProcessedFile],
@@ -2034,25 +2206,19 @@ fn write_commentary(
let presentation = options.presentation;
let verbose = options.verbosity.shows(Detail::Verbose);
let skips = skip_lines(skipped, presentation, options.verbosity);
- /* NOTE: `diff` keeps standard output for the patch alone, so the skips it met
- * are left to standard error. `fix --dry-run` is that same `diff` speaking
- * for the `fix` it stands in for: a skipped path can be the whole answer
- * to the run, so the preview still owes the reader the reason — but beside
- * the summary that counts it, because what the preview promises on
- * standard output is a patch that has to survive being piped into `git
- * apply`. A plain `fix` writes no patch and keeps its skips there. */
- if operation != Operation::Diff {
+ /* NOTE: `diff` keeps standard output for the patch alone, so the skips it met are left to standard error.
+ * `fix --dry-run` is that same `diff` speaking for the `fix` it stands in for: a skipped path can be the whole answer to the run, so the preview still owes the reader the reason — but beside the summary that counts it, because what the preview promises on standard output is a patch that has to survive being piped into `git apply`.
+ * A plain `fix` writes no patch and keeps its skips there. */
+ if !matches!(operation, Operation::Diff(_)) {
for line in &skips {
wrote(writeln!(output, "{line}"))?;
}
}
- /* NOTE: The findings are on standard output and the commentary that follows is
- * on standard error; a terminal sees both, so the buffer is emptied first
- * to keep the report in the order it was written. */
+ /* NOTE: The findings are on standard output and the commentary that follows is on standard error; a terminal sees both, so the buffer is emptied first to keep the report in the order it was written. */
finish(output)?;
let stderr = io::stderr();
let mut report = stderr.lock();
- if operation == Operation::Diff && options.dry_run {
+ if matches!(operation, Operation::Diff(_)) && options.dry_run {
for line in &skips {
note(&mut report, options.verbosity, Detail::Normal, line)?;
}
@@ -2068,28 +2234,22 @@ fn write_commentary(
Detail::Normal,
&summary_report(&summary, options, folded),
)?;
- /* NOTE: After the verdict, because it is about the verdict: the count comes
- * first and then where that count is and what would answer it. */
+ /* NOTE: After the verdict, because it is about the verdict: the count comes first and then where that count is and what would answer it. */
for line in concentration(files, options) {
note(&mut report, options.verbosity, Detail::Normal, &line)?;
}
- /* NOTE: Under any other policy a kept preamble is one of many deliberate keeps
- * and saying so every run would be noise. `all` said it would take
- * everything, so what it left behind is the surprise worth a line. */
+ /* NOTE: Under any other policy a kept preamble is one of many deliberate keeps and saying so every run would be noise.
+ * `all` said it would take everything, so what it left behind is the surprise worth a line. */
if options.policy == Policy::All {
- /* NOTE: Two protections and two lines, because the two are not the same
- * surprise. A preamble was held back by the file's own syntax; a
- * load-bearing directive was held back by what reads it, and a reader
- * who asked for every comment to go is owed the difference rather than
- * a count that runs them together. */
+ /* NOTE: Two protections and two lines, because the two are not the same surprise.
+ * A preamble was held back by the file's own syntax; a load-bearing directive was held back by what reads it, and a reader who asked for every comment to go is owed the difference rather than a count that runs them together. */
for (protection, adjective) in [
(PROTECTED_PREAMBLE, "protected preamble"),
(LOAD_BEARING, "load-bearing"),
] {
let protected = kept_for(files, protection);
if protected > 0 {
- /* NOTE: The line counts what it kept, so the pronoun that stands for it
- * has to agree with that count. */
+ /* NOTE: The line counts what it kept, so the pronoun that stands for it has to agree with that count. */
let pronoun = if protected == 1 { "it" } else { "them" };
note(
&mut report,
@@ -2123,20 +2283,17 @@ fn write_commentary(
Ok(())
}
-/// The skips one run has to name, in one wording for whichever stream ends up
-/// carrying them. An I/O error is named however quiet the run was asked to be:
+/// The skips one run has to name, in one wording for whichever stream ends up carrying them.
+/// An I/O error is named however quiet the run was asked to be:
/// it is a failure, not commentary.
///
-/// Shared with `fix --interactive`, which writes no report of its own and would
-/// otherwise be the one command that never says why it passed a file over.
+/// Shared with `fix --interactive`, which writes no report of its own and would otherwise be the one command that never says why it passed a file over.
/// Whether a skip is worth a line of the report, in human and in GitHub form.
///
-/// An I/O error decides the exit code, so it is said however quietly the run
-/// was asked to speak. A path the caller named is answered on a line of its
-/// own, because they asked about that path. What a walk merely wandered past
-/// is neither: one unscannable file is a skip, forty of them are noise, and
-/// the end-of-run summary counts those instead — `-v` is how a reader asks for
-/// the list. Both renderers share this so the two cannot drift apart.
+/// An I/O error decides the exit code, so it is said however quietly the run was asked to speak.
+/// A path the caller named is answered on a line of its own, because they asked about that path.
+/// What a walk merely wandered past is neither: one unscannable file is a skip, forty of them are noise, and the end-of-run summary counts those instead — `-v` is how a reader asks for the list.
+/// Both renderers share this so the two cannot drift apart.
pub(crate) fn skip_is_visible(item: &SkippedFile, verbosity: Verbosity) -> bool {
// NOTE: An I/O error decides the exit code, so it is named however quiet the run.
item.error
@@ -2164,14 +2321,13 @@ pub(crate) fn skip_lines(
/// The numbers an interactive run's verdict is built from.
///
-/// They count answers rather than findings, which is the one thing the ordinary
-/// summary cannot say: it counts what a run *could* have removed.
+/// They count answers rather than findings, which is the one thing the ordinary summary cannot say: it counts what a run *could* have removed.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct InteractiveOutcome {
/// Comments the reader accepted for removal.
pub removed: usize,
- /// Questions the reader answered. `a` and `d` answer for every remaining
- /// comment in their file, so those count here too.
+ /// Questions the reader answered.
+ /// `a` and `d` answer for every remaining comment in their file, so those count here too.
pub reviewed: usize,
/// Comments the run had to offer, whether or not it got as far as asking.
pub offered: usize,
@@ -2183,17 +2339,11 @@ pub(crate) struct InteractiveOutcome {
/// What an interactive run came to, in the vocabulary every other summary uses.
///
-/// A run with nothing to offer borrows the wording the plain `fix` summary
-/// gives the same answer, because the only number worth reporting there is how
-/// much was looked at. A run stopped by `q` is counted against the questions it
-/// actually asked, and says how many it never got to: measuring the acceptances
-/// against every comment the run *could* have offered would read as a pile of
-/// refusals nobody made.
+/// A run with nothing to offer borrows the wording the plain `fix` summary gives the same answer, because the only number worth reporting there is how much was looked at.
+/// A run stopped by `q` is counted against the questions it actually asked, and says how many it never got to: measuring the acceptances against every comment the run *could* have offered would read as a pile of refusals nobody made.
///
-/// Either way the verdict closes on the `(N files scanned)` every other summary
-/// ends with. Answering questions about three files says nothing about how many
-/// were opened to find them, and that is the number a reader checks a run
-/// against.
+/// Either way the verdict closes on the `(N files scanned)` every other summary ends with.
+/// Answering questions about three files says nothing about how many were opened to find them, and that is the number a reader checks a run against.
pub(crate) fn interactive_summary(outcome: InteractiveOutcome) -> String {
if outcome.offered == 0 {
return format!("Nothing to fix in {}.", plural(outcome.scanned, "file"));
@@ -2217,38 +2367,30 @@ pub(crate) fn interactive_summary(outcome: InteractiveOutcome) -> String {
/// and the I/O errors that were listed one by one above it.
/// How many files a concentrated report names before it stops.
///
-/// Enough to see where the work is and short enough to read without
-/// scrolling. A caller who wants the whole distribution has `--format json`.
+/// Enough to see where the work is and short enough to read without scrolling.
+/// A caller who wants the whole distribution has `--format json`.
const TOP_FILES: usize = 5;
/// How many findings a run has to have before it is worth summarising.
///
-/// Under this a reader has already read every line by the time they reach the
-/// summary, and telling them where the findings are would be telling them what
-/// they just saw.
+/// Under this a reader has already read every line by the time they reach the summary, and telling them where the findings are would be telling them what they just saw.
const CONCENTRATION_THRESHOLD: usize = 10;
/// The lines that turn a wall of findings into something to act on.
///
-/// A run reporting twenty-one removable comments has told the reader what it
-/// found and nothing about what to do. Two things it already knows would
-/// answer that: which files hold the findings, and whether they are all of one
-/// kind -- because if they are, one flag makes the run clean, and the reader
-/// should not have to work that out from the list.
+/// A run reporting twenty-one removable comments has told the reader what it found and nothing about what to do.
+/// Two things it already knows would answer that: which files hold the findings, and whether they are all of one kind -- because if they are, one flag makes the run clean, and the reader should not have to work that out from the list.
///
-/// Both are held back below [`CONCENTRATION_THRESHOLD`] findings, where the
-/// list is short enough to have been read already.
+/// Both are held back below [`CONCENTRATION_THRESHOLD`] findings, where the list is short enough to have been read already.
fn concentration(files: &[ProcessedFile], options: &RenderOptions) -> Vec {
let mut per_file: Vec<(&Path, usize)> = Vec::new();
- /* NOTE: Counted into a slot per kind rather than a map, as `kind_breakdown`
- * does, because `CommentKind` is an enum with a canonical order and
- * `CommentKind::ALL` is that order. */
+ /* NOTE: Counted into a slot per kind rather than a map, as `kind_breakdown` does, because `CommentKind` is an enum with a canonical order and `CommentKind::ALL` is that order. */
let mut kinds = [0usize; CommentKind::ALL.len()];
let mut total = 0usize;
for file in files {
let mut count = 0usize;
for comment in &file.result.report.comments {
- if comment.disposition.is_remove() {
+ if comment.action().removes() {
count += 1;
let slot = CommentKind::ALL
.iter()
@@ -2267,8 +2409,7 @@ fn concentration(files: &[ProcessedFile], options: &RenderOptions) -> Vec Vec 0)
.map(|(_, kind)| kind)
.collect();
- if options.operation != Operation::Fix
+ if !options.operation.removes()
&& let Some(advice) = advice_for(&present, options.policy)
{
lines.push(advice);
@@ -2303,22 +2444,15 @@ fn concentration(files: &[ProcessedFile], options: &RenderOptions) -> Vec Option {
if let Some(policy) = Policy::strongest_keeping(present)
&& policy != current
@@ -2327,13 +2461,9 @@ fn advice_for(present: &[CommentKind], current: Policy) -> Option {
"every one of these is a kind `--policy {policy}` keeps"
));
}
- /* NOTE: And nothing when no policy answers. `--keep-kind line` was offered
- * here, and it is the shortest way to a green run and says nothing about
- * whether the run should be green: a gate that names the flag which
- * silences it, at the moment it fires, is arguing against its own finding.
- * The kinds are still reported -- the line above this one says what they
- * are and where -- and what to do about them is a decision rather than a
- * flag. */
+ /* NOTE: And nothing when no policy answers.
+ * `--keep-kind line` was offered here, and it is the shortest way to a green run and says nothing about whether the run should be green: a gate that names the flag which silences it, at the moment it fires, is arguing against its own finding.
+ * The kinds are still reported -- the line above this one says what they are and where -- and what to do about them is a decision rather than a flag. */
None
}
@@ -2343,8 +2473,7 @@ fn summary_report(summary: &Summary, options: &RenderOptions, folded: bool) -> S
let mut report = if summary.files_scanned > 0 {
format!("{}{skips}", summary_line(summary, options))
} else if !skips.is_empty() {
- /* NOTE: Nothing was scanned, so the verdict would count zero files; what the
- * run actually did was pass every candidate over. */
+ /* NOTE: Nothing was scanned, so the verdict would count zero files; what the run actually did was pass every candidate over. */
format!("Nothing to {nothing}:{skips}")
} else if summary.named_skips > 0 {
format!("Nothing to {nothing}.")
@@ -2357,97 +2486,150 @@ fn summary_report(summary: &Summary, options: &RenderOptions, folded: bool) -> S
report
}
-/// The verb a run uses for the work it found nothing to do. `fix --dry-run`
-/// borrows the vocabulary of the `fix` it is standing in for, as it does
-/// everywhere else in the summary.
+/// The verb a run uses for the work it found nothing to do.
+/// `fix --dry-run` borrows the vocabulary of the `fix` it is standing in for, as it does everywhere else in the summary.
fn nothing_to(options: &RenderOptions) -> &'static str {
match options.operation {
Operation::Check => "check",
- Operation::Fix => "fix",
- Operation::Diff if options.dry_run => "fix",
- Operation::Diff => "diff",
+ Operation::Fix(Writes::Everything) => "fix",
+ Operation::Fix(Writes::RewritesOnly) => "tidy",
+ Operation::Diff(Writes::Everything) if options.dry_run => "fix",
+ Operation::Diff(Writes::RewritesOnly) if options.dry_run => "tidy",
+ Operation::Diff(_) => "diff",
Operation::Scan => "scan",
}
}
+/// What the headline counts, under the noun that covers it.
+///
+/// A run that only removed comments says `comments`, as it always has.
+/// A run that rewrote a document's paragraphs has to say something else: a paragraph of Markdown is not a comment, and a headline that called it one would be the report's first line telling a reader something about their file that is not so.
+fn headline_count(removable: usize, restyled: usize, noun: &str) -> String {
+ if removable == 0 || noun == "comment" {
+ return plural(removable + restyled, noun);
+ }
+ format!(
+ "{} and {}",
+ comments(removable, "removable"),
+ plural(restyled, noun)
+ )
+}
+
/// The one-line verdict for the run, without the skipped-file clause.
+///
+/// Every sentence here is unchanged when nothing would be rewritten, which is every run that has not asked for a style rule.
+/// That is deliberate: these lines are what a CI job greps for, and a report that reworded itself for every reader because a feature they do not use exists would be a report that broke their job to tell them nothing.
fn summary_line(summary: &Summary, options: &RenderOptions) -> String {
let scanned = plural(summary.files_scanned, "file");
+ let files = plural(summary.files_with_findings, "file");
let found = || {
+ if summary.rewritten() == 0 {
+ return format!(
+ "Found {} in {files} ({scanned} scanned).",
+ comments(summary.removable_comments, "removable"),
+ );
+ }
+ if summary.removable_comments == 0 {
+ return format!(
+ "Found {} to rewrite in {files} ({scanned} scanned).",
+ plural(summary.rewritten(), summary.rewritten_noun()),
+ );
+ }
format!(
- "Found {} in {} ({scanned} scanned).",
+ "Found {} and {} to rewrite in {files} ({scanned} scanned).",
comments(summary.removable_comments, "removable"),
- plural(summary.files_with_removable, "file")
+ summary.rewritten(),
)
};
match options.operation {
- /* NOTE: `fix --dry-run` is the diff of a fix: it counts what a real run would
- * take out and points back at the run that would write it. */
- Operation::Diff if options.dry_run => {
- if summary.removable_comments == 0 {
+ /* NOTE: `fix --dry-run` is the diff of a fix: it counts what a real run would take out and points back at the run that would write it. */
+ Operation::Diff(_) if options.dry_run => {
+ if summary.findings() == 0 {
return format!("Nothing to fix in {scanned}.");
}
+ if summary.rewritten() == 0 {
+ return format!(
+ "Would remove {} in {files}. Rerun without --dry-run to apply.",
+ comments(summary.removable_comments, ""),
+ );
+ }
format!(
- "Would remove {} in {}. Rerun without --dry-run to apply.",
- comments(summary.removable_comments, ""),
- plural(summary.files_with_removable, "file")
+ "Would change {} in {files}. Rerun without --dry-run to apply.",
+ comments(summary.findings(), ""),
)
}
- Operation::Check | Operation::Diff => {
- if summary.removable_comments == 0 {
+ Operation::Check | Operation::Diff(_) => {
+ if summary.findings() == 0 {
return format!("No removable comments in {scanned}.");
}
- let next = if options.operation == Operation::Diff {
+ let next = if matches!(options.operation, Operation::Diff(_)) {
"apply the patch"
- } else if summary.removable_comments == 1 {
+ } else if summary.removable_comments == 0 {
+ "apply the rewrites"
+ } else if summary.findings() == 1 {
"remove it"
} else {
"remove them"
};
format!("{} Run `ocomment fix` to {next}.", found())
}
- Operation::Fix => {
+ Operation::Fix(writes) => {
if options.applied && summary.files_changed > 0 {
- /* NOTE: The evidence, not just the count -- what makes a tool
- * safe to wire into a hook is being able to say what was
- * checked. Which is why it cannot be printed unconditionally: a
- * file that did not scan produces a result that does not scan,
- * so a forced write skips that check, and claiming it anyway
- * would put the strongest sentence here prints on the one run
- * that did not earn it. */
- let head = format!(
- "Removed {} in {} ({scanned} scanned)",
- comments(summary.comments_removed, ""),
- plural(summary.files_changed, "file")
- );
+ /* NOTE: The evidence, not just the count -- what makes a tool safe to wire into a hook is being able to say what was checked.
+ * Which is why it cannot be printed unconditionally: a file that did not scan produces a result that does not scan,
+ * so a forced write skips that check, and claiming it anyway would put the strongest sentence here prints on the one run that did not earn it. */
+ let head = match writes {
+ Writes::Everything => format!(
+ "Removed {} in {} ({scanned} scanned)",
+ comments(summary.comments_removed, ""),
+ plural(summary.files_changed, "file")
+ ),
+ Writes::RewritesOnly => format!(
+ "Rewrote {} in {} ({scanned} scanned)",
+ plural(summary.rewritten(), summary.rewritten_noun()),
+ plural(summary.files_changed, "file")
+ ),
+ };
if summary.forced_files > 0 {
- format!(
+ return format!(
"{head}; {} written from a scan that failed, edited only outside what the failure covers and re-scanned by nothing.",
plural(summary.forced_files, "file")
- )
- } else {
- format!("{head}; each re-scanned clean and idempotent before writing.")
+ );
}
- } else if summary.removable_comments == 0 {
- format!("Nothing to fix in {scanned}.")
+ /* NOTE: A tidying run ends with the half it was told not to touch still in the files.
+ * Saying only what it wrote would read as "done" over a tree that still has the decisions in it, and the run exits 1 for exactly those. */
+ if writes == Writes::RewritesOnly && summary.removable_comments > 0 {
+ return format!(
+ "{head}; {} left to decide on. Run `ocomment check` to see them.",
+ comments(summary.removable_comments, "removable")
+ );
+ }
+ format!("{head}; each re-scanned clean and idempotent before writing.")
+ } else if summary.findings() == 0 {
+ format!("Nothing to {} in {scanned}.", nothing_to(options))
} else {
- /* NOTE: The transaction never reached the disk; report what is still
- * there rather than claiming a removal. */
+ /* NOTE: The transaction never reached the disk; report what is still there rather than claiming a removal. */
found()
}
}
- Operation::Scan => format!(
+ Operation::Scan if summary.rewritten() == 0 => format!(
"Scanned {scanned}: {} ({} removable, {} kept).",
comments(summary.removable_comments + summary.kept_comments, ""),
summary.removable_comments,
summary.kept_comments
),
+ Operation::Scan => format!(
+ "Scanned {scanned}: {} ({} removable, {} to rewrite, {} kept).",
+ comments(summary.findings() + summary.kept_comments, ""),
+ summary.removable_comments,
+ summary.rewritten(),
+ summary.kept_comments
+ ),
}
}
-/// The skipped-file clause appended to the summary line. Only the skips met
-/// while walking are folded here; a named path was already reported on its own
-/// line.
+/// The skipped-file clause appended to the summary line.
+/// Only the skips met while walking are folded here; a named path was already reported on its own line.
fn skip_clause(summary: &Summary, folded: bool) -> String {
let total = summary.skipped_files();
if total == 0 {
@@ -2468,7 +2650,7 @@ fn skip_clause(summary: &Summary, folded: bool) -> String {
/// The `-v` breakdown of what each comment kind contributed.
fn kind_breakdown(files: &[ProcessedFile], options: &RenderOptions) -> Option {
- let verb = if options.operation == Operation::Fix && options.applied {
+ let verb = if options.operation.removes() && options.applied {
"removed"
} else {
"removable"
@@ -2481,7 +2663,7 @@ fn kind_breakdown(files: &[ProcessedFile], options: &RenderOptions) -> Option &'static str {
/// The path half of a report line, and the hyperlink wrapped around it.
///
-/// A file name is chosen by whoever made the file, so the shown half is
-/// untrusted input on its way to a terminal exactly like the preview beside
-/// it, and gets `sanitize_path`'s treatment: one line, no control characters,
+/// A file name is chosen by whoever made the file, so the shown half is untrusted input on its way to a terminal exactly like the preview beside it, and gets `sanitize_path`'s treatment: one line, no control characters,
/// and no width cap, because a path cut to an ellipsis names no file.
///
/// The link *target* is untrusted for the same reason and by the same route —
-/// the frame around it is written in escape bytes, so a name carrying one of
-/// its own would close the frame early and the rest of the name would be read
-/// as terminal instructions. A URL cannot carry a byte it has no spelling for
-/// anyway, so the target is encoded outright rather than patched up for the
-/// three characters somebody thought of first.
+/// the frame around it is written in escape bytes, so a name carrying one of its own would close the frame early and the rest of the name would be read as terminal instructions.
+/// A URL cannot carry a byte it has no spelling for anyway, so the target is encoded outright rather than patched up for the three characters somebody thought of first.
fn display_path(path: &Path, hyperlinks: bool) -> String {
let display = sanitize_path(&path.display().to_string());
if !hyperlinks {
@@ -2533,16 +2710,10 @@ fn display_path(path: &Path, hyperlinks: bool) -> String {
format!("\x1b]8;;file://{target}\x1b\\{display}\x1b]8;;\x1b\\")
}
-/// The path half of a `file://` URL, with every byte a URL may not carry
-/// spelled as the `%XX` a reader of the URL puts back.
+/// The path half of a `file://` URL, with every byte a URL may not carry spelled as the `%XX` a reader of the URL puts back.
///
-/// The unreserved set of RFC 3986 is kept as it stands, and so is the `/` that
-/// separates one path segment from the next; everything else — the space and
-/// the `#` that used to be special-cased here, the `%` that makes an encoding
-/// an encoding, and every control byte — is encoded. A path is bytes rather
-/// than characters, so the encoding is done over the UTF-8 the name is spelled
-/// in: a `%XX` pair is defined as a byte, and half an encoded character is not
-/// a character a terminal can put back together.
+/// The unreserved set of RFC 3986 is kept as it stands, and so is the `/` that separates one path segment from the next; everything else — the space and the `#` that used to be special-cased here, the `%` that makes an encoding an encoding, and every control byte — is encoded.
+/// A path is bytes rather than characters, so the encoding is done over the UTF-8 the name is spelled in: a `%XX` pair is defined as a byte, and half an encoded character is not a character a terminal can put back together.
fn percent_encode(path: impl AsRef<[u8]>) -> String {
let path = path.as_ref();
let mut encoded = String::with_capacity(path.len());
@@ -2562,8 +2733,8 @@ fn push_percent_encoded(output: &mut String, byte: u8) {
output.push(HEX[usize::from(byte & 0xf)]);
}
-/// The digits a percent-encoded byte is spelled with. RFC 3986 asks for the
-/// upper-case ones.
+/// The digits a percent-encoded byte is spelled with.
+/// RFC 3986 asks for the upper-case ones.
const HEX: [char; 16] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
];
@@ -2581,16 +2752,11 @@ fn render_json(
version: u8,
files: JsonFiles<'a>,
skipped: JsonSkipped<'a>,
- /// The same grouping the other two formats show, for a caller that
- /// parses rather than reads.
+ /// The same grouping the other two formats show, for a caller that parses rather than reads.
///
/// `files` says where every comment is and what was decided about it,
- /// which is the report. This says what its author is being asked to do
- /// about it, which is the part a caller acts on -- and it is here
- /// rather than beside each comment because the unit of the answer is
- /// the decision, not the finding: four comments under one question are
- /// one edit to make four times, and a caller that reads them one at a
- /// time has to rebuild that before it can start.
+ /// which is the report.
+ /// This says what its author is being asked to do about it, which is the part a caller acts on -- and it is here rather than beside each comment because the unit of the answer is the decision, not the finding: four comments under one question are one edit to make four times, and a caller that reads them one at a time has to rebuild that before it can start.
#[serde(skip_serializing_if = "Vec::is_empty")]
decisions: Vec,
}
@@ -2617,8 +2783,7 @@ struct JsonDecision {
instruction: String,
comments: usize,
findings: Vec,
- /// The setting that would stop this being asked, as the lines to add and
- /// the file to add them to.
+ /// The setting that would stop this being asked, as the lines to add and the file to add them to.
#[serde(skip_serializing_if = "Option::is_none")]
keep_instead: Option,
}
@@ -2626,14 +2791,10 @@ struct JsonDecision {
#[derive(Serialize)]
struct JsonFinding {
path: String,
- /// The bytes the finding covers, from its first comment's first byte to
- /// its last comment's last.
+ /// The bytes the finding covers, from its first comment's first byte to its last comment's last.
///
- /// A path and a line do not identify it. Two removable comments share a
- /// line whenever one sits beside code, and two findings then reached this
- /// format identical in every field — so a reader could neither tell them
- /// apart nor act on either without going back to the file to work out
- /// which was which.
+ /// A path and a line do not identify it.
+ /// Two removable comments share a line whenever one sits beside code, and two findings then reached this format identical in every field — so a reader could neither tell them apart nor act on either without going back to the file to work out which was which.
span: ByteSpan,
line: usize,
/// One-based, and the same column the text formats put after the line.
@@ -2641,8 +2802,8 @@ struct JsonFinding {
end_line: usize,
/// The lines as they are.
old: Vec,
- /// What would replace them. Absent when the answer is to delete rather
- /// than to rewrite, which is not the same as replacing them with nothing.
+ /// What would replace them.
+ /// Absent when the answer is to delete rather than to rewrite, which is not the same as replacing them with nothing.
#[serde(skip_serializing_if = "Vec::is_empty")]
new: Vec,
/// The code the comment is about, when the decision turns on it.
@@ -2786,27 +2947,23 @@ pub struct JsonOptions {
/// Where a SARIF reader is sent to learn what the tool itself is.
const TOOL_INFORMATION_URI: &str = "https://github.com/P4suta/OComment";
-/// Where a rule about a comment sends a reader asking why that comment is
-/// reported — and why the one beside it is not.
+/// Where a rule about a comment sends a reader asking why that comment is reported — and why the one beside it is not.
const KIND_HELP_URI: &str = "https://github.com/P4suta/OComment#why-was-this-comment-kept";
/// The base id a path under the directory the run walked is reported against.
-/// SARIF readers, GitHub code scanning among them, resolve `%SRCROOT%` to the
-/// root of the checkout.
+/// SARIF readers, GitHub code scanning among them, resolve `%SRCROOT%` to the root of the checkout.
const SRCROOT: &str = "%SRCROOT%";
-/// The one sentence every scan diagnostic is described by. The codes are as
-/// varied as the languages that raise them, and the result carries the message
-/// that says what was actually met.
+/// The one sentence every scan diagnostic is described by.
+/// The codes are as varied as the languages that raise them, and the result carries the message that says what was actually met.
const DIAGNOSTIC_DESCRIPTION: &str =
"A problem OComment met while scanning the file; the message on the result says what it was.";
/// The repository spelling a machine format reports a path under.
///
/// GitHub's `file=` property is a repository path, while SARIF wants a URI.
-/// Both start from this byte-preserving spelling: platform separators become
-/// `/`, and `.` segments left by a typed path are removed. Keeping this layer
-/// separate prevents URI escaping from being mistaken for a repository name.
+/// Both start from this byte-preserving spelling: platform separators become `/`, and `.` segments left by a typed path are removed.
+/// Keeping this layer separate prevents URI escaping from being mistaken for a repository name.
fn report_path_bytes(path: &Path) -> Vec {
#[cfg(unix)]
let bytes = {
@@ -2821,8 +2978,7 @@ fn report_path_bytes(path: &Path) -> Vec {
.filter(|segment| *segment != b".")
.collect();
if segments.is_empty() {
- /* NOTE: The path was `.` (or `./`) and naming nothing at all would be worse
- * than naming the directory. */
+ /* NOTE: The path was `.` (or `./`) and naming nothing at all would be worse than naming the directory. */
return bytes;
}
let mut normalized = Vec::with_capacity(bytes.len());
@@ -2866,9 +3022,8 @@ fn lossless_text(mut bytes: &[u8]) -> String {
text
}
-/// The URI spelling SARIF requires. Unlike a GitHub annotation property it is
-/// an RFC 3986 reference, so spaces, controls, literal percent signs, and raw
-/// Unix filename bytes are percent-encoded exactly once.
+/// The URI spelling SARIF requires.
+/// Unlike a GitHub annotation property it is an RFC 3986 reference, so spaces, controls, literal percent signs, and raw Unix filename bytes are percent-encoded exactly once.
fn sarif_uri(path: &Path) -> String {
if path == Path::new(STDIN_PATH) {
return STDIN_PATH.to_owned();
@@ -2884,11 +3039,9 @@ fn sarif_uri(path: &Path) -> String {
encoded
}
-/// Encode a GitHub workflow-command `file=` property from path bytes. This is
-/// not URI encoding: GitHub decodes its small `%25`/`%0D`/`%0A` command
-/// alphabet before matching the repository path. Invalid UTF-8 has no command
-/// representation, so it remains visible and non-lossy as `%XX` instead of
-/// silently becoming U+FFFD.
+/// Encode a GitHub workflow-command `file=` property from path bytes.
+/// This is not URI encoding: GitHub decodes its small `%25`/`%0D`/`%0A` command alphabet before matching the repository path.
+/// Invalid UTF-8 has no command representation, so it remains visible and non-lossy as `%XX` instead of silently becoming U+FFFD.
fn github_path(path: &Path) -> String {
let bytes = report_path_bytes(path);
let mut escaped = String::with_capacity(bytes.len());
@@ -2918,13 +3071,8 @@ fn github_path(path: &Path) -> String {
/// The SARIF `artifactLocation` for a reported path.
///
-/// A path under the directory the run started in is reported against
-/// `%SRCROOT%`: SARIF resolves a relative URI against a base id, and a reader
-/// given none has nothing to resolve it against, so the finding lands on no
-/// file. An absolute path is not under the checkout as far as the run can
-/// tell, one that climbs out through `..` has left it, and the pseudo-path
-/// standard input is reported under is not a file at all — each of those is
-/// reported as it stands, with no base id claiming otherwise.
+/// A path under the directory the run started in is reported against `%SRCROOT%`: SARIF resolves a relative URI against a base id, and a reader given none has nothing to resolve it against, so the finding lands on no file.
+/// An absolute path is not under the checkout as far as the run can tell, one that climbs out through `..` has left it, and the pseudo-path standard input is reported under is not a file at all — each of those is reported as it stands, with no base id claiming otherwise.
fn artifact_location(path: &Path) -> Value {
let repository_path = report_path(path);
let uri = sarif_uri(path);
@@ -2940,22 +3088,15 @@ fn artifact_location(path: &Path) -> Value {
}
}
-/// Whether a repository-relative URI opens with a segment no reader will take
-/// for a directory name.
+/// Whether a repository-relative URI opens with a segment no reader will take for a directory name.
///
-/// A `uri` is read as a URI, and RFC 3986 hands a relative reference's first
-/// segment to the scheme as soon as it holds a colon: `c:/a.rs` parses as the
-/// scheme `c` over the path `/a.rs`, and a Windows reader sees a drive letter
-/// in it besides. A POSIX checkout is free to hold a directory named `c:`, so
-/// the path says which it meant with the one `.` segment the standard keeps
-/// for exactly this: `./c:/a.rs` is a relative reference whatever reads it,
+/// A `uri` is read as a URI, and RFC 3986 hands a relative reference's first segment to the scheme as soon as it holds a colon: `c:/a.rs` parses as the scheme `c` over the path `/a.rs`, and a Windows reader sees a drive letter in it besides.
+/// A POSIX checkout is free to hold a directory named `c:`, so the path says which it meant with the one `.` segment the standard keeps for exactly this: `./c:/a.rs` is a relative reference whatever reads it,
/// and it still resolves against `%SRCROOT%`.
///
-/// Only a repository-relative path is treated this way. A GitHub annotation is
-/// matched against the paths the checkout uses rather than parsed as a URI, so
-/// [`report_path`] leaves the spelling alone and only this document adds to it;
-/// `tools/validate_schemas.py` is the other half of the rule and turns down
-/// the bare form.
+/// Only a repository-relative path is treated this way.
+/// A GitHub annotation is matched against the paths the checkout uses rather than parsed as a URI, so [`report_path`] leaves the spelling alone and only this document adds to it;
+/// `tools/validate_schemas.py` is the other half of the rule and turns down the bare form.
fn reads_as_a_drive_letter(uri: &str) -> bool {
let mut head = uri.split('/').next().unwrap_or_default().chars();
matches!(
@@ -2976,15 +3117,11 @@ fn under_source_root(path: &Path) -> bool {
/// The rules of one SARIF run, and the index each result points at.
///
-/// A result names its rule twice: by `ruleId`, and by the position of that
-/// rule's description in `tool.driver.rules`. A code-scanning UI shows a
-/// finding through that description — its title, the sentence under it, and
-/// the link it offers — so handing out the id and the index together is what
-/// keeps a result from pointing at a description that is not there.
+/// A result names its rule twice: by `ruleId`, and by the position of that rule's description in `tool.driver.rules`.
+/// A code-scanning UI shows a finding through that description — its title, the sentence under it, and the link it offers — so handing out the id and the index together is what keeps a result from pointing at a description that is not there.
///
-/// Every comment kind is described whether or not the run met one, because the
-/// rules a tool reports are also read as the list of what it can find. The
-/// rest — a scan diagnostic, a skipped file, a file that could not be read —
+/// Every comment kind is described whether or not the run met one, because the rules a tool reports are also read as the list of what it can find.
+/// The rest — a scan diagnostic, a skipped file, a file that could not be read —
/// are described as the run meets them.
struct SarifRules {
entries: Vec,
@@ -3008,11 +3145,35 @@ impl SarifRules {
KIND_HELP_URI,
);
}
+ /* NOTE: Both origins of every rule, written out rather than described on first use.
+ * The table is the tool's declared vocabulary, and a consumer that reads it to build a filter should find every identifier this run can emit whether or not this run emitted it. */
+ for rule in StyleRule::ALL {
+ for (origin, what) in [
+ (ProseOrigin::Comments, "comment paragraph"),
+ (ProseOrigin::Document, "paragraph"),
+ ] {
+ let run = ProseRun {
+ span: ocomment_core::ByteSpan::new(0, 0),
+ origin,
+ rule,
+ replacement: Vec::new(),
+ };
+ rules.describe(
+ &run_rule_id(&run),
+ "note",
+ &format!("{} {what}", sentence_case(rewrite_label(rule))),
+ &format!(
+ "A {what} OComment would write differently: {}.",
+ rule.detail()
+ ),
+ KIND_HELP_URI,
+ );
+ }
+ }
rules
}
- /// The index of the rule `id`, describing it first if this run has not
- /// reported it before.
+ /// The index of the rule `id`, describing it first if this run has not reported it before.
fn describe(&mut self, id: &str, level: &str, short: &str, full: &str, help: &str) -> usize {
if let Some(&index) = self.indices.get(id) {
return index;
@@ -3064,9 +3225,8 @@ fn sarif_level(severity: ocomment_core::Severity) -> &'static str {
}
}
-/// A SARIF result array serialized one finding at a time. Keeping the rule
-/// table separate lets the header be finalized first without retaining a
-/// `serde_json::Value` for every comment in the run.
+/// A SARIF result array serialized one finding at a time.
+/// Keeping the rule table separate lets the header be finalized first without retaining a `serde_json::Value` for every comment in the run.
struct SarifResults<'a> {
files: &'a [ProcessedFile],
skipped: &'a [SkippedFile],
@@ -3081,24 +3241,14 @@ impl Serialize for SarifResults<'_> {
let mut results = serializer.serialize_seq(None)?;
for file in self.files {
if file.result.report.diagnostics.is_empty()
- && !file
- .result
- .report
- .comments
- .iter()
- .any(|comment| comment.disposition.is_remove())
+ && !file.result.report.comments.iter().any(reported)
+ && file.result.report.runs.is_empty()
{
continue;
}
let location = artifact_location(&file.path);
let lines = LineIndex::new(&file.source);
- for comment in file
- .result
- .report
- .comments
- .iter()
- .filter(|comment| comment.disposition.is_remove())
- {
+ for comment in file.result.report.comments.iter().filter(|c| reported(c)) {
let (line, column) = lines.line_column(comment.span.start);
let (end_line, end_column) = lines.line_column(comment.span.end);
let (fix_span, replacement) = fix_for_span(file, comment.span);
@@ -3109,14 +3259,18 @@ impl Serialize for SarifResults<'_> {
"ruleId": format!("removable-{kind}"),
"ruleIndex": self.rules.kind(comment.kind),
"level": "note",
- "message": {"text": removable_label(comment.kind)},
+ "message": {"text": finding_label(comment)},
"locations": [{"physicalLocation": {
"artifactLocation": location.clone(),
"region": {"startLine": line, "startColumn": column,
"endLine": end_line, "endColumn": end_column}
}}],
"fixes": [{
- "description": {"text": "Remove comment with OComment"},
+ "description": {"text": if comment.action().removes() {
+ "Remove comment with OComment"
+ } else {
+ "Rewrite comment with OComment"
+ }},
"artifactChanges": [{
"artifactLocation": location.clone(),
"replacements": [{"deletedRegion": {
@@ -3127,6 +3281,36 @@ impl Serialize for SarifResults<'_> {
}]
}))?;
}
+ /* NOTE: And the paragraphs, which are not any one comment's.
+ * A run's replacement is the verdict itself rather than something derived from the file, so the fix here carries it directly instead of asking `fix_for_span`, which is about the lines a *removal* has to swallow. */
+ for run in &file.result.report.runs {
+ let (line, column) = lines.line_column(run.span.start);
+ let (end_line, end_column) = lines.line_column(run.span.end);
+ let id = run_rule_id(run);
+ results.serialize_element(&json!({
+ "ruleId": id,
+ "ruleIndex": self.rules.index(&id),
+ "level": "note",
+ "message": {"text": run_label(run)},
+ "locations": [{"physicalLocation": {
+ "artifactLocation": location.clone(),
+ "region": {"startLine": line, "startColumn": column,
+ "endLine": end_line, "endColumn": end_column}
+ }}],
+ "fixes": [{
+ "description": {"text": "Rewrite paragraph with OComment"},
+ "artifactChanges": [{
+ "artifactLocation": location.clone(),
+ "replacements": [{"deletedRegion": {
+ "startLine": line, "startColumn": column,
+ "endLine": end_line, "endColumn": end_column
+ }, "insertedContent": {
+ "text": String::from_utf8_lossy(&run.replacement)
+ }}]
+ }]
+ }]
+ }))?;
+ }
for diagnostic in &file.result.report.diagnostics {
let (line, column) = lines.line_column(diagnostic.span.start);
let (end_line, end_column) = lines.line_column(diagnostic.span.end);
@@ -3253,23 +3437,14 @@ fn render_sarif(
Ok(())
}
-/// The rewrite a removed comment's SARIF fix offers: the bytes it deletes and
-/// the bytes that go in their place.
+/// The rewrite a removed comment's SARIF fix offers: the bytes it deletes and the bytes that go in their place.
///
-/// A fix is an offer to rewrite the file, so what it deletes has to be what the
-/// run would have deleted. Under [`ocomment_core::Layout::Compact`] that is
-/// wider than the comment: a comment alone on its line takes the indentation
-/// before it and the terminator after it with it, and a fix cut back to the
-/// comment's own span would leave behind exactly the blank line that layout
-/// exists to close up. So the edit that *contains* the comment is what is
-/// reported, rather than one that starts and ends where the comment does.
+/// A fix is an offer to rewrite the file, so what it deletes has to be what the run would have deleted.
+/// Under [`ocomment_core::Layout::Compact`] that is wider than the comment: a comment alone on its line takes the indentation before it and the terminator after it with it, and a fix cut back to the comment's own span would leave behind exactly the blank line that layout exists to close up.
+/// So the edit that *contains* the comment is what is reported, rather than one that starts and ends where the comment does.
///
-/// Edits are sorted and non-overlapping and each one spans the comment it
-/// removes, so at most one of them can contain a given comment. A file whose
-/// report came back invalid has comments but no edits — nothing is rewritten
-/// from a source the scanner could not read to the end — and there the
-/// comment's own span, with nothing to put in its place, is all there is to
-/// offer.
+/// Edits are sorted and non-overlapping and each one spans the comment it removes, so at most one of them can contain a given comment.
+/// A file whose report came back invalid has comments but no edits — nothing is rewritten from a source the scanner could not read to the end — and there the comment's own span, with nothing to put in its place, is all there is to offer.
fn fix_for_span(file: &ProcessedFile, span: ByteSpan) -> (ByteSpan, String) {
file.result
.edits
@@ -3288,22 +3463,17 @@ fn fix_for_span(file: &ProcessedFile, span: ByteSpan) -> (ByteSpan, String) {
/// The `::` level a removable comment is annotated at.
///
-/// An annotation level is a claim about what the run means, and the run
-/// already makes that claim in its exit status: `check` and `diff` answer a
-/// finding with 1 and every other operation ends at 0 whatever it found. A
-/// gate that fails on the 1 was posting `::notice` about the very comments it
-/// failed over, which reads in the checks tab as though nothing was wrong --
-/// and GitHub folds notices away where it surfaces errors. So the level
-/// follows the status: what fails the run is an error, and what is offered for
-/// information is a notice. `--annotation-level` overrules it for a job that
-/// posts annotations without gating on them, or gates without wanting the red.
+/// An annotation level is a claim about what the run means, and the run already makes that claim in its exit status: `check` and `diff` answer a finding with 1 and every other operation ends at 0 whatever it found.
+/// A gate that fails on the 1 was posting `::notice` about the very comments it failed over, which reads in the checks tab as though nothing was wrong -- and GitHub folds notices away where it surfaces errors.
+/// So the level follows the status: what fails the run is an error, and what is offered for information is a notice.
+/// `--annotation-level` overrules it for a job that posts annotations without gating on them, or gates without wanting the red.
fn annotation_level(options: &RenderOptions) -> &'static str {
if let Some(level) = options.annotation_level {
return level.as_str();
}
match options.operation {
- Operation::Check | Operation::Diff => "error",
- Operation::Scan | Operation::Fix => "notice",
+ Operation::Check | Operation::Diff(_) => "error",
+ Operation::Scan | Operation::Fix(_) => "notice",
}
}
@@ -3317,29 +3487,29 @@ fn render_github(
let level = annotation_level(options);
for file in files {
if file.result.report.diagnostics.is_empty()
- && !file
- .result
- .report
- .comments
- .iter()
- .any(|comment| comment.disposition.is_remove())
+ && !file.result.report.comments.iter().any(reported)
+ && file.result.report.runs.is_empty()
{
continue;
}
let lines = LineIndex::new(&file.source);
- for comment in file
- .result
- .report
- .comments
- .iter()
- .filter(|comment| comment.disposition.is_remove())
- {
+ for comment in file.result.report.comments.iter().filter(|c| reported(c)) {
let (line, column) = lines.line_column(comment.span.start);
wrote(writeln!(
output,
"::{level} file={},line={line},col={column}::{}",
github_path(&file.path),
- removable_label(comment.kind)
+ finding_label(comment)
+ ))?;
+ }
+ // NOTE: And the paragraphs, which are not any one comment's and are annotated where they open.
+ for run in &file.result.report.runs {
+ let (line, column) = lines.line_column(run.span.start);
+ wrote(writeln!(
+ output,
+ "::{level} file={},line={line},col={column}::{}",
+ github_path(&file.path),
+ run_label(run)
))?;
}
for diagnostic in &file.result.report.diagnostics {
@@ -3353,17 +3523,10 @@ fn render_github(
))?;
}
}
- /* INVARIANT: `-q` trims the human report down to what went wrong, and there is
- * no such thing to trim here: an annotation is the *product* of this
- * format, not commentary about it, and a hook told to work quietly is
- * still owed the notice for the path its caller named and the error for
- * the file it could not read. So the visibility rule below is asked at
- * `Normal` however quiet the run was, and only `-v` widens it. */
+ /* INVARIANT: `-q` trims the human report down to what went wrong, and there is no such thing to trim here: an annotation is the *product* of this format, not commentary about it, and a hook told to work quietly is still owed the notice for the path its caller named and the error for the file it could not read.
+ * So the visibility rule below is asked at `Normal` however quiet the run was, and only `-v` widens it. */
let visibility = verbosity.at_least_normal();
- /* NOTE: An annotation costs the reader a line of the checks tab, so a walked
- * skip is folded away here exactly as it is in the human report: a run
- * over a repository with forty Markdown files in it must not post forty
- * notices about them. */
+ /* NOTE: An annotation costs the reader a line of the checks tab, so a walked skip is folded away here exactly as it is in the human report: a run over a repository with forty Markdown files in it must not post forty notices about them. */
for item in skipped
.iter()
.filter(|item| skip_is_visible(item, visibility))
@@ -3420,9 +3583,8 @@ pub fn unified_diff(path: &Path, original: &[u8], transformed: &[u8]) -> Vec
output
}
-/// Split on the byte Git treats as a line ending without decoding or replacing
-/// any other byte. The newline remains in each item so the resulting patch can
-/// reconstruct the source exactly.
+/// Split on the byte Git treats as a line ending without decoding or replacing any other byte.
+/// The newline remains in each item so the resulting patch can reconstruct the source exactly.
fn byte_lines(bytes: &[u8]) -> Vec<&[u8]> {
let mut lines = Vec::new();
let mut start = 0;
@@ -3438,9 +3600,8 @@ fn byte_lines(bytes: &[u8]) -> Vec<&[u8]> {
lines
}
-/// Spell a patch header path the way Git's parser accepts it. Ordinary names
-/// stay readable; bytes that could terminate or corrupt the header use Git's
-/// C-style quoting, including three-digit octal escapes for non-UTF-8 bytes.
+/// Spell a patch header path the way Git's parser accepts it.
+/// Ordinary names stay readable; bytes that could terminate or corrupt the header use Git's C-style quoting, including three-digit octal escapes for non-UTF-8 bytes.
fn git_patch_path(prefix: &[u8], path: &Path) -> Vec {
#[cfg(unix)]
let bytes = {
@@ -3478,17 +3639,13 @@ fn git_patch_path(prefix: &[u8], path: &Path) -> Vec {
output
}
-/// A reusable byte-offset index for the CLI's one-based line and column
-/// coordinates.
+/// A reusable byte-offset index for the CLI's one-based line and column coordinates.
///
-/// `after_first` preserves the established answer for an offset on the LF of
-/// a CRLF pair: that offset is already on the following line, while an offset
-/// after the pair begins its column after both bytes.
+/// `after_first` preserves the established answer for an offset on the LF of a CRLF pair: that offset is already on the following line, while an offset after the pair begins its column after both bytes.
#[derive(Clone, Debug, Default)]
pub(crate) struct LineIndex {
- /// `(after_first << 1) | is_crlf`. Packing the CRLF bit keeps the index to
- /// one machine word per logical line break even though an offset on the
- /// LF and an offset after it have different column starts.
+ /// `(after_first << 1) | is_crlf`.
+ /// Packing the CRLF bit keeps the index to one machine word per logical line break even though an offset on the LF and an offset after it have different column starts.
breaks: Vec,
source_len: usize,
}
@@ -3552,6 +3709,25 @@ fn github_escape(text: &str) -> String {
pub fn changed(files: &[ProcessedFile]) -> bool {
files.iter().any(|file| file.result.changed())
}
+
+/// The code a finished run answers with.
+///
+/// One function rather than the same `match` at each of the two places a run can finish -- over a working tree and over a Git index -- because they are one contract that had grown the same shape twice.
+/// Exit 2 is decided before this: a run that could not do its job at all does not reach here.
+///
+/// The last two clauses are the ones a commit hook depends on.
+/// A tidying run wrote one half of what it found and left the other where it was, and the half it left is a finding like any other.
+/// A staged write changed the bytes the commit will carry, so the run that did it cannot also report that there was nothing to see: what the author typed and what Git is about to record have stopped being the same thing, and the exit code is the only place that can say so.
+#[must_use]
+pub fn exit_code(operation: Operation, files: &[ProcessedFile], rewrote_the_index: bool) -> u8 {
+ let left_to_decide = files.iter().any(|file| removable_count(file) > 0);
+ match operation {
+ Operation::Check | Operation::Diff(_) if changed(files) => 1,
+ Operation::Fix(Writes::RewritesOnly) if left_to_decide => 1,
+ Operation::Fix(_) if rewrote_the_index => 1,
+ Operation::Check | Operation::Scan | Operation::Diff(_) | Operation::Fix(_) => 0,
+ }
+}
pub fn invalid(files: &[ProcessedFile]) -> bool {
files.iter().any(|file| !file.result.report.valid)
}
@@ -3560,19 +3736,13 @@ pub fn invalid(files: &[ProcessedFile]) -> bool {
mod tests {
use super::*;
- /// Every reason `--deny-skipped` accepts has to be a reason a skip is
- /// actually reported under.
+ /// Every reason `--deny-skipped` accepts has to be a reason a skip is actually reported under.
///
- /// The flag matches the caller's word against the label the report gives
- /// the skip, so a reason with no skip behind it is a reason that turns the
- /// gate off and says nothing — which is the failure the flag exists to
- /// catch, one level up from where it catches it. The two spellings live in
- /// one file so a change to either is visible beside the other; this is
- /// what makes that arrangement a check rather than a convention.
+ /// The flag matches the caller's word against the label the report gives the skip, so a reason with no skip behind it is a reason that turns the gate off and says nothing — which is the failure the flag exists to catch, one level up from where it catches it.
+ /// The two spellings live in one file so a change to either is visible beside the other; this is what makes that arrangement a check rather than a convention.
#[test]
fn every_refusable_reason_is_one_a_skip_is_reported_under() {
- /* NOTE: The reason strings as `files.rs` writes them, so this fails if
- * a skip is reworded without its refusable name following. */
+ /* NOTE: The reason strings as `files.rs` writes them, so this fails if a skip is reworded without its refusable name following. */
let reported = [
(SkipReason::UnknownLanguage, crate::files::NO_LANGUAGE),
(SkipReason::TooLarge, "larger than 1048576 bytes"),
@@ -3589,14 +3759,11 @@ mod tests {
"`{reason:?}` names no skip the report produces"
);
}
- /* NOTE: The one that is not a `skip_label` answer. An unreadable file
- * carries the I/O error as its reason, and `coverage::denied` labels
- * it from this enum rather than from a literal of its own. */
+ /* NOTE: The one that is not a `skip_label` answer.
+ * An unreadable file carries the I/O error as its reason, and `coverage::denied` labels it from this enum rather than from a literal of its own. */
let covered: Vec = reported.iter().map(|(reason, _)| *reason).collect();
- /* NOTE: Asked of clap's own variant list rather than of a second one
- * written here. What the flag accepts is the set that has to be
- * covered, and a hand-kept copy of it is one more place to add a
- * reason to and forget. */
+ /* NOTE: Asked of clap's own variant list rather than of a second one written here.
+ * What the flag accepts is the set that has to be covered, and a hand-kept copy of it is one more place to add a reason to and forget. */
for reason in SkipReason::value_variants() {
assert!(
covered.contains(reason) || *reason == SkipReason::Unreadable,
@@ -3653,11 +3820,8 @@ mod tests {
}
}
- /// The frame around a hyperlink target is written in escape bytes, so a
- /// name carrying one of its own would close the frame early and be read as
- /// terminal instructions from there on. Nor may a URL carry the `%` that
- /// makes an encoding an encoding, the space that ends a URL, or the `#`
- /// that starts a fragment.
+ /// The frame around a hyperlink target is written in escape bytes, so a name carrying one of its own would close the frame early and be read as terminal instructions from there on.
+ /// Nor may a URL carry the `%` that makes an encoding an encoding, the space that ends a URL, or the `#` that starts a fragment.
#[test]
fn a_hyperlink_target_encodes_every_byte_a_url_may_not_carry() {
assert_eq!(
@@ -3669,13 +3833,11 @@ mod tests {
percent_encode("/tmp/evil\u{1b}[2Jname.rs"),
"/tmp/evil%1B%5B2Jname.rs"
);
- /* NOTE: A path is bytes, and one character is as many `%XX` pairs as it
- * takes to spell it. */
+ /* NOTE: A path is bytes, and one character is as many `%XX` pairs as it takes to spell it. */
assert_eq!(percent_encode("/tmp/\u{e9}.rs"), "/tmp/%C3%A9.rs");
}
- /// A name is shown to be typed back, so its own spacing survives; what
- /// does not is anything that would drive the terminal or break the row.
+ /// A name is shown to be typed back, so its own spacing survives; what does not is anything that would drive the terminal or break the row.
#[test]
fn a_sanitized_path_keeps_its_spacing_and_loses_its_controls() {
assert_eq!(sanitize_path(" lead.rs "), " lead.rs ");
@@ -3685,9 +3847,8 @@ mod tests {
}
/// The reported path is read by a machine that has to find the file again:
- /// GitHub matches an annotation by `file=`, and a SARIF reader resolves
- /// `artifactLocation.uri` against the checkout. A Windows separator and a
- /// `.` segment both name a file no checkout has.
+ /// GitHub matches an annotation by `file=`, and a SARIF reader resolves `artifactLocation.uri` against the checkout.
+ /// A Windows separator and a `.` segment both name a file no checkout has.
#[test]
fn report_path_spells_a_path_the_way_a_repository_does() {
assert_eq!(report_path(Path::new("./a.rs")), "a.rs");
@@ -3704,8 +3865,7 @@ mod tests {
assert_eq!(report_path(Path::new(r"sub\doc.rs")), r"sub\doc.rs");
assert_eq!(sarif_uri(Path::new(r"sub\doc.rs")), "sub%5Cdoc.rs");
}
- /* NOTE: A path that leaves the tree, an absolute one, and standard input are
- * all left as they are; only the separators are normalised. */
+ /* NOTE: A path that leaves the tree, an absolute one, and standard input are all left as they are; only the separators are normalised. */
assert_eq!(report_path(Path::new("../sibling/a.rs")), "../sibling/a.rs");
assert_eq!(report_path(Path::new("/tmp/a.rs")), "/tmp/a.rs");
assert_eq!(report_path(Path::new(STDIN_PATH)), STDIN_PATH);
@@ -3731,8 +3891,7 @@ mod tests {
assert_ne!(github_path(literal_percent), github_path(&raw_invalid));
}
- /// `%SRCROOT%` says the path is measured from the root of the checkout, so
- /// it is claimed only for the paths that are.
+ /// `%SRCROOT%` says the path is measured from the root of the checkout, so it is claimed only for the paths that are.
#[test]
fn only_a_path_inside_the_tree_is_reported_against_the_source_root() {
for inside in ["a.rs", "sub/doc.rs", "./sub/doc.rs"] {
@@ -3752,16 +3911,29 @@ mod tests {
}
}
- /// A relative reference whose first segment holds a colon is read as a
- /// scheme, so a checkout that really does hold a directory named `c:` says
- /// so with the one `.` segment a URI keeps for the purpose. Nothing else
- /// gains one, and a path that is under no base is left exactly as it was.
+ /// A relative reference whose first segment holds a colon is read as a scheme, so a checkout that really does hold a directory named `c:` says so with the one `.` segment a URI keeps for the purpose.
+ /// Nothing else gains one, and a path that is under no base is left exactly as it was.
+ ///
+ /// The two spellings this is about are a different path on each system, so the case is asked once per system rather than assumed.
+ /// `c:/a.rs` names a directory called `c:` in a POSIX checkout and the root of a drive on Windows, and `std::path` says so: `components()` yields two `Normal`s there and a `Prefix` here.
+ /// Being under the source root and needing a `./` follows from that, so the answer differs and both are right.
#[test]
fn a_first_segment_that_reads_as_a_drive_letter_is_disambiguated() {
- let location = artifact_location(Path::new("c:/a.rs"));
- assert_eq!(location["uri"], json!("./c:/a.rs"));
- assert_eq!(location["uriBaseId"], json!(SRCROOT));
- assert_eq!(artifact_location(Path::new("c:"))["uri"], json!("./c:"));
+ #[cfg(unix)]
+ {
+ let location = artifact_location(Path::new("c:/a.rs"));
+ assert_eq!(location["uri"], json!("./c:/a.rs"));
+ assert_eq!(location["uriBaseId"], json!(SRCROOT));
+ assert_eq!(artifact_location(Path::new("c:"))["uri"], json!("./c:"));
+ }
+ #[cfg(windows)]
+ {
+ /* NOTE: An absolute path, so it is under no base and claims none.
+ * The `./` exists to stop a reader taking a relative reference for a scheme, and there is no relative reference here to mistake. */
+ let location = artifact_location(Path::new("c:/a.rs"));
+ assert_eq!(location["uri"], json!(sarif_uri(Path::new("c:/a.rs"))));
+ assert!(location.get("uriBaseId").is_none());
+ }
for plain in ["a.rs", "sub/doc.rs", "cc:/a.rs", "sub/c:/a.rs"] {
assert_eq!(
artifact_location(Path::new(plain))["uri"],
@@ -3776,22 +3948,84 @@ mod tests {
);
}
- /// Every result points into the rules by index, so the two orders have to
- /// be the same one.
+ /// The same question the case above asks, asked of the thing it turns on.
+ ///
+ /// Both halves of that test would pass if `under_source_root` simply stopped answering, so this names what each system is expected to say and why: a checkout holds `c:` as a directory only where `c:` can be a directory name.
+ #[test]
+ fn a_drive_letter_is_a_directory_name_on_one_system_and_a_root_on_the_other() {
+ assert_eq!(under_source_root(Path::new("c:/a.rs")), cfg!(unix));
+ for both in ["a.rs", "sub/doc.rs", "cc:/a.rs"] {
+ assert!(
+ under_source_root(Path::new(both)),
+ "`{both}` is a relative path on every system"
+ );
+ }
+ for neither in ["/tmp/a.rs", "../a.rs"] {
+ assert!(
+ !under_source_root(Path::new(neither)),
+ "`{neither}` is not under the checkout on any system"
+ );
+ }
+ }
+
+ /// Every identifier a result can carry, derived from the same lists the table is built from.
+ ///
+ /// Written this way rather than as a number, because a number is a gate that stops covering what it was written for the day a kind or a style rule is added.
+ fn prepared_rule_ids() -> Vec {
+ let mut ids: Vec = CommentKind::ALL
+ .iter()
+ .map(|kind| format!("removable-{kind}"))
+ .collect();
+ for rule in StyleRule::ALL {
+ for origin in [ProseOrigin::Comments, ProseOrigin::Document] {
+ ids.push(run_rule_id(&ProseRun {
+ span: ByteSpan::new(0, 0),
+ origin,
+ rule,
+ replacement: Vec::new(),
+ }));
+ }
+ }
+ ids
+ }
+
+ /// Every result points into the rules by index, so the two orders have to be the same one.
#[test]
fn a_rule_is_described_once_and_keeps_its_index() {
+ let prepared = prepared_rule_ids();
let mut rules = SarifRules::new();
- assert_eq!(rules.entries.len(), CommentKind::ALL.len());
+ assert_eq!(rules.entries.len(), prepared.len());
assert_eq!(rules.kind(CommentKind::Line), 0);
let first = rules.describe("io-error", "error", "short", "full", TOOL_INFORMATION_URI);
- assert_eq!(first, CommentKind::ALL.len());
+ assert_eq!(first, prepared.len());
let again = rules.describe("io-error", "note", "other", "other", TOOL_INFORMATION_URI);
assert_eq!(first, again, "a second sighting described the rule twice");
assert_eq!(
rules.entries[first]["defaultConfiguration"]["level"],
"error"
);
- assert_eq!(rules.entries.len(), CommentKind::ALL.len() + 1);
+ assert_eq!(rules.entries.len(), prepared.len() + 1);
+ }
+
+ /// The table is the tool's declared vocabulary, and `SarifRules::index` panics on an identifier it has not prepared.
+ ///
+ /// So the list is checked against the thing it is a list of, in both directions: every identifier a run can emit is described, and every description answers to an identifier a run can emit.
+ #[test]
+ fn every_rule_a_result_can_name_is_described() {
+ let rules = SarifRules::new();
+ let prepared = prepared_rule_ids();
+ for id in &prepared {
+ assert!(
+ rules.indices.contains_key(id),
+ "`{id}` can be emitted and is not in the rule table"
+ );
+ }
+ for id in rules.indices.keys() {
+ assert!(
+ prepared.contains(id),
+ "`{id}` is described and nothing can emit it"
+ );
+ }
}
#[test]
@@ -3849,8 +4083,7 @@ mod tests {
);
}
- /// Bidi overrides and isolates can make a comment render as its own
- /// reverse, and the line/paragraph separators break the one-line promise.
+ /// Bidi overrides and isolates can make a comment render as its own reverse, and the line/paragraph separators break the one-line promise.
#[test]
fn preview_replaces_bidirectional_and_separator_controls() {
let source = "// \u{202e}reverse\u{202c} \u{200e}\u{200f} \u{2066}iso\u{2069} \
@@ -3873,8 +4106,7 @@ mod tests {
}
}
- /// Zero-width characters cost no display columns, so the width budget alone
- /// cannot bound the line; a hard character cap must.
+ /// Zero-width characters cost no display columns, so the width budget alone cannot bound the line; a hard character cap must.
#[test]
fn preview_caps_the_character_count_of_a_zero_width_run() {
let source = format!("a{}", "\u{301}".repeat(1000));
@@ -3887,9 +4119,7 @@ mod tests {
assert!(rendered.ends_with('\u{2026}'), "truncation is unmarked");
}
- /// A hunk is read as code, so the indentation that says what a line belongs
- /// to survives — but nothing that drives the terminal does, because the
- /// prompt asking about that line sits directly underneath it.
+ /// A hunk is read as code, so the indentation that says what a line belongs to survives — but nothing that drives the terminal does, because the prompt asking about that line sits directly underneath it.
#[test]
fn a_source_line_keeps_its_shape_and_loses_its_control_characters() {
assert_eq!(
@@ -3918,10 +4148,8 @@ mod tests {
);
}
- /// The interactive verdict counts answers, and every noun agrees with the
- /// number in front of it. It closes on the same `(N files scanned)` the
- /// plain `fix` summary ends with: the reader still has to be told how much
- /// was looked at to reach the answers.
+ /// The interactive verdict counts answers, and every noun agrees with the number in front of it.
+ /// It closes on the same `(N files scanned)` the plain `fix` summary ends with: the reader still has to be told how much was looked at to reach the answers.
#[test]
fn the_interactive_summary_pluralizes_both_of_its_nouns() {
assert_eq!(
@@ -3946,10 +4174,7 @@ mod tests {
);
}
- /// A run that was never asked a question says so in the vocabulary the
- /// plain `fix` summary uses for the same answer, and counts the files it
- /// scanned — `Removed 0 of 0 comments in 0 files` named three numbers, none
- /// of which was the one the reader wanted.
+ /// A run that was never asked a question says so in the vocabulary the plain `fix` summary uses for the same answer, and counts the files it scanned — `Removed 0 of 0 comments in 0 files` named three numbers, none of which was the one the reader wanted.
#[test]
fn an_interactive_run_with_nothing_to_offer_borrows_the_fix_wording() {
assert_eq!(
@@ -3968,9 +4193,8 @@ mod tests {
);
}
- /// `q` stops the questions, so the verdict counts the ones that were
- /// answered and says how many were left unasked. Reporting `1 of 9` to a
- /// reader who answered twice would read as seven refusals.
+ /// `q` stops the questions, so the verdict counts the ones that were answered and says how many were left unasked.
+ /// Reporting `1 of 9` to a reader who answered twice would read as seven refusals.
#[test]
fn a_stopped_interactive_run_counts_the_questions_it_asked() {
assert_eq!(
@@ -3995,8 +4219,7 @@ mod tests {
);
}
- /// What a probed tool says about itself gets the preview's treatment: one
- /// line, no control sequences, and no more of it than a preview shows.
+ /// What a probed tool says about itself gets the preview's treatment: one line, no control sequences, and no more of it than a preview shows.
#[test]
fn sanitize_line_replaces_controls_and_caps_the_width() {
assert_eq!(
@@ -4022,14 +4245,10 @@ mod tests {
}
}
-/// The one sentence that says what this project accepts, when every file with a
-/// finding was judged by the same rules.
+/// The one sentence that says what this project accepts, when every file with a finding was judged by the same rules.
///
-/// A report that lists what has to change and never says what would have been
-/// acceptable teaches nothing: the reader fixes these three comments and writes
-/// the fourth the same way. When the files disagree — a `[[overrides]]` table
-/// covering part of the tree — there is no one sentence to write, and none is
-/// written rather than one that is true of some of the findings.
+/// A report that lists what has to change and never says what would have been acceptable teaches nothing: the reader fixes these three comments and writes the fourth the same way.
+/// When the files disagree — a `[[overrides]]` table covering part of the tree — there is no one sentence to write, and none is written rather than one that is true of some of the findings.
fn accepted_here(files: &[ProcessedFile], explanations: &Explanations) -> Option {
let mut rules: Option<&ScanOptions> = None;
for file in files {
@@ -4083,8 +4302,8 @@ fn accepted_here(files: &[ProcessedFile], explanations: &Explanations) -> Option
/// The kinds a policy takes out, named rather than counted.
///
-/// What a report of removals owes its reader is the set it is drawn from. The
-/// kinds no policy reaches are left out: they are not what this run is about,
+/// What a report of removals owes its reader is the set it is drawn from.
+/// The kinds no policy reaches are left out: they are not what this run is about,
/// and naming them would suggest the reader could have to deal with one.
fn removed_kinds(policy: Policy) -> Vec {
CommentKind::ALL
@@ -4118,18 +4337,15 @@ fn join_with(items: &[String], conjunction: &str) -> String {
pub enum Subject {
/// They are, so `ocomment fix` is a way to do what the report asks.
OnDisk,
- /// They are not: a hook judged an edit before it was written. There is no
- /// file to fix, and telling a reader to run `fix` on one would send them
- /// to bytes that do not exist yet.
+ /// They are not: a hook judged an edit before it was written.
+ /// There is no file to fix, and telling a reader to run `fix` on one would send them to bytes that do not exist yet.
Proposed,
}
-/// The whole agent report as one string, or `None` when there is nothing to
-/// say.
+/// The whole agent report as one string, or `None` when there is nothing to say.
///
-/// Silence is the pass. A caller embedding this in a hook decision needs to
-/// know whether there is a decision to make, and a report that says "nothing
-/// to do" is a report the caller has to parse to find that out.
+/// Silence is the pass.
+/// A caller embedding this in a hook decision needs to know whether there is a decision to make, and a report that says "nothing to do" is a report the caller has to parse to find that out.
pub fn agent_report(
files: &[ProcessedFile],
skipped: &[SkippedFile],
@@ -4144,11 +4360,8 @@ pub fn agent_report(
/// The report for a reader that is going to act on it rather than read it.
///
-/// Three parts, in the order they are needed: what has to change, one line per
-/// comment and the verb first; the rule that decided them, so the next comment
-/// is written differently; and the command that would do it instead. A clean
-/// run writes nothing at all, which is what makes this format usable as the
-/// body of a hook decision.
+/// Three parts, in the order they are needed: what has to change, one line per comment and the verb first; the rule that decided them, so the next comment is written differently; and the command that would do it instead.
+/// A clean run writes nothing at all, which is what makes this format usable as the body of a hook decision.
fn render_agent(
output: &mut impl Write,
files: &[ProcessedFile],
@@ -4175,7 +4388,14 @@ fn write_agent(
subject: Subject,
) -> Result<()> {
let groups = crate::advice::plan(files, options.policy);
- let removable: usize = groups.iter().map(crate::advice::Group::comments).sum();
+ /* NOTE: Two numbers, because they ask two different things of the reader.
+ * A removal is a judgement nobody but them can make; a rewrite is one this tool has already made and is offering to apply, and counting the two together told an agent it had twice as much to think about as it did. */
+ let (tidy, removable): (Vec<_>, Vec<_>) = groups
+ .iter()
+ .partition(|group| matches!(group.decision, crate::advice::Decision::Restyle { .. }));
+ let to_tidy: usize = tidy.iter().map(|group| group.comments()).sum();
+ let removable: usize = removable.iter().map(|group| group.comments()).sum();
+ let findings = to_tidy + removable;
let broken: Vec = files
.iter()
.flat_map(|file| {
@@ -4196,10 +4416,8 @@ fn write_agent(
})
.collect();
let unreadable: Vec<&SkippedFile> = skipped.iter().filter(|item| item.error).collect();
- if removable == 0 && broken.is_empty() && unreadable.is_empty() {
- /* NOTE: Silence is the pass, and a caller embedding this in a hook
- * decision reads emptiness rather than parsing a sentence to find out
- * there was nothing to say. */
+ if findings == 0 && broken.is_empty() && unreadable.is_empty() {
+ /* NOTE: Silence is the pass, and a caller embedding this in a hook decision reads emptiness rather than parsing a sentence to find out there was nothing to say. */
return Ok(());
}
@@ -4208,11 +4426,9 @@ fn write_agent(
.flat_map(|group| &group.items)
.map(|item| item.path.as_path())
.collect();
- /* NOTE: The denominator is what was read, not what the walk reached. A
- * reader that cannot re-run the scan has no way to catch a coverage
- * figure that counts the files it skipped, and this is the format whose
- * reader is a program. The skips are named beside it rather than folded
- * into it. */
+ /* NOTE: The denominator is what was read, not what the walk reached.
+ * A reader that cannot re-run the scan has no way to catch a coverage figure that counts the files it skipped, and this is the format whose reader is a program.
+ * The skips are named beside it rather than folded into it. */
let unread = if skipped.is_empty() {
String::new()
} else {
@@ -4221,9 +4437,15 @@ fn write_agent(
plural(skipped.len(), "file")
)
};
+ /* NOTE: The tidy half is named only when there is one, so a report with nothing but removals reads exactly as it did. */
+ let tidy_clause = if to_tidy == 0 {
+ String::new()
+ } else {
+ format!(" and {} this tool can write for you", comments(to_tidy, ""))
+ };
wrote(writeln!(
output,
- "# ocomment: {} to answer for in {} of {} scanned{unread}, policy {}.",
+ "# ocomment: {} to answer for{tidy_clause} in {} of {} scanned{unread}, policy {}.",
comments(removable, ""),
touched.len(),
plural(files.len(), "file"),
@@ -4234,10 +4456,17 @@ fn write_agent(
}
for group in &groups {
+ /* NOTE: The same split the review format makes, in the marker rather than in colour.
+ * A reader told to DECIDE about a reflow would be asked for a judgement that was already made, and the obvious way to answer it is to delete the comment. */
+ let marker = if matches!(group.decision, crate::advice::Decision::Restyle { .. }) {
+ "TIDY"
+ } else {
+ "DECIDE"
+ };
wrote(writeln!(output))?;
wrote(writeln!(
output,
- "DECIDE {} | {}",
+ "{marker} {} | {}",
group.decision.instruction(),
comments(group.comments(), "")
))?;
@@ -4279,54 +4508,63 @@ fn write_agent(
if let Some(rule) = accepted_here(files, explanations) {
wrote(writeln!(output, "# {}", fold(&rule)))?;
}
- /* NOTE: `fix` is offered only for files it could open. Standard input has
- * no name to hand it, and a proposal has no file yet, so the argv would
- * name bytes that are not there. */
+ /* NOTE: `fix` is offered only for files it could open.
+ * Standard input has no name to hand it, and a proposal has no file yet, so the argv would name bytes that are not there. */
let on_disk = subject == Subject::OnDisk
&& touched
.iter()
.all(|path| path.to_string_lossy() != crate::files::STDIN_PATH);
if !on_disk && removable > 0 {
- /* NOTE: A proposal has no file to point an argv at, and naming one
- * would send the reader at bytes that are not there yet. */
+ /* NOTE: A proposal has no file to point an argv at, and naming one would send the reader at bytes that are not there yet. */
wrote(writeln!(
output,
"# these bytes are not on disk yet: write it without them."
))?;
}
- if on_disk && options.operation != Operation::Fix {
+ if on_disk && !options.operation.removes() {
wrote(writeln!(output, "RECHECK {}", argv(&["ocomment", "check"])))?;
- wrote(writeln!(
- output,
- "REMOVE-ALL {} removes {}, including any above that were worth keeping",
- argv(&["ocomment", "fix"]),
- comments(removable, "")
- ))?;
+ /* NOTE: Offered before the blunt one, and only when there is something for it to do.
+ * This is the command that applies every TIDY above and touches no DECIDE, which makes it the one an agent can run without reading the report first. */
+ if to_tidy > 0 {
+ wrote(writeln!(
+ output,
+ "TIDY-ALL {} writes {} and removes nothing",
+ argv(&["ocomment", "fix", "--tidy"]),
+ comments(to_tidy, "")
+ ))?;
+ }
+ if removable > 0 {
+ wrote(writeln!(
+ output,
+ "REMOVE-ALL {} removes {}, including any above that were worth keeping",
+ argv(&["ocomment", "fix"]),
+ comments(removable, "")
+ ))?;
+ }
}
Ok(())
}
/// What the markers in the agent report mean, carried in the report.
///
-/// A machine format that needs its schema fetched from somewhere else is a
-/// format its reader has to go and learn before it can act, and the reader this
-/// is for is one that would rather spend that round trip on the work. Six lines
-/// of preamble buy every one of them back.
-const AGENT_SCHEMA: [&str; 7] = [
+/// A machine format that needs its schema fetched from somewhere else is a format its reader has to go and learn before it can act, and the reader this is for is one that would rather spend that round trip on the work.
+/// Six lines of preamble buy every one of them back.
+const AGENT_SCHEMA: [&str; 9] = [
"Every line starts with a marker. DECIDE opens one question, asked of each",
- "FINDING under it. A FINDING names a path and the first and last line of one",
- "comment, which may span several, and the column when the comment does not",
- "open its line. `-` is what is there now, `+` what would replace it, `=` the",
- "code the comment is about. KEEP names a file and `|` the setting that would",
- "stop the question being asked. BROKEN is a file that did not parse. The",
- "argv lines are commands, ready to run.",
+ "FINDING under it, and only you can answer it. TIDY opens one this tool has",
+ "already answered and is offering to write; TIDY-ALL applies every one of",
+ "them and removes nothing. A FINDING names a path and the first and last line",
+ "of one comment, which may span several, and the column when the comment does",
+ "not open its line. `-` is what is there now, `+` what would replace it, `=`",
+ "the code the comment is about. KEEP names a file and `|` the setting that",
+ "would stop the question being asked. BROKEN is a file that did not parse.",
+ "The argv lines are commands, ready to run.",
];
/// A command as the argv a caller can run without retyping it.
///
-/// Prose loses to a copied array. A path with a space in it, a digest, a flag
-/// whose spelling matters -- each is a chance to get one character wrong, and
-/// the reader most likely to get it wrong is the one reading fastest.
+/// Prose loses to a copied array.
+/// A path with a space in it, a digest, a flag whose spelling matters -- each is a chance to get one character wrong, and the reader most likely to get it wrong is the one reading fastest.
fn argv(words: &[&str]) -> String {
let quoted: Vec = words
.iter()
@@ -4335,15 +4573,10 @@ fn argv(words: &[&str]) -> String {
format!("[{}]", quoted.join(","))
}
-/// The end-of-run summary as one JSON object, whatever `--format` the run
-/// wrote its product in.
+/// The end-of-run summary as one JSON object, whatever `--format` the run wrote its product in.
///
-/// The human summary goes to standard error and the machine formats carry no
-/// summary at all, so a caller that wants the *numbers* — a CI job setting an
-/// output, a dashboard, a script deciding whether to open a pull request — has
-/// had to re-derive them by parsing the product. This is the same count the
-/// run already made, written once, to a file the caller names so it cannot
-/// collide with the product on either stream.
+/// The human summary goes to standard error and the machine formats carry no summary at all, so a caller that wants the *numbers* — a CI job setting an output, a dashboard, a script deciding whether to open a pull request — has had to re-derive them by parsing the product.
+/// This is the same count the run already made, written once, to a file the caller names so it cannot collide with the product on either stream.
pub fn write_summary(
path: &Path,
files: &[ProcessedFile],
@@ -4356,7 +4589,7 @@ pub fn write_summary(
for file in files {
let mut removable = 0usize;
for comment in &file.result.report.comments {
- if comment.disposition.is_remove() {
+ if comment.action().removes() {
removable += 1;
*kinds.entry(comment.kind.as_str()).or_default() += 1;
}
@@ -4365,8 +4598,7 @@ pub fn write_summary(
per_file.push((report_path(&file.path), removable));
}
}
- /* NOTE: Most findings first, then by path, so two runs over the same tree
- * write the same bytes. */
+ /* NOTE: Most findings first, then by path, so two runs over the same tree write the same bytes. */
per_file.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
per_file.truncate(TOP_FILES);
let document = json!({
@@ -4374,11 +4606,12 @@ pub fn write_summary(
"operation": match operation {
Operation::Check => "check",
Operation::Scan => "scan",
- Operation::Diff => "diff",
- Operation::Fix => "fix",
+ Operation::Diff(_) => "diff",
+ Operation::Fix(Writes::Everything) => "fix",
+ Operation::Fix(Writes::RewritesOnly) => "tidy",
},
"files_scanned": summary.files_scanned,
- "files_with_findings": summary.files_with_removable,
+ "files_with_findings": summary.files_with_findings,
"removable_comments": summary.removable_comments,
"kept_comments": summary.kept_comments,
"files_changed": summary.files_changed,
diff --git a/rust/ocomment/src/plugin.rs b/rust/ocomment/src/plugin.rs
index 37779c5..850ac3c 100644
--- a/rust/ocomment/src/plugin.rs
+++ b/rust/ocomment/src/plugin.rs
@@ -75,9 +75,8 @@ impl Drop for TemporaryPath {
/// Offline, prevalidated set of configured scanner components.
///
-/// The engine is only constructed when at least one plugin is enabled. Each
-/// scan gets a fresh store so fuel and memory accounting cannot leak between
-/// files, while parsed components are shared across worker threads.
+/// The engine is only constructed when at least one plugin is enabled.
+/// Each scan gets a fresh store so fuel and memory accounting cannot leak between files, while parsed components are shared across worker threads.
pub struct PluginHost {
runtime: Option,
}
@@ -750,8 +749,8 @@ pub fn update(output: &mut impl Write, root: &Path, selected: Option<&str>) -> R
if !is_remote_source(&plugin.source) {
add(output, root, &plugin.source, Some(&name), None, None)?;
} else {
- /* INVARIANT: The existing signature identity authorizes a freshly fetched
- * artifact. Its new digest is then written to the lockfile. */
+ /* INVARIANT: The existing signature identity authorizes a freshly fetched artifact.
+ * Its new digest is then written to the lockfile. */
install(
output,
root,
@@ -870,9 +869,7 @@ self-contained and return sorted, non-overlapping, non-empty byte spans.
}
/// What each external tool is needed for, in the words the command line uses.
-/// One constant per purpose keeps the four spawn sites and `doctor` naming the
-/// same thing: the line `doctor` prints for a tool that is missing has to be
-/// the line the failure would have printed once something needed it.
+/// One constant per purpose keeps the four spawn sites and `doctor` naming the same thing: the line `doctor` prints for a tool that is missing has to be the line the failure would have printed once something needed it.
pub const HTTPS_SOURCES: &str = "https:// plugin sources";
pub const GH_SOURCES: &str = "gh: plugin sources";
pub const OCI_SOURCES: &str = "oci: plugin sources";
@@ -880,10 +877,8 @@ pub const SIGNATURE_VERIFICATION: &str = "--identity verification";
/// Why a tool OComment shells out to could not be started.
///
-/// The operating system says only "No such file or directory", which names
-/// neither the missing binary nor the part of the run that wanted it. This
-/// says both, and sends the reader to the command that reports every tool at
-/// once instead of making them rediscover the next gap one failure at a time.
+/// The operating system says only "No such file or directory", which names neither the missing binary nor the part of the run that wanted it.
+/// This says both, and sends the reader to the command that reports every tool at once instead of making them rediscover the next gap one failure at a time.
fn missing_tool(tool: &str, purpose: &str) -> String {
format!("cannot run `{tool}` (needed for {purpose}); run `ocomment doctor`")
}
@@ -1332,14 +1327,11 @@ mod tests {
assert!(result.report.comments.is_empty());
}
- /// A tool that is not installed is the most common way a plugin command
- /// fails, and the shell's "No such file or directory" names neither the
- /// binary nor the reason this run wanted it. Every spawn site says both,
+ /// A tool that is not installed is the most common way a plugin command fails, and the shell's "No such file or directory" names neither the binary nor the reason this run wanted it.
+ /// Every spawn site says both,
/// and points at the one command that reports the whole environment.
///
- /// `cosign` runs only after an artifact has already been fetched and
- /// validated, which no offline test can arrange, so its wording is pinned
- /// here rather than through the command line.
+ /// `cosign` runs only after an artifact has already been fetched and validated, which no offline test can arrange, so its wording is pinned here rather than through the command line.
#[test]
fn a_missing_tool_names_itself_its_purpose_and_doctor() {
for (tool, purpose) in [
diff --git a/rust/ocomment/src/ratchet.rs b/rust/ocomment/src/ratchet.rs
index 05f6603..d48fab7 100644
--- a/rust/ocomment/src/ratchet.rs
+++ b/rust/ocomment/src/ratchet.rs
@@ -1,21 +1,15 @@
//! A count that may only fall.
//!
-//! A project with eleven thousand comments and a rule it wants to reach has two
-//! bad options: turn the rule on and fail every commit, or leave it off and
-//! never arrive. A ledger is the third. It records what each file holds today,
-//! fails when a file holds more than that, and fails again when a file holds
-//! fewer — because a ledger that only notices one direction eventually
-//! describes a repository that no longer exists.
+//! A project with eleven thousand comments and a rule it wants to reach has two bad options: turn the rule on and fail every commit, or leave it off and never arrive.
+//! A ledger is the third.
+//! It records what each file holds today,
+//! fails when a file holds more than that, and fails again when a file holds fewer — because a ledger that only notices one direction eventually describes a repository that no longer exists.
//!
-//! That second failure is the one that makes this different from a baseline
-//! file. A baseline forgives what it recorded and says nothing when the work is
-//! done; a ledger asks to be updated, so the number in the file is always the
-//! number in the tree, and the distance left to go is readable at a glance.
+//! That second failure is the one that makes this different from a baseline file.
+//! A baseline forgives what it recorded and says nothing when the work is done; a ledger asks to be updated, so the number in the file is always the number in the tree, and the distance left to go is readable at a glance.
//!
-//! It is deliberately not a suppression mechanism. The entries carry no
-//! reasons, no expiry dates and no per-comment granularity: a ledger is a
-//! measurement, and the moment it starts explaining itself it has become a
-//! second configuration file arguing with the first.
+//! It is deliberately not a suppression mechanism.
+//! The entries carry no reasons, no expiry dates and no per-comment granularity: a ledger is a measurement, and the moment it starts explaining itself it has become a second configuration file arguing with the first.
use crate::output::{Detail, OutputFormat, ProcessedFile, Verbosity, note, plural, stdout, wrote};
use anyhow::{Context, Result};
@@ -35,8 +29,7 @@ pub type Counts = BTreeMap;
pub struct Drift {
/// Files holding more than the ledger allows, with both numbers.
grew: Vec<(String, usize, usize)>,
- /// Files holding fewer, which is progress the ledger has not been told
- /// about.
+ /// Files holding fewer, which is progress the ledger has not been told about.
shrank: Vec<(String, usize, usize)>,
/// Entries naming a file the walk did not reach.
absent: Vec,
@@ -49,11 +42,9 @@ impl Drift {
}
}
-/// Count the removable comments of a run, per file, under the path the report
-/// uses.
+/// Count the removable comments of a run, per file, under the path the report uses.
///
-/// Files with none are absent rather than zero: a ledger of zeroes would grow
-/// with every file added to a clean repository and say nothing.
+/// Files with none are absent rather than zero: a ledger of zeroes would grow with every file added to a clean repository and say nothing.
pub fn count(files: &[ProcessedFile], root: &Path) -> Counts {
let mut counts = Counts::new();
for file in files {
@@ -62,7 +53,7 @@ pub fn count(files: &[ProcessedFile], root: &Path) -> Counts {
.report
.comments
.iter()
- .filter(|comment| comment.disposition.is_remove())
+ .filter(|comment| comment.disposition().action().changes_bytes())
.count();
if removable == 0 {
continue;
@@ -80,8 +71,7 @@ pub fn count(files: &[ProcessedFile], root: &Path) -> Counts {
/// Read a ledger, or an empty one when the file does not exist.
///
-/// A missing ledger is not an error: `ocomment ratchet update` is how the first
-/// one is written, and a run before that has nothing to be held to.
+/// A missing ledger is not an error: `ocomment ratchet update` is how the first one is written, and a run before that has nothing to be held to.
pub fn read(path: &Path) -> Result {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
@@ -236,10 +226,7 @@ pub fn report(
&mut summary,
verbosity,
Detail::Normal,
- /* NOTE: "in N places" rather than "N entries": the regular
- * pluralizer every other count goes through appends an `s`, and
- * the hand-written `entr(ies)` that avoided it printed
- * `1 entr(ies)` for the commonest case of all. */
+ /* NOTE: "in N places" rather than "N entries": the regular pluralizer every other count goes through appends an `s`, and the hand-written `entr(ies)` that avoided it printed `1 entr(ies)` for the commonest case of all. */
&format!(
"The ledger is out of date in {}; \
run `ocomment ratchet update` to record the progress.",
diff --git a/rust/ocomment/src/selftest.rs b/rust/ocomment/src/selftest.rs
index 5b633a9..909ea51 100644
--- a/rust/ocomment/src/selftest.rs
+++ b/rust/ocomment/src/selftest.rs
@@ -1,22 +1,15 @@
//! What this binary can show about itself, on the machine it was installed on.
//!
-//! The test suite proves the source is correct on the machine that ran it. It
-//! says nothing about the artefact somebody downloaded: an archive that lost
-//! bytes, a build for an architecture the project has never run a test on, a
-//! package a distributor patched. Those produce a binary that starts, answers
-//! `--version`, and is wrong.
+//! The test suite proves the source is correct on the machine that ran it.
+//! It says nothing about the artefact somebody downloaded: an archive that lost bytes, a build for an architecture the project has never run a test on, a package a distributor patched.
+//! Those produce a binary that starts, answers `--version`, and is wrong.
//!
//! So the shared corpus travels inside the binary and can be re-run on demand.
-//! `ocomment selftest` scans every case and compares the result against the
-//! expectation recorded with it — the same cases `tools/differential.py` gives
-//! to the OCaml reference and `spec_fixtures.rs` gives to the library, asked of
-//! the executable in the reader's hands.
+//! `ocomment selftest` scans every case and compares the result against the expectation recorded with it — the same cases `tools/differential.py` gives to the OCaml reference and `spec_fixtures.rs` gives to the library, asked of the executable in the reader's hands.
//!
-//! The corpus earns its place here because of what it is. `hazards.json` is not
-//! a set of examples: every case in it is a form that was got wrong once — a
-//! `#` inside a Perl regex, a Swift regex literal that looks like division, a
-//! Rust lifetime that looks like a character. A binary that still gets all of
-//! those right is a binary whose lexer arrived intact.
+//! The corpus earns its place here because of what it is.
+//! `hazards.json` is not a set of examples: every case in it is a form that was got wrong once — a `#` inside a Perl regex, a Swift regex literal that looks like division, a Rust lifetime that looks like a character.
+//! A binary that still gets all of those right is a binary whose lexer arrived intact.
use crate::output::{Detail, OutputFormat, Verbosity, note, stdout, wrote};
use anyhow::{Context, Result};
@@ -29,16 +22,12 @@ use std::{io::Write, str::FromStr};
/// The corpus, embedded so that it is present wherever the binary is.
///
-/// Derived from `spec/fixtures/v1` by `tools/gen_selftest_corpus.py`, which
-/// keeps the input, the options and the recorded result and drops what the
-/// check has no use for -- the prose explaining each case, the diagnostics and
-/// edits of the differential protocol, and the indentation. That is 239 KB
-/// rather than 533 KB, for the same 486 cases.
+/// Derived from `spec/fixtures/v1` by `tools/gen_selftest_corpus.py`, which keeps the input, the options and the recorded result and drops what the check has no use for -- the prose explaining each case, the diagnostics and edits of the differential protocol, and the indentation.
+/// That is 239 KB rather than 533 KB, for the same 486 cases.
///
-/// It is a derivation rather than a second source. `--check` on that script
-/// fails when it no longer matches what `spec/fixtures/v1` would produce,
-/// which is what stops the binary from certifying itself against cases the
-/// project has moved on from.
+/// It is a derivation rather than a second source.
+/// `--check` on that script fails when it no longer matches what `spec/fixtures/v1` would produce,
+/// which is what stops the binary from certifying itself against cases the project has moved on from.
const CORPUS: &str = include_str!("../assets/selftest-corpus.json");
/// One case that did not do what was recorded for it.
@@ -96,10 +85,8 @@ pub fn run(format: OutputFormat, verbosity: Verbosity) -> Result {
}
}
- /* NOTE: A corpus that shrank is a corpus that stopped asking something, and
- * a self-test happily reporting "all 3 cases passed" is the failure this
- * guards against. The floors are the same two `tools/differential.py` and
- * the library test read, so none of the three can be lowered alone. */
+ /* NOTE: A corpus that shrank is a corpus that stopped asking something, and a self-test happily reporting "all 3 cases passed" is the failure this guards against.
+ * The floors are the same two `tools/differential.py` and the library test read, so none of the three can be lowered alone. */
if cases.len() < case_floor {
failures.push(Failure {
id: "".to_owned(),
@@ -109,9 +96,7 @@ pub fn run(format: OutputFormat, verbosity: Verbosity) -> Result {
),
});
}
- /* NOTE: Compared against every case that carries an expectation, not just
- * the ones this binary can reach, because the floor counts what the corpus
- * records rather than what any one runner asks. */
+ /* NOTE: Compared against every case that carries an expectation, not just the ones this binary can reach, because the floor counts what the corpus records rather than what any one runner asks. */
let recorded = checked + out_of_reach;
if recorded < expectation_floor {
failures.push(Failure {
@@ -140,8 +125,7 @@ fn parse_corpus() -> Result {
/// One of the floors recorded beside the corpus and carried with it.
///
-/// They are the same two `tools/differential.py` and the library test read, so
-/// none of the three runners can be lowered on its own.
+/// They are the same two `tools/differential.py` and the library test read, so none of the three runners can be lowered on its own.
fn floor(document: &Value, name: &str) -> Result {
document
.get("floors")
@@ -153,10 +137,8 @@ fn floor(document: &Value, name: &str) -> Result {
/// Run one case through the operation it names.
///
-/// Only the operations a shipped binary can answer for are run. A case built
-/// around a caller-supplied edit list or an externally supplied span is asking
-/// about an API rather than about this executable, and is left to the library
-/// test that can call it.
+/// Only the operations a shipped binary can answer for are run.
+/// A case built around a caller-supplied edit list or an externally supplied span is asking about an API rather than about this executable, and is left to the library test that can call it.
fn execute(case: &Value) -> Result