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"]