diff --git a/CHANGELOG.md b/CHANGELOG.md index fc91b7b0..00b5d8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. - - - +## v0.11.8 - 2026-10-03 +#### Tests +- (**markdown**) accept the line-block terminator on the structure span - (6f3cd8c) - *HaoZeke* +- (**markdown**) keep the paragraph after an unclosed bracket - (037c3de) - *HaoZeke* +- (**markdown**) unclosed bracket math must not swallow the next line - (9223256) - *HaoZeke* +- restore the 42 CLI tests the consolidated binary overwrote - (df6b271) - *HaoZeke* +- the fancyvrb VerbatimInput test is named for what it checks - (9d043bb) - *HaoZeke* +- a nested test runs at the top level, and the clippy lints in the consolidated tests are cleared - (eeb13b4) - *HaoZeke* +#### Buildsystem +- (**release**) the bump moves every pre-commit rev pin, which now live under docs/orgmode - (ed8847a) - *HaoZeke* +- (**release**) a bump moves only the workspace crates in the lockfile - (439519a) - *HaoZeke* +- (**tests**) one integration test binary links the library once - (f0c1e75) - *HaoZeke* +#### Documentation +- (**org**) hang the vale item description under its tag - (9bda344) - *HaoZeke* +- cli reference reflowed where a sentence opens with a number - (918162e) - *HaoZeke* +- keep the 0.11.7 changelog on the released notes - (1029ef1) - *HaoZeke* +#### Bug Fixes +- (**build**) use thin LTO for the release profile - (bf92050) - *HaoZeke* +- (**latex**) keep letter-delimited verb and url spans whole - (dee2151) - *HaoZeke* +- (**latex**) keep pgfplots group and special axes whole - (c8fb922) - *HaoZeke* +- (**latex**) keep breqn dgroup and darray whole - (4ca94ae) - *HaoZeke* +- (**latex**) keep more math and verbatim environments whole - (1e41fb9) - *HaoZeke* +- (**latex**) keep leftover math and filecontents bodies whole - (2af7677) - *HaoZeke* +- (**markdown**) keep mid-line bracket display math whole - (3633660) - *HaoZeke* +- (**markdown**) hang the second sentence of a pandoc example item - (d1a833f) - *HaoZeke* +- (**markdown**) match shortcut labels without lookahead - (45405fd) - *HaoZeke* +- (**markdown**) keep shortcut labels, citations, and dollar math whole - (25d078f) - *HaoZeke* +- (**markdown**) keep pandoc line blocks and example lists whole - (37f3086) - *HaoZeke* +- (**markdown**) keep LaTeX non-prose environments whole - (c22f021) - *HaoZeke* +- (**markdown**) end a bracket math search at a blank line - (3b1e7bd) - *HaoZeke* +- (**markdown**) do not treat a div fence as a definition marker - (c124ffa) - *HaoZeke* +- (**markdown**) keep bracket math and div fences out of prose - (2a93456) - *HaoZeke* +- (**org**) take the item tag through the last separator - (ad67258) - *HaoZeke* +- (**org**) keep list item tags on the item line - (7a13061) - *HaoZeke* +- (**org,rst**) keep continued tags and titles whole - (711f7a8) - *HaoZeke* +- (**pandoc**) shield alltt, BVerbatim, and luacode - (3e207aa) - *HaoZeke* +- (**pandoc**) shield starred verbatim, lstlisting, and minted - (ceb11e5) - *HaoZeke* +- (**plaintext**) do not split a balanced quote on a period - (7a87430) - *HaoZeke* +- (**rst**) hang the indented line after a same-line title - (66651ec) - *HaoZeke* +- (**rst**) keep csv-table title continuations as structure - (2547a67) - *HaoZeke* +- (**rst**) keep directive title arguments on the directive line - (389e864) - *HaoZeke* +- (**sentence**) lowercase academic abbreviations rejoin a number after them - (67b0547) - *HaoZeke* +- (**sentence**) a number opens a sentence, and a lowercase start can be asked for - (e0a7df2) - *HaoZeke* +#### Style +- (**latex**) sort imports with the edition 2024 formatter - (b5de4ca) - *HaoZeke* +- (**org,rst**) wrap the lines rustfmt rejected - (3356281) - *HaoZeke* +- (**pandoc**) wrap the starred-verbatim test input - (1aefe7d) - *HaoZeke* +- (**rst**) keep the title-body fixture on one line - (c325926) - *HaoZeke* +- unwrap_or_else with panic! over expect(&format!) in tests, and two needless borrows - (db7b015) - *HaoZeke* +- clear the clippy lints Rust 1.98 raises in tests and the LaTeX parser - (ee1f49e) - *HaoZeke* + +- - - + ## v0.11.7 - 2026-09-23 #### Bug Fixes - generated pre-commit hook runs under bash so the format loop runs on Ubuntu diff --git a/Cargo.lock b/Cargo.lock index 6a7ca243..0b4ab786 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2221,7 +2221,7 @@ checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "snapper-fmt" -version = "0.11.7" +version = "0.11.8" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 36669c15..2beaddbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "snapper-fmt" -version = "0.11.7" +version = "0.11.8" edition = "2024" autotests = false rust-version = "1.85" diff --git a/README.md b/README.md index 802a4f9f..ea03d62e 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ Configuration guide (org source in-tree): `docs/orgmode/howto/mcp-integration.or ## Pre-commit hook - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.7 + rev: v0.11.8 hooks: - id: snapper diff --git a/cog.toml b/cog.toml index 72983d55..4e758df3 100644 --- a/cog.toml +++ b/cog.toml @@ -11,7 +11,8 @@ pre_bump_hooks = [ # dependency and ship upgrades no test ran against. "cargo update --workspace", "sed -i 's/^release = \".*\"/release = \"{{version}}\"/' docs/source/conf.py", - "sed -i 's/rev: v[0-9][0-9.\\-]*/rev: v{{version}}/' README.md docs/source/howto/vale-integration.rst", + # Only the rev under snapper's own repo line; other hooks keep their pins. + "sed -i '/repo: https:\\/\\/github.com\\/TurtleTech-ehf\\/snapper/{n;s/rev: v[0-9][0-9.\\-]*/rev: v{{version}}/}' README.md readme_src.org docs/orgmode/tutorials/quickstart.org docs/orgmode/howto/vale-integration.org docs/orgmode/howto/ci-enforcement.org", "sed -i 's/\"version\": \".*\"/\"version\": \"{{version}}\"/' editors/vscode/package.json editors/obsidian/package.json editors/obsidian/manifest.json editors/word/package.json packages/snapper-wasm/package.json npm/package.json", ] diff --git a/conda/recipe.yaml b/conda/recipe.yaml index 51270949..4f9e66da 100644 --- a/conda/recipe.yaml +++ b/conda/recipe.yaml @@ -3,7 +3,7 @@ schema_version: 1 context: - version: "0.11.7" + version: "0.11.8" package: name: snapper-fmt diff --git a/docs/orgmode/howto/ci-enforcement.org b/docs/orgmode/howto/ci-enforcement.org index 209a97d6..b8388eaf 100644 --- a/docs/orgmode/howto/ci-enforcement.org +++ b/docs/orgmode/howto/ci-enforcement.org @@ -10,7 +10,7 @@ The easiest way to enforce semantic line breaks in CI. Add to your workflow: #+begin_src yaml -- uses: TurtleTech-ehf/snapper@v0.11.7 +- uses: TurtleTech-ehf/snapper@v0.11.8 with: files: '**/*.org **/*.tex **/*.md' #+end_src @@ -20,7 +20,7 @@ This installs snapper and runs =--check= on the specified files. For GitHub Code Scanning integration (SARIF annotations on PRs): #+begin_src yaml -- uses: TurtleTech-ehf/snapper@v0.11.7 +- uses: TurtleTech-ehf/snapper@v0.11.8 with: files: '**/*.org **/*.tex **/*.md' sarif: 'true' @@ -35,7 +35,7 @@ Add to =.pre-commit-config.yaml=: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.7 + rev: v0.11.8 hooks: - id: snapper #+end_src diff --git a/docs/orgmode/howto/vale-integration.org b/docs/orgmode/howto/vale-integration.org index d6315b39..a684d022 100644 --- a/docs/orgmode/howto/vale-integration.org +++ b/docs/orgmode/howto/vale-integration.org @@ -70,7 +70,7 @@ A typical workflow: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.7 + rev: v0.11.8 hooks: - id: snapper - repo: https://github.com/errata-ai/vale diff --git a/docs/orgmode/tutorials/quickstart.org b/docs/orgmode/tutorials/quickstart.org index 78595c4b..4b0c6781 100644 --- a/docs/orgmode/tutorials/quickstart.org +++ b/docs/orgmode/tutorials/quickstart.org @@ -232,7 +232,7 @@ Add to your =.pre-commit-config.yaml=: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.7 + rev: v0.11.8 hooks: - id: snapper #+end_src diff --git a/docs/source/conf.py b/docs/source/conf.py index 140e42b7..d9a2e77b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,7 +3,7 @@ project = "snapper" copyright = '2026--present, Rohit Goswami' author = "Rohit Goswami" -release = "0.11.7" +release = "0.11.8" html_logo = "../../branding/logo/snapper_logo.png" extensions = [ diff --git a/editors/obsidian/manifest.json b/editors/obsidian/manifest.json index d944b987..e7fabfbd 100644 --- a/editors/obsidian/manifest.json +++ b/editors/obsidian/manifest.json @@ -1,7 +1,7 @@ { "id": "snapper", "name": "Snapper - Semantic Line Breaks", - "version": "0.11.7", + "version": "0.11.8", "minAppVersion": "1.0.0", "description": "Format prose with semantic line breaks for clean git diffs. Supports Org-mode, LaTeX, Markdown, and plaintext.", "author": "TurtleTech", diff --git a/editors/obsidian/package-lock.json b/editors/obsidian/package-lock.json index ec95ae59..f75fce2c 100644 --- a/editors/obsidian/package-lock.json +++ b/editors/obsidian/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-snapper", - "version": "0.11.7", + "version": "0.11.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-snapper", - "version": "0.11.7", + "version": "0.11.8", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -17,7 +17,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.7", + "version": "0.11.8", "dev": true, "license": "MIT", "devDependencies": { diff --git a/editors/obsidian/package.json b/editors/obsidian/package.json index 3cf5cb02..d4aa39f5 100644 --- a/editors/obsidian/package.json +++ b/editors/obsidian/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-snapper", - "version": "0.11.7", + "version": "0.11.8", "private": true, "description": "Obsidian plugin for snapper semantic line break formatter", "scripts": { diff --git a/editors/vscode/README.md b/editors/vscode/README.md index df1ea77a..139e1cd5 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,9 +1,9 @@ # snapper - Semantic Line Breaks -Requires the **snapper** / **snapper-fmt** CLI **0.11.7+** on your PATH (or set `snapper.path`). +Requires the **snapper** / **snapper-fmt** CLI **0.11.8+** on your PATH (or set `snapper.path`). The crate is `snapper-fmt`; installers ship both names. If [openSUSE snapper](https://github.com/openSUSE/snapper) already owns `/usr/bin/snapper`, call `snapper-fmt` or set `snapper.path`. -Install: `cargo install snapper-fmt` or the [release installer](https://github.com/TurtleTech-ehf/snapper/releases/tag/v0.11.7). +Install: `cargo install snapper-fmt` or the [release installer](https://github.com/TurtleTech-ehf/snapper/releases/tag/v0.11.8). Format prose so each sentence occupies its own line, producing clean git diffs for collaborative writing. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 11f52ff5..221a2fbe 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "snapper", "displayName": "snapper - Semantic Line Breaks", "description": "Format prose with semantic line breaks for clean git diffs", - "version": "0.11.7", + "version": "0.11.8", "publisher": "TurtleTech", "license": "MIT", "repository": { diff --git a/editors/word/README.md b/editors/word/README.md index 6c23fe6d..750ddd0b 100644 --- a/editors/word/README.md +++ b/editors/word/README.md @@ -2,7 +2,7 @@ Office add-in that formats document prose with **semantic line breaks** (one sentence per line) using the **snapper WASM** build (`@snapper/wasm`). Useful when drafting in Word and exporting to Org, Markdown, or LaTeX for git-friendly diffs. -**Version:** 0.11.7 (tracks snapper-fmt) +**Version:** 0.11.8 (tracks snapper-fmt) Development preview; not published in AppSource. @@ -34,7 +34,7 @@ CI builds the Word add-in in `.github/workflows/wasm.yml` (`build-word` job). ## Relationship to the CLI -Word uses the **plaintext** format path in WASM. For Org/LaTeX fidelity, prefer the CLI or VS Code extension (LSP). Delimiter-span and abbreviation behavior matches the current WASM API (requires snapper **0.11.7+**). +Word uses the **plaintext** format path in WASM. For Org/LaTeX fidelity, prefer the CLI or VS Code extension (LSP). Delimiter-span and abbreviation behavior matches the current WASM API (requires snapper **0.11.8+**). ## License diff --git a/editors/word/manifest.xml b/editors/word/manifest.xml index d4a342bf..585e47d5 100644 --- a/editors/word/manifest.xml +++ b/editors/word/manifest.xml @@ -5,7 +5,7 @@ xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xsi:type="TaskPaneApp"> a8f3c2e1-9b4d-4e7a-8c5f-1d2e3f4a5b6c - 0.11.7 + 0.11.8 TurtleTech ehf en-US diff --git a/editors/word/package-lock.json b/editors/word/package-lock.json index 8f706653..91d281b8 100644 --- a/editors/word/package-lock.json +++ b/editors/word/package-lock.json @@ -1,12 +1,12 @@ { "name": "word-snapper", - "version": "0.11.7", + "version": "0.11.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "word-snapper", - "version": "0.11.7", + "version": "0.11.8", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -21,7 +21,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.7", + "version": "0.11.8", "dev": true, "license": "MIT", "devDependencies": { diff --git a/editors/word/package.json b/editors/word/package.json index f0292532..54b8e046 100644 --- a/editors/word/package.json +++ b/editors/word/package.json @@ -1,6 +1,6 @@ { "name": "word-snapper", - "version": "0.11.7", + "version": "0.11.8", "private": true, "description": "Microsoft Word add-in for snapper semantic line break formatter", "scripts": { diff --git a/editors/word/src/taskpane/taskpane.html b/editors/word/src/taskpane/taskpane.html index 6919521f..4a740f69 100644 --- a/editors/word/src/taskpane/taskpane.html +++ b/editors/word/src/taskpane/taskpane.html @@ -10,7 +10,7 @@

Snapper

-

Semantic line breaks for Word (v0.11.7)

+

Semantic line breaks for Word (v0.11.8)

Formats paragraphs as plaintext. Use the CLI or VS Code for Org/LaTeX structure awareness.

diff --git a/npm/package.json b/npm/package.json index d5a5adb5..cba347f3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@turtletech/snapper-mcp", - "version": "0.11.7", + "version": "0.11.8", "private": true, "description": "MCP server for snapper semantic line break formatter", "main": "bin/run.js", diff --git a/packages/snapper-wasm/package-lock.json b/packages/snapper-wasm/package-lock.json index 7687340a..a1742945 100644 --- a/packages/snapper-wasm/package-lock.json +++ b/packages/snapper-wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@snapper/wasm", - "version": "0.11.7", + "version": "0.11.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snapper/wasm", - "version": "0.11.7", + "version": "0.11.8", "license": "MIT", "devDependencies": { "typescript": "^5.4" diff --git a/packages/snapper-wasm/package.json b/packages/snapper-wasm/package.json index c0f5143c..aee68ac5 100644 --- a/packages/snapper-wasm/package.json +++ b/packages/snapper-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@snapper/wasm", - "version": "0.11.7", + "version": "0.11.8", "description": "WebAssembly build of snapper semantic line break formatter", "type": "module", "main": "dist/index.js", diff --git a/readme_src.org b/readme_src.org index 76a02a30..e8e7a301 100644 --- a/readme_src.org +++ b/readme_src.org @@ -157,7 +157,7 @@ Configuration guide (org source in-tree): =docs/orgmode/howto/mcp-integration.or :END: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.7 + rev: v0.11.8 hooks: - id: snapper #+end_src diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs new file mode 100644 index 00000000..6e2faa91 --- /dev/null +++ b/tests/cli_integration.rs @@ -0,0 +1,622 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +fn snapper_binary() -> Command { + Command::new(env!("CARGO_BIN_EXE_snapper")) +} + +fn run_format(format: &str, input_path: &str) -> String { + let output = snapper_binary() + .args(["--native", "--format", format, input_path]) + .output() + .expect("failed to run snapper"); + assert!( + output.status.success(), + "snapper failed: {:?}", + output.stderr + ); + String::from_utf8(output.stdout).expect("invalid utf8") +} + +fn pipe_stdin(input: &str, args: &[&str]) -> std::process::Output { + let mut cmd = snapper_binary(); + cmd.arg("--native"); + cmd.args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().expect("failed to spawn snapper"); + { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + } + child.wait_with_output().expect("failed to wait") +} + +fn pipe_stdin_in_dir(input: &str, args: &[&str], dir: &Path) -> std::process::Output { + let mut cmd = snapper_binary(); + cmd.arg("--native"); + cmd.args(args) + .current_dir(dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().expect("failed to spawn snapper"); + { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + } + child.wait_with_output().expect("failed to wait") +} + +fn fixture_path(name: &str) -> String { + let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + base.join(name).to_string_lossy().to_string() +} + +// ---- EXISTING TESTS ---- + +#[test] +fn org_format() { + let actual = run_format("org", &fixture_path("sample.org")); + let expected = fs::read_to_string(fixture_path("expected.org")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn plaintext_format() { + let actual = run_format("plaintext", &fixture_path("sample.txt")); + let expected = fs::read_to_string(fixture_path("expected.txt")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_format() { + let actual = run_format("latex", &fixture_path("sample.tex")); + let expected = fs::read_to_string(fixture_path("expected.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_filecontents_fixture_stays_verbatim() { + let actual = run_format("latex", &fixture_path("filecontents.tex")); + let expected = fs::read_to_string(fixture_path("filecontents.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_pycode_fixture_stays_verbatim() { + let actual = run_format("latex", &fixture_path("pycode.tex")); + let expected = fs::read_to_string(fixture_path("pycode.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_verbatim_fixture_stays_verbatim() { + let actual = run_format("latex", &fixture_path("verbatim.tex")); + let expected = fs::read_to_string(fixture_path("verbatim.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_tikzcd_fixture_stays_structure() { + let actual = run_format("latex", &fixture_path("tikzcd.tex")); + let expected = fs::read_to_string(fixture_path("tikzcd.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_comment_fixture_stays_verbatim() { + let actual = run_format("latex", &fixture_path("comment.tex")); + let expected = fs::read_to_string(fixture_path("comment.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn latex_iffalse_fixture_stays_verbatim() { + let actual = run_format("latex", &fixture_path("iffalse.tex")); + let expected = fs::read_to_string(fixture_path("iffalse.tex")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn markdown_format() { + let actual = run_format("markdown", &fixture_path("sample.md")); + let expected = fs::read_to_string(fixture_path("expected.md")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn idempotent_org() { + let first = run_format("org", &fixture_path("sample.org")); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("org", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "org reflow must be idempotent"); +} + +#[test] +fn mixed_list_org_hangs_and_is_idempotent() { + let first = run_format("org", &fixture_path("mixed_list.org")); + assert!( + first.contains("- One.\n Two.\n"), + "org dash continuation must hang: {first}" + ); + assert!( + first.contains( + "1. Numbered one.\n Numbered two.\n - Nested dash.\n Nested second.\n" + ), + "org nested list must hang and stay two items: {first}" + ); + assert!( + first.contains(" 1. Nested numbered.\n Nested numbered two.\n"), + "org nested ordered continuation must hang: {first}" + ); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("org", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "mixed_list.org must be idempotent"); +} + +#[test] +fn mixed_list_markdown_hangs_and_is_idempotent() { + let first = run_format("markdown", &fixture_path("mixed_list.md")); + assert!( + first.contains("- One.\n Two.\n"), + "markdown dash continuation must hang: {first}" + ); + assert!( + first.contains( + "1. Numbered one.\n Numbered two.\n - Nested dash.\n Nested second.\n" + ), + "markdown nested list must hang and stay two items: {first}" + ); + assert!( + first.contains("> Quoted one.\n> Quoted two.\n"), + "markdown quote continuation must repeat the prefix: {first}" + ); + assert!( + first.contains("> > Nested one.\n> > Nested two.\n"), + "nested markdown quote must keep the full prefix: {first}" + ); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("markdown", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "mixed_list.md must be idempotent"); +} + +#[test] +fn idempotent_plaintext() { + let first = run_format("plaintext", &fixture_path("sample.txt")); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("plaintext", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "plaintext reflow must be idempotent"); +} + +#[test] +fn idempotent_latex() { + let first = run_format("latex", &fixture_path("sample.tex")); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("latex", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "latex reflow must be idempotent"); +} + +#[test] +fn idempotent_markdown() { + let first = run_format("markdown", &fixture_path("sample.md")); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("markdown", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "markdown reflow must be idempotent"); +} + +#[test] +fn check_mode_passes_on_formatted() { + let output = snapper_binary() + .args([ + "--native", + "--check", + "--format", + "org", + &fixture_path("expected.org"), + ]) + .output() + .expect("failed to run snapper"); + assert!( + output.status.success(), + "--check should pass on already-formatted file" + ); +} + +#[test] +fn check_mode_fails_on_unformatted() { + let output = snapper_binary() + .args([ + "--native", + "--check", + "--format", + "org", + &fixture_path("sample.org"), + ]) + .output() + .expect("failed to run snapper"); + assert!( + !output.status.success(), + "--check should fail on unformatted file" + ); +} + +#[test] +fn stdin_stdout() { + let input = "Hello world. This is a test. Another sentence."; + let out = pipe_stdin(input, &[]); + let result = String::from_utf8(out.stdout).unwrap(); + assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence."); +} + +// ---- FEATURE 1: --stdin-filepath ---- + +#[test] +fn stdin_filepath_auto_detects_org() { + let input = "#+TITLE: Test\n\nFirst sentence. Second sentence.\n"; + let out = pipe_stdin(input, &["--stdin-filepath", "paper.org"]); + assert!(out.status.success()); + let result = String::from_utf8(out.stdout).unwrap(); + // Should detect org format and preserve #+TITLE as structure + assert!(result.starts_with("#+TITLE: Test\n")); + assert!(result.contains("First sentence.\n")); + assert!(result.contains("Second sentence.\n")); +} + +#[test] +fn stdin_filepath_detects_latex() { + // .tex extension should trigger LaTeX parser (preamble preserved) + let input = + "\\documentclass{article}\n\\begin{document}\nHello world. Second.\n\\end{document}\n"; + let out = pipe_stdin(input, &["--stdin-filepath", "paper.tex"]); + assert!(out.status.success()); + let result = String::from_utf8(out.stdout).unwrap(); + assert!(result.contains("\\documentclass{article}")); + assert!(result.contains("Hello world.\n")); +} + +#[test] +fn format_flag_overrides_stdin_filepath() { + // --format should take priority over --stdin-filepath + let input = "Hello world. Second sentence.\n"; + let out = pipe_stdin( + input, + &["--format", "plaintext", "--stdin-filepath", "paper.org"], + ); + assert!(out.status.success()); + let result = String::from_utf8(out.stdout).unwrap(); + assert_eq!(result, "Hello world.\nSecond sentence.\n"); +} + +// ---- FEATURE 2: snapper:off / snapper:on pragmas ---- + +#[test] +fn pragma_off_on_org() { + let input = + "First. Second.\n# snapper:off\nDo not. Touch this.\n# snapper:on\nThird. Fourth.\n"; + let out = pipe_stdin(input, &["--format", "org"]); + let result = String::from_utf8(out.stdout).unwrap(); + assert!(result.contains("First.\nSecond.\n")); + assert!(result.contains("Do not. Touch this.\n")); // untouched + assert!(result.contains("Third.\nFourth.\n")); +} + +#[test] +fn pragma_off_on_latex() { + let input = "\\begin{document}\nHello world. Goodbye world.\n% snapper:off\nKeep this. Exactly here.\n% snapper:on\nFinal thing. Last sentence.\n\\end{document}\n"; + let out = pipe_stdin(input, &["--format", "latex"]); + let result = String::from_utf8(out.stdout).unwrap(); + assert!(result.contains("Hello world.\nGoodbye world.\n")); + assert!(result.contains("Keep this. Exactly here.\n")); // untouched + assert!(result.contains("Final thing.\nLast sentence.\n")); +} + +#[test] +fn pragma_off_on_markdown() { + let input = "Hello world. Goodbye world.\n\nKeep this. Exactly here.\n\nFinal thing. Last sentence.\n"; + let out = pipe_stdin(input, &["--format", "markdown"]); + let result = String::from_utf8(out.stdout).unwrap(); + assert!(result.contains("Hello world.\nGoodbye world.\n")); + assert!(result.contains("Keep this. Exactly here.\n")); + assert!(result.contains("Final thing.\nLast sentence.")); +} + +#[test] +fn pragma_off_on_plaintext() { + let input = "Hello world. Goodbye world.\nsnapper:off\nKeep this. Exactly here.\nsnapper:on\nFinal thing. Last sentence.\n"; + let out = pipe_stdin(input, &["--format", "plaintext"]); + let result = String::from_utf8(out.stdout).unwrap(); + assert!(result.contains("Hello world.\nGoodbye world.\n")); + assert!(result.contains("Keep this. Exactly here.\n")); + assert!(result.contains("Final thing.\nLast sentence.\n")); +} + +// ---- FEATURE 5: --range ---- + +#[test] +fn range_formats_only_specified_lines() { + let input = "Line one. Stay same.\nLine two. Should split. Into two.\nLine three. Stay same.\n"; + let out = pipe_stdin(input, &["--range", "2:2"]); + let result = String::from_utf8(out.stdout).unwrap(); + // Line 1 unchanged + assert!(result.starts_with("Line one. Stay same.\n")); + // Line 2 split + assert!(result.contains("Line two.\nShould split.\nInto two.\n")); + // Line 3 unchanged + assert!(result.ends_with("Line three. Stay same.\n")); +} + +#[test] +fn range_preserves_outside_lines_exactly() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + tmp.path(), + "Keep. This line.\nReformat. This line. Please.\nKeep. This too.\n", + ) + .unwrap(); + let output = snapper_binary() + .args([ + "--native", + "--format", + "plaintext", + "--range", + "2:2", + &tmp.path().to_string_lossy(), + ]) + .output() + .expect("failed to run"); + let result = String::from_utf8(output.stdout).unwrap(); + assert!(result.starts_with("Keep. This line.\n")); + assert!(result.contains("Reformat.\nThis line.\nPlease.\n")); + assert!(result.ends_with("Keep. This too.\n")); +} + +// ---- FEATURE 6: --output-format json/sarif ---- + +#[test] +fn check_json_output() { + let output = snapper_binary() + .args([ + "--native", + "--check", + "--output-format", + "json", + "--format", + "org", + &fixture_path("sample.org"), + ]) + .output() + .expect("failed to run"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + assert!(parsed.is_array()); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert!(arr[0]["file"].as_str().unwrap().contains("sample.org")); +} + +#[test] +fn check_sarif_output() { + let output = snapper_binary() + .args([ + "--native", + "--check", + "--output-format", + "sarif", + "--format", + "org", + &fixture_path("sample.org"), + ]) + .output() + .expect("failed to run"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("invalid SARIF"); + assert_eq!(parsed["version"], "2.1.0"); + let results = &parsed["runs"][0]["results"]; + assert!(results.is_array()); + assert!(!results.as_array().unwrap().is_empty()); +} + +// ---- Golden master: edge cases ---- + +#[test] +fn edge_cases_org() { + let actual = run_format("org", &fixture_path("edge_cases.org")); + let expected = fs::read_to_string(fixture_path("expected_edge_cases.org")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn edge_cases_plaintext() { + let actual = run_format("plaintext", &fixture_path("edge_cases.txt")); + let expected = fs::read_to_string(fixture_path("expected_edge_cases.txt")).unwrap(); + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] +fn idempotent_edge_cases_org() { + let first = run_format("org", &fixture_path("edge_cases.org")); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("org", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!(first, second, "edge cases org reflow must be idempotent"); +} + +#[test] +fn idempotent_edge_cases_plaintext() { + let first = run_format("plaintext", &fixture_path("edge_cases.txt")); + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), &first).unwrap(); + let second = run_format("plaintext", &tmp.path().to_string_lossy()); + pretty_assertions::assert_eq!( + first, + second, + "edge cases plaintext reflow must be idempotent" + ); +} + +// ---- FEATURE 7: snapper init ---- + +#[test] +fn init_dry_run_shows_config() { + let output = snapper_binary() + .args(["init", "--dry-run"]) + .output() + .expect("failed to run"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains(".snapperrc.toml")); + assert!(stderr.contains("format =")); + assert!(stderr.contains("pre-commit")); + assert!(stderr.contains("apheleia")); +} + +#[test] +fn config_lang_applies_to_stdin() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".snapperrc.toml"), "lang = \"de\"\n").unwrap(); + let out = pipe_stdin_in_dir("z.B. Das ist ein Satz. Noch einer.\n", &[], dir.path()); + assert!(out.status.success()); + let result = String::from_utf8(out.stdout).unwrap(); + assert_eq!(result, "z.B. Das ist ein Satz.\nNoch einer.\n"); +} + +#[test] +fn config_default_format_applies_to_stdin() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".snapperrc.toml"), "format = \"org\"\n").unwrap(); + let out = pipe_stdin_in_dir("* Heading\nSentence one. Sentence two.\n", &[], dir.path()); + assert!(out.status.success()); + let result = String::from_utf8(out.stdout).unwrap(); + assert!(result.starts_with("* Heading\n")); + assert!(result.contains("Sentence one.\n")); + assert!(result.contains("Sentence two.\n")); +} + +#[test] +fn config_ignore_skips_check_mode() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".snapperrc.toml"), "ignore = [\"*.md\"]\n").unwrap(); + fs::write(dir.path().join("draft.md"), "Sentence one. Sentence two.\n").unwrap(); + let output = snapper_binary() + .current_dir(dir.path()) + .args(["--native", "--check", "draft.md"]) + .output() + .expect("failed to run snapper"); + assert!(output.status.success()); + assert!(String::from_utf8(output.stderr).unwrap().is_empty()); +} + +#[test] +fn config_per_format_width_applies() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(".snapperrc.toml"), + "max_width = 80\n[plaintext]\nmax_width = 12\n", + ) + .unwrap(); + fs::write( + dir.path().join("draft.txt"), + "Alpha beta gamma delta epsilon zeta.\n", + ) + .unwrap(); + let output = snapper_binary() + .current_dir(dir.path()) + .args(["--native", "draft.txt"]) + .output() + .expect("failed to run snapper"); + assert!(output.status.success()); + let result = String::from_utf8(output.stdout).unwrap(); + assert_eq!(result, "Alpha beta\ngamma delta\nepsilon\nzeta.\n"); +} + +#[test] +fn config_latex_envs_and_commands_apply() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(".snapperrc.toml"), + "[latex]\nverbatim_envs = [\"Verbatim\"]\nstructure_envs = [\"algorithm\", \"comment\"]\nverbatim_commands = [\"Verb\"]\n", + ) + .unwrap(); + let input = "\\begin{document}\n\\begin{algorithm}\nFirst step. Second step.\n\\end{algorithm}\nUse \\Verb|a.b! c| here. Next sentence.\n\\end{document}\n"; + let out = pipe_stdin_in_dir(input, &["--format", "latex"], dir.path()); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let result = String::from_utf8(out.stdout).unwrap(); + assert!( + result.contains("First step. Second step."), + "algorithm body must not reflow, got:\n{result}" + ); + assert!( + !result.contains("First step.\nSecond step."), + "algorithm must stay one source line, got:\n{result}" + ); + assert!( + result.contains("Use \\Verb|a.b! c| here.\nNext sentence."), + "configured Verb must tokenize like verb, got:\n{result}" + ); +} + +#[test] +fn markdown_numbered_atx_heading_not_split() { + // snapper-25kc / rtrash README regression + let input = + "### 1. `cargo binstall` (preferred binary install)\n\nBody one. Body two more words.\n"; + let out = pipe_stdin(input, &["--format", "markdown"]); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let result = String::from_utf8(out.stdout).unwrap(); + assert!( + result.starts_with("### 1. `cargo binstall` (preferred binary install)\n"), + "heading must stay one line, got:\n{result}" + ); + assert!( + !result.contains("### 1.\n`"), + "must not orphan ### 1. before title:\n{result}" + ); +} + +#[test] +fn markdown_setext_heading_not_collapsed() { + let input = "Setext Title With Period. Still Title\n=====================================\n\nBody after. Second body.\n"; + let out = pipe_stdin(input, &["--format", "markdown"]); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let result = String::from_utf8(out.stdout).unwrap(); + assert!( + result.starts_with( + "Setext Title With Period. Still Title\n=====================================\n" + ), + "setext title+underline must stay intact, got:\n{result}" + ); + assert!( + !result.contains("Still Title ====="), + "must not glue underline onto title:\n{result}" + ); +} diff --git a/tests/integration.rs b/tests/integration.rs index 6b10bd09..91337ac4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -6,6 +6,8 @@ #[path = "check_line_diagnostics.rs"] mod check_line_diagnostics; +#[path = "cli_integration.rs"] +mod cli_integration; #[path = "code_block_format.rs"] mod code_block_format; #[path = "code_block_reflow.rs"]