diff --git a/CHANGELOG.md b/CHANGELOG.md index 3002973..fc91b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,40 @@ All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. - - - -## Unreleased (main) +## v0.11.7 - 2026-09-23 +#### Bug Fixes +- generated pre-commit hook runs under bash so the format loop runs on Ubuntu +- leftover LaTeX yquant body stays non-prose +- leftover LaTeX quantikz body stays non-prose +- leftover LaTeX numcases body stays non-prose +- leftover LaTeX intertext argument stays prose +- leftover LaTeX empheq body stays non-prose +- leftover LaTeX prooftree body stays non-prose +- leftover LaTeX CD diagram body stays non-prose +- leftover LaTeX subarray body stays non-prose +- leftover LaTeX xalignat body stays non-prose +- leftover LaTeX smallmatrix body stays non-prose +- leftover markdown hang-plus-four line stays in an open list paragraph +- leftover org nested item returns to the parent list +- leftover org item ends at the bullet column +- leftover LaTeX verb with an internal space stays one wrap token +- leftover markdown HTML type 6 and 7 blocks run to a blank line +- leftover markdown footnote continuation keeps an ATX heading +- leftover markdown list marker remainder stays blocks +- leftover org plain list ends on two blanks +- leftover RST anonymous target keeps its indented link block +- leftover bare colon paragraph opens an RST literal block +- leftover org ordered marker past nine digits skip-cut +- leftover short markdown setext underline is escaped +- leftover wrap-created LaTeX sectioning command skip-cut +- leftover org item bullet allows a tab +- leftover markdown closing fence with a non-space tail stays inside the code block +- leftover wrap-created RST plus-fragment skip-cut +- leftover wrap-created RST jinja statement and anonymous target skip-cut +- leftover wrap-created RST grid-table top skip-cut +- leftover type-6 void HTML (`param`/`track`) does not swallow following prose +- leftover wrap-created RST simple-table border skip-cut +- leftover org bibtex/eshell/w3m plain links hang and wrap - - - diff --git a/Cargo.lock b/Cargo.lock index 4ebb597..6a7ca24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2221,7 +2221,7 @@ checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "snapper-fmt" -version = "0.11.6" +version = "0.11.7" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8c69811..d0e3008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "snapper-fmt" -version = "0.11.6" +version = "0.11.7" edition = "2024" rust-version = "1.85" description = "Semantic line break formatter for Org, LaTeX, Markdown, RST, and plaintext" diff --git a/README.md b/README.md index 3f07847..802a4f9 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.6 + rev: v0.11.7 hooks: - id: snapper diff --git a/conda/recipe.yaml b/conda/recipe.yaml index 1fb3702..5127094 100644 --- a/conda/recipe.yaml +++ b/conda/recipe.yaml @@ -3,7 +3,7 @@ schema_version: 1 context: - version: "0.11.6" + version: "0.11.7" package: name: snapper-fmt diff --git a/docs/orgmode/howto/ci-enforcement.org b/docs/orgmode/howto/ci-enforcement.org index bd59c5b..209a97d 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.6 +- uses: TurtleTech-ehf/snapper@v0.11.7 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.6 +- uses: TurtleTech-ehf/snapper@v0.11.7 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.6 + rev: v0.11.7 hooks: - id: snapper #+end_src diff --git a/docs/orgmode/howto/git-filter.org b/docs/orgmode/howto/git-filter.org index 500b0b5..9689468 100644 --- a/docs/orgmode/howto/git-filter.org +++ b/docs/orgmode/howto/git-filter.org @@ -14,7 +14,8 @@ This makes semantic line breaks transparent to collaborators who do not use snap :CUSTOM_ID: setup :END: -Configure the filter in your local git config: +=snapper init= writes this filter into the local git config as =snapper-fmt --native --stdin-filepath %f= (extension auto-detect; =snapper-fmt= avoids openSUSE's Btrfs =snapper=) plus a working pre-commit hook. +To configure it by hand: #+begin_src bash git config filter.snapper.clean "snapper --native --format org" diff --git a/docs/orgmode/howto/vale-integration.org b/docs/orgmode/howto/vale-integration.org index f165e24..65905ff 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.6 + rev: v0.11.7 hooks: - id: snapper - repo: https://github.com/errata-ai/vale diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org index 2eed746..2039453 100644 --- a/docs/orgmode/reference/cli.org +++ b/docs/orgmode/reference/cli.org @@ -255,10 +255,13 @@ Detects which prose formats exist in the directory tree and generates: - =.snapperrc.toml= with sensible defaults - =.gitattributes= entries for the git smudge/clean filter -- pre-commit hook snippet +- a working =.git/hooks/pre-commit= (native parsers, prefers =snapper-fmt= so openSUSE Btrfs =snapper= does not collide) when run inside a git repository +- =filter.snapper.clean= / =smudge= in the local git config (=snapper-fmt --native --stdin-filepath %f=) so the attributes filter actually runs +- pre-commit framework snippet - Apheleia (Emacs) configuration snippet Use =--dry-run= to preview without writing files. +An existing pre-commit hook that was not generated by snapper is left alone. *** =snapper sdiff = :PROPERTIES: diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index b65c41e..37bdd50 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -32,7 +32,12 @@ The classification depends on the format. - Comment lines (starting with =#= but not =#+= ) - Column-0 diary-sexp lines (=%%(...)=; org-element-diary-sexp-parser) - Full headline lines (stars, optional TODO keyword, and title text) -- List item markers (=-=, =+=, =1.=); continuation sentences hang at the marker width +- List item markers (=-=, =+=, =1.=, including a tab after the bullet). + An ordered marker is one or more digits. A wrap cut that would park it at column 0 skip-cuts the token onto the previous line. + Continuation sentences hang at the marker width. + One blank line stays inside the item. Two blank lines end the plain list. + An item also ends at a line indented less than or equal to its bullet, so a two-space line stays in a column-0 item. + A line indented like a child bullet ends that child and stays in the parent - LaTeX environments (=\begin{equation}= ... =\end{equation}=, =\begin{align}=, etc.). An unmatched =\begin{env}= is a paragraph (org-element latex-environment-parser) - Display math (=\[= ... =\]=) - Line breaks (org-element-line-break-parser: =\\= plus optional spaces or tabs at end of line) @@ -90,7 +95,8 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, cases, numcases, array, subarray, CD, prooftree, quantikz, yquant, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). + =\\intertext= and =\\shortintertext= arguments inside those displays stay prose - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) @@ -98,7 +104,8 @@ These tokens within prose are not split across lines: - =\iffalse= through =\fi= (tree-sitter =block_comment=) - Comment lines (starting with =%=) - =\end{document}= -- Full sectioning command lines (=\section{...}=, =\subsection{...}=, and friends, including title text) +- Full sectioning command lines (=\section{...}=, =\subsection{...}=, KOMA =\addsec= / =\addchap= / =\addpart=, including an optional short title and the title text). + A wrap cut that would park one of those commands at column 0 skip-cuts the token onto the previous line *** Code regions (=minted=, =lstlisting=, =verbatim=, =comment=, =Piton=) :PROPERTIES: @@ -136,7 +143,8 @@ These tokens within prose are not split across lines: - Extra names from =[latex].verbatim_envs= are code regions too - Body follows the same comment-reflow and optional =--format-code= rules as other formats when language is known (=minted= / =minted*= language arg, =lstlisting= / =lstlisting*= =language== option) - Inline leftover =\verb= / =\verb*= / leftover =\lstinline= / leftover =\spverb= / leftover =\mintinline= / leftover =\mint= / leftover fancyvrb =\Verb= / =\Verb*= / leftover =\SaveVerb= / leftover =\UseVerb= / leftover =\UseVerbatim= / leftover =\LUseVerbatim= / leftover =\BUseVerbatim= / leftover =\DefineShortVerb= / =\UndefineShortVerb= / leftover fvextra =\EscVerb= / leftover fvextra =\VerbatimInsertBuffer= / =\VerbatimClearBuffer= / =\InsertBuffer= / =\IterateBuffer= / =\VerbatimInput= / =\BVerbatimInput= / =\LVerbatimInput= / piton.sty leftover =\piton= / =\PitonInputFile= / =\PitonInputFileT= / =\PitonInputFileF= / =\PitonInputFileTF= / listings.sty =\lstinputlisting= / minted.sty =\inputminted= / tools/verbatim.sty =\verbatiminput= / tcolorbox =\tcbinputlisting= / pythontex.sty =\inputpy= / =\inputpycon= / leftover inline =\py= / =\pyc= / =\pys= / =\pyb= / =\pyv= / =\pycon= and twins / =\sympy= / =\pylab= and twins / leftover usefamily =\ruby= / =\rb= / =\julia= / =\jl= / =\matlab= / =\octave= / =\bash= / =\sage= / =\rust= / =\rs= / =\R= / =\perl= / =\pl= / =\perlsix= / =\psix= / =\javascript= / =\js= and twins / =\inputpygments= / =\pygment= / leftover =\pythontexcustomc= / pythonhighlight.sty =\inputpython= / =\inputpythonfile= / leftover =\pyth= / catchfilebetweentags.sty =\CatchFileBetweenTags= / =\CatchFileBetweenDelims= / =\ExecuteMetaData= / catchfile.sty leftover =\CatchFileDef= / =\CatchFileEdef= / moreverb =\listinginput= / leftover =\verbatimtabinput= / =\verbatimtabinput*= / leftover =\verbatimwrite= / =\verbatimwrite*= / leftover =\listingcont= / sagetex =\sageinput= / leftover inline =\sageplot= / =\sagestr= / scontents leftover =\Scontents= / =\Scontents*= / =\typestored= / =\getstored= / =\mergesc= / =\meaningsc= / =\foreachsc= (and extra =[latex].verbatim_commands=) stay atomic; inner =.!?%= do not split or comment. - leftover =\verb= / =\verb*= / =\lstinline= / =\spverb= take a delimiter or, for =\lstinline=, optional =[...]= then a delimiter or ={...}=; a flush following sentence stays on its own line + leftover =\verb= / =\verb*= / =\lstinline= / =\spverb= take a delimiter or, for =\lstinline=, optional =[...]= then a delimiter or ={...}=; a flush following sentence stays on its own line. + A width wrap keeps a space inside that delimiter on the same line leftover =\mintinline= / =\mint= take optional =[...]=, ={lang}=, then a delimiter or ={...}= body; a flush following sentence stays on its own line leftover =\SaveVerb= takes optional =[...]=, a ={name}=, then the same delimiter body as =\Verb=; a flush following sentence stays on its own line leftover =\UseVerb= / =\UseVerb*= take optional =[...]= then a ={name}=; leftover =\UseVerbatim= / =\LUseVerbatim= / =\BUseVerbatim= take optional =[...]= then a ={name}=; a flush following sentence stays on its own line @@ -187,8 +195,11 @@ These tokens within prose are not split across lines: - Front matter (=---= or =+++= delimited at file start) - Full ATX heading lines (=#= … =######= including title text) - Empty ATX headings (a mark line with no title text) stay Structure -- Setext headings (title line plus ====== or ------ underline) -- List item markers (=-=, =*=, =+=, =1.=); continuation sentences hang at the marker width +- Setext headings (title line plus a line of one or more = or - characters). + A wrap cut that would park a short underline at column 0 escapes it +- List item markers (=-=, =*=, =+=, =1.=); continuation sentences hang at the marker width. + A line indented four spaces past the marker stays in an open item paragraph. After a blank line it is indented code. + A fence, ATX heading, thematic break, HTML block, or GFM table in the marker remainder stays a block - Empty list markers (including quoted) stay Structure - Definition-list terms and =: = markers (pulldown =ENABLE_DEFINITION_LIST=); the body hangs at the marker width - Blockquote markers (=>= / nested => > =); continuation sentences repeat the quote prefix @@ -198,15 +209,16 @@ These tokens within prose are not split across lines: - GFM footnote definitions (=[^label]:=); the marker is Structure and the body hangs - Hard line breaks (two trailing spaces, or a trailing backslash) - HTML comments (==, including multiline); == / == remain pragmas -- Closed type-6 and type-7 HTML blocks end at the matching close tag; following prose stays Prose -- Void type-6 tags (=
= / == / == / ==) end on the tag line; they have no closer, so leftover following prose stays Prose even without a blank +- Type-6 and type-7 HTML blocks run to the next blank line. A matching end tag does not end the block +- Void type-6 tags (=
= / == / == / == / == / ==) end on the tag line; they have no closer, so leftover following prose stays Prose even without a blank - Pipe tables *** Code regions (fenced =```= / =~~~=) :PROPERTIES: :CUSTOM_ID: markdown-code :END: -- Opening and closing fence lines are structure +- Opening and closing fence lines are structure. + A closing fence may be followed only by spaces or tabs; any other tail stays inside the code block - Indented fence bodies preserve indentation on reflowed comment lines - Language from the fence info string selects =[code.]=; unknown or missing lang passes the body through unchanged (unless =--format-code= is not applicable without a formatter entry) @@ -217,7 +229,8 @@ These tokens within prose are not split across lines: - Paragraph text - List item text (after the marker); continuation sentences hang at the marker width - Definition-list body (after the =: = marker); continuation sentences hang at the marker width -- GFM footnote-definition body (after =[^label]:=); continuation sentences hang at the marker width +- GFM footnote-definition body (after =[^label]:=); continuation sentences hang at the marker width. + An ATX heading in that continuation stays one structure line - Blockquote inner text (after the =>= marker); continuation sentences repeat the quote prefix *** Inline tokens (kept atomic) @@ -243,9 +256,15 @@ These tokens within prose are not split across lines: - Container directive openers (=.. note::=, =.. warning::=, =.. figure::=, =.. topic::=, =.. sidebar::=, =.. container::=, leftover =.. parsed-literal::=, =.. epigraph::=, =.. highlights::=, =.. pull-quote::=, =.. compound::=, =.. header::=, =.. footer::=) and their =:option:= fields; the indented body is prose - Same-line body after =::= on those containers, leftover =.. parsed-literal::=, =.. header::=, =.. footer::=, =.. |name| replace::=, and =.. meta::= fields hangs and splits. Flush bibliographic fields close a meta leftover -- Literal blocks (text after =::= with indented or line-prefix-quoted content) +- Literal blocks (text after =::= with indented or line-prefix-quoted content). + A paragraph whose text is only =::= is that marker. A two-character title underlined with =::= stays a section - Section titles and underlines (=====, =-----=, etc.). A wrap cut that would park a solid Docutils =Body.line= adornment at column 0 skip-cuts the token onto the previous line (snapper-7xd3) + A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) + A wrap cut that would park a Docutils grid-table top (=+---+=) at column 0 skip-cuts the token onto the previous line + A wrap cut that would park a Jinja statement (=\{% ... %\}=) or a Docutils anonymous target (=__ uri=) at column 0 skip-cuts the token onto the previous line. + The indented link block under that target stays structure until a blank + A wrap cut that would park a leftover plus fragment (=+===+=, =+foo=) at column 0 skip-cuts the token onto the previous line - Field lists (=:Author:=, =:Date:=, etc.) - Empty list items, empty doctest openers, and empty field-list markers at EOL stay Structure - Comments (=..= without a directive) diff --git a/docs/orgmode/tutorials/quickstart.org b/docs/orgmode/tutorials/quickstart.org index eebc79f..78595c4 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.6 + rev: v0.11.7 hooks: - id: snapper #+end_src diff --git a/docs/source/conf.py b/docs/source/conf.py index eef2f5e..140e42b 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.6" +release = "0.11.7" html_logo = "../../branding/logo/snapper_logo.png" extensions = [ diff --git a/editors/obsidian/manifest.json b/editors/obsidian/manifest.json index 344ab5c..d944b98 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.6", + "version": "0.11.7", "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 0f5a933..ec95ae5 100644 --- a/editors/obsidian/package-lock.json +++ b/editors/obsidian/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-snapper", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-snapper", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -17,7 +17,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "dev": true, "license": "MIT", "devDependencies": { diff --git a/editors/obsidian/package.json b/editors/obsidian/package.json index 85b7f0a..3cf5cb0 100644 --- a/editors/obsidian/package.json +++ b/editors/obsidian/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-snapper", - "version": "0.11.6", + "version": "0.11.7", "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 0910253..df1ea77 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.6+** on your PATH (or set `snapper.path`). +Requires the **snapper** / **snapper-fmt** CLI **0.11.7+** 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.6). +Install: `cargo install snapper-fmt` or the [release installer](https://github.com/TurtleTech-ehf/snapper/releases/tag/v0.11.7). 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 348367f..11f52ff 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.6", + "version": "0.11.7", "publisher": "TurtleTech", "license": "MIT", "repository": { diff --git a/editors/word/README.md b/editors/word/README.md index 18bef59..6c23fe6 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.6 (tracks snapper-fmt) +**Version:** 0.11.7 (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.6+**). +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+**). ## License diff --git a/editors/word/manifest.xml b/editors/word/manifest.xml index 653c640..d4a342b 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.6 + 0.11.7 TurtleTech ehf en-US diff --git a/editors/word/package-lock.json b/editors/word/package-lock.json index ad62f27..8f70665 100644 --- a/editors/word/package-lock.json +++ b/editors/word/package-lock.json @@ -1,12 +1,12 @@ { "name": "word-snapper", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "word-snapper", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -21,7 +21,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "dev": true, "license": "MIT", "devDependencies": { diff --git a/editors/word/package.json b/editors/word/package.json index aa87db9..f029253 100644 --- a/editors/word/package.json +++ b/editors/word/package.json @@ -1,6 +1,6 @@ { "name": "word-snapper", - "version": "0.11.6", + "version": "0.11.7", "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 b7c12ee..6919521 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.6)

+

Semantic line breaks for Word (v0.11.7)

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 ec3b170..d5a5adb 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@turtletech/snapper-mcp", - "version": "0.11.6", + "version": "0.11.7", "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 8b88166..7687340 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.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "devDependencies": { "typescript": "^5.4" diff --git a/packages/snapper-wasm/package.json b/packages/snapper-wasm/package.json index 32373cb..c0f5143 100644 --- a/packages/snapper-wasm/package.json +++ b/packages/snapper-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "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 ad86b3a..76a02a3 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.6 + rev: v0.11.7 hooks: - id: snapper #+end_src diff --git a/src/init.rs b/src/init.rs index fed70a8..b9af60d 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,5 +1,6 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::{Context, Result}; @@ -143,6 +144,128 @@ fn generate_precommit() -> String { ) } +const HOOK_MARKER: &str = "Generated by `snapper init`"; + +/// Git pre-commit hook that formats staged prose with native parsers. +fn generate_precommit_hook() -> String { + format!( + r#"#!/usr/bin/env bash +# {HOOK_MARKER}. Format staged prose with native parsers. +# bash, not /bin/sh: NUL-delimited `read -d ''` is not POSIX, and +# Ubuntu's dash skips the loop. +set -e +# Prefer snapper-fmt: argv0 snapper collides with openSUSE's Btrfs tool. +if command -v snapper-fmt >/dev/null 2>&1; then + SNAPPER=snapper-fmt +elif command -v snapper >/dev/null 2>&1; then + SNAPPER=snapper +else + echo "snapper: neither snapper-fmt nor snapper on PATH; skip" >&2 + exit 0 +fi + +git diff --cached --name-only --diff-filter=ACM -z | +while IFS= read -r -d '' f || [ -n "$f" ]; do + case "$f" in + *.org|*.tex|*.latex|*.md|*.markdown|*.rst|*.txt) + "$SNAPPER" --native --in-place -- "$f" + git -c filter.snapper.clean=cat add -- "$f" + ;; + esac +done +"# + ) +} + +fn git_hooks_dir(cwd: &Path) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--git-path", "hooks"]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let rel = String::from_utf8_lossy(&output.stdout); + let rel = rel.trim(); + if rel.is_empty() { + return None; + } + let path = Path::new(rel); + Some(if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }) +} + +fn write_precommit_hook(cwd: &Path, dry_run: bool) -> Result<()> { + let Some(hooks_dir) = git_hooks_dir(cwd) else { + eprintln!(" not a git repository; skip pre-commit hook"); + return Ok(()); + }; + let hook_path = hooks_dir.join("pre-commit"); + let content = generate_precommit_hook(); + if dry_run { + eprintln!("\n--- {} ---", hook_path.display()); + eprint!("{content}"); + return Ok(()); + } + if hook_path.exists() { + let existing = fs::read_to_string(&hook_path).unwrap_or_default(); + if !existing.contains(HOOK_MARKER) { + eprintln!( + " {} already exists and is not a snapper hook, skipping", + hook_path.display() + ); + return Ok(()); + } + } + fs::create_dir_all(&hooks_dir).context("failed to create git hooks directory")?; + fs::write(&hook_path, content).context("failed to write git pre-commit hook")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&hook_path)?.permissions(); + perms.set_mode(perms.mode() | 0o755); + fs::set_permissions(&hook_path, perms).context("failed to chmod git pre-commit hook")?; + } + eprintln!(" Created {}", hook_path.display()); + Ok(()) +} + +fn configure_git_filter(cwd: &Path, dry_run: bool) -> Result<()> { + if git_hooks_dir(cwd).is_none() { + return Ok(()); + } + if dry_run { + eprintln!(" git config filter.snapper.clean \"snapper-fmt --native --stdin-filepath %f\""); + eprintln!(" git config filter.snapper.smudge cat"); + return Ok(()); + } + let clean = Command::new("git") + .args([ + "config", + "filter.snapper.clean", + "snapper-fmt --native --stdin-filepath %f", + ]) + .current_dir(cwd) + .status(); + let smudge = Command::new("git") + .args(["config", "filter.snapper.smudge", "cat"]) + .current_dir(cwd) + .status(); + match (clean, smudge) { + (Ok(c), Ok(s)) if c.success() && s.success() => { + eprintln!(" Configured git filter.snapper.clean/smudge"); + } + _ => { + eprintln!(" git config filter.snapper.* failed, skipping"); + } + } + Ok(()) +} + /// Generate Apheleia elisp snippet. fn generate_apheleia(formats: &[&str]) -> String { let mut s = String::from(";; Add to your Emacs config:\n(with-eval-after-load 'apheleia\n"); @@ -211,15 +334,13 @@ pub fn run_init(dry_run: bool) -> Result<()> { } } + write_precommit_hook(&cwd, dry_run)?; + configure_git_filter(&cwd, dry_run)?; + // Print pre-commit and Apheleia snippets eprintln!("\n{}", generate_precommit()); eprintln!("{}", generate_apheleia(&formats)); - // Git filter setup reminder - eprintln!("To enable the git smudge/clean filter, run:"); - eprintln!(" git config filter.snapper.clean \"snapper\""); - eprintln!(" git config filter.snapper.smudge cat"); - Ok(()) } @@ -278,4 +399,21 @@ mod tests { assert!(ga.contains("# snapper")); assert!(!ga.contains("filter=snapper")); } + + #[test] + fn generate_precommit_hook_is_native_and_has_fmt_fallback() { + let hook = generate_precommit_hook(); + // NUL-delimited `read -d ''` is not POSIX. Ubuntu dash skips the loop. + assert!(hook.starts_with("#!/usr/bin/env bash\n")); + assert!(hook.contains(HOOK_MARKER)); + assert!(hook.contains("--native")); + assert!( + hook.find("command -v snapper-fmt").unwrap() + < hook.find("command -v snapper >/dev/null").unwrap(), + "hook must prefer snapper-fmt over argv0 snapper" + ); + assert!(hook.contains("--in-place")); + assert!(hook.contains("filter.snapper.clean=cat")); + assert!(hook.contains("*.org|*.tex|*.latex|*.md|*.markdown|*.rst|*.txt")); + } } diff --git a/src/parser/latex.rs b/src/parser/latex.rs index f51aa7b..84ffa2b 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -30,6 +30,10 @@ static NON_PROSE_ENVS: &[&str] = &[ "align*", "alignat", "alignat*", + "xalignat", + "xalignat*", + "xxalignat", + "xxalignat*", "aligned", "aligned*", "alignedat", @@ -91,20 +95,35 @@ static NON_PROSE_ENVS: &[&str] = &[ "tikzpicture", "tikzcd", "tikzcd*", + "quantikz", + "yquant", "pgfpicture", "pgfpicture*", "axis", "axis*", "array", "array*", + "subarray", + "CD", + "prooftree", + "empheq", + "empheq*", "matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", + "smallmatrix", + "psmallmatrix", + "bsmallmatrix", + "Bsmallmatrix", + "vsmallmatrix", + "Vsmallmatrix", "cases", "cases*", + "numcases", + "subnumcases", "dcases", "dcases*", "rcases", @@ -725,6 +744,45 @@ fn caption_open_brace(line: &str, cmd_end: usize, stop: usize) -> Option } /// Matching `}` for the `{` at `open_at`, skipping `\{` / `\}` and `\verb`. +/// Index of the `{` that opens `\intertext` or `\shortintertext`. +fn find_intertext_brace(line: &str, extra: &[String]) -> Option { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'\\' { + i += 1; + continue; + } + if let Some(end) = latex_verb_span_end_with(line, i, extra) { + i = end; + continue; + } + let rest = &line[i + 1..]; + let name = if rest.starts_with("shortintertext") { + "shortintertext" + } else if rest.starts_with("intertext") { + "intertext" + } else { + i += 1; + continue; + }; + let after_name = i + 1 + name.len(); + if after_name < bytes.len() && bytes[after_name].is_ascii_alphabetic() { + i += 1; + continue; + } + let mut j = after_name; + while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') { + j += 1; + } + if j < bytes.len() && bytes[j] == b'{' { + return Some(j); + } + i += 1; + } + None +} + fn find_matching_curly(s: &str, open_at: usize, extra_cmds: &[String]) -> Option { let bytes = s.as_bytes(); let mut depth = 0; @@ -1926,6 +1984,16 @@ impl<'a> ParseState<'a> { i = hit.end; } } + let extra = self.parser.extra_verbatim_commands.clone(); + if let Some(open) = find_intertext_brace(line.text, &extra) { + if find_matching_curly(line.text, open, &extra).is_some() { + // amsmath \intertext / mathtools \shortintertext is a + // paragraph between alignment rows, not a math row. + self.push_structure(ByteSpan::new(line.start, line.start + open + 1)); + self.emit_caption_group(line, open); + return; + } + } self.regions .push(SpannedRegion::structure(self.input, line.span())); } @@ -2881,6 +2949,303 @@ More text. assert!(structure_count >= 4); } + #[test] + fn yquant_body_is_not_sentence_split() { + // yquant is a circuit diagram, same class as quantikz. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{yquant}\n", + "The wire is open. The next claim stays put.\n", + "\\end{yquant}\n", + "After the circuit. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The wire is open. The next claim stays put.\n"), + "yquant body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the circuit.\nNext."), + "prose after the circuit must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn quantikz_body_is_not_sentence_split() { + // quantikz is a circuit diagram, same class as tikzcd. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{quantikz}\n", + "The wire is open. The next claim stays put.\n", + "\\end{quantikz}\n", + "After the circuit. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The wire is open. The next claim stays put.\n"), + "quantikz body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the circuit.\nNext."), + "prose after the circuit must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn numcases_body_is_not_sentence_split() { + // cases.sty numcases / subnumcases are numbered cases, same + // class as amsmath cases. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + for name in ["numcases", "subnumcases"] { + let input = format!( + "\\begin{{{name}}}{{y =}}\nx & x > 0. Second sentence stays put.\n\\end{{{name}}}\nAfter the cases. Next.\n" + ); + let out = crate::format_text(&input, &cfg).unwrap(); + assert!( + out.contains("x & x > 0. Second sentence stays put.\n"), + "{name} body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the cases.\nNext."), + "prose after {name} must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + } + + #[test] + fn empheq_body_is_not_sentence_split() { + // empheq wraps a display. A line that is not itself an inner + // non-prose environment still must not sentence-split. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{empheq}{align}\n", + "The premise holds. The next claim stays put.\n", + "\\end{empheq}\n", + "After the display. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The premise holds. The next claim stays put.\n"), + "empheq body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the display.\nNext."), + "prose after the display must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn intertext_argument_is_prose() { + // amsmath \intertext / mathtools \shortintertext are paragraphs + // between alignment rows. The math rows stay structure. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + for cmd in ["intertext", "shortintertext"] { + let input = format!( + "\\begin{{align}}\na &= b \\\\\n\\{cmd}{{The note holds. The next claim stays put.}}\nc &= d\n\\end{{align}}\nAfter the align. Next.\n" + ); + let out = crate::format_text(&input, &cfg).unwrap(); + assert!( + out.contains(&format!( + "\\{cmd}{{The note holds.\nThe next claim stays put.}}" + )), + "{cmd} argument must split, got:\n{out}" + ); + assert!( + out.contains("a &= b \\\\"), + "alignment row must stay intact, got:\n{out}" + ); + assert!( + out.contains("After the align.\nNext."), + "prose after the align must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + } + + #[test] + fn prooftree_body_is_not_sentence_split() { + // bussproofs prooftree is a proof diagram, same class as CD. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{prooftree}\n", + "The premise holds. The next claim stays put.\n", + "\\end{prooftree}\n", + "After the proof. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The premise holds. The next claim stays put.\n"), + "prooftree body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the proof.\nNext."), + "prose after the proof must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn cd_body_is_not_sentence_split() { + // amscd CD is a commutative diagram, same class as a math array. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{CD}\n", + "Arrow to the target. Second sentence stays put.\n", + "\\end{CD}\n", + "After the diagram. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Arrow to the target. Second sentence stays put.\n"), + "CD body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the diagram.\nNext."), + "prose after the diagram must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn subarray_body_is_not_sentence_split() { + // amsmath subarray is a math stack, same class as array. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{subarray}{c}\n", + "a. Second sentence stays put.\n", + "\\end{subarray}\n", + "After the stack. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("a. Second sentence stays put.\n"), + "subarray body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the stack.\nNext."), + "prose after the stack must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn xalignat_body_is_not_sentence_split() { + // amsmath xalignat / xxalignat are alignment displays, same + // class as alignat. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + for name in ["xalignat", "xxalignat"] { + let input = format!( + "\\begin{{{name}}}{{2}}\na &= b. Second sentence stays put.\n\\end{{{name}}}\nAfter the align. Next.\n" + ); + let out = crate::format_text(&input, &cfg).unwrap(); + assert!( + out.contains("a &= b. Second sentence stays put.\n"), + "{name} body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the align.\nNext."), + "prose after {name} must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + } + + #[test] + fn smallmatrix_body_is_not_sentence_split() { + // amsmath smallmatrix is a math array, same class as matrix. + let input = concat!( + "\\begin{smallmatrix}\n", + "a & b. Another sentence stays put.\n", + "\\end{smallmatrix}\n", + "After the array. Second sentence.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("a & b. Another sentence stays put.\n"), + "smallmatrix body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the array.\nSecond sentence."), + "prose after the array must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + for name in [ + "psmallmatrix", + "bsmallmatrix", + "Bsmallmatrix", + "vsmallmatrix", + "Vsmallmatrix", + ] { + let env = format!( + "\\begin{{{name}}}\na & b. Another sentence stays put.\n\\end{{{name}}}\nAfter the array. Second sentence.\n" + ); + let env_out = crate::format_text(&env, &cfg).unwrap(); + assert!( + env_out.contains("a & b. Another sentence stays put.\n"), + "{name} body must stay one line, got:\n{env_out}" + ); + assert!( + env_out.contains("After the array.\nSecond sentence."), + "prose after {name} must still split, got:\n{env_out}" + ); + } + } + #[test] fn comments_preserved() { let input = r"\begin{document} diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 5637d31..3978d05 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -255,11 +255,12 @@ fn is_html_block_tag(name: &str) -> bool { } /// Type-6 tags with no closer (HTML void elements on the CM type-6 list). -/// snapper-56tj: nest never hits 0, so leftover following prose was Structure. +/// snapper-56tj / snapper-18gq: nest never hits 0, so leftover following +/// prose was Structure. `param` / `track` are the remaining CM type-6 voids. fn is_html_void_type6(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), - "base" | "basefont" | "col" | "frame" | "hr" | "link" | "menuitem" + "base" | "basefont" | "col" | "frame" | "hr" | "link" | "menuitem" | "param" | "track" ) } @@ -396,7 +397,11 @@ fn named_html_end_idx(lines: &[Line<'_>], start_idx: usize, tag: &str) -> usize let mut nest = 0i32; let mut j = start_idx; loop { - if apply_type6_named_tags(lines[j].text, tag, &mut nest) { + let closed = apply_type6_named_tags(lines[j].text, tag, &mut nest); + // A void tag has no body, so the block is that line. A matching + // end tag does not end type 6 or 7; CommonMark runs to the next + // blank line. + if closed && is_html_void_type6(tag) { return j; } if j + 1 >= lines.len() || lines[j + 1].text.trim().is_empty() { @@ -464,9 +469,9 @@ fn html_block_line_ends(line: &str, kind: HtmlBlock) -> bool { fn html_block_end_idx(kind: HtmlBlock, lines: &[Line<'_>], start_idx: usize) -> usize { match kind { HtmlBlock::Type6 | HtmlBlock::Type7 => { - // Closed type-6 (`
…
`) and type-7 (`…`) - // end at the matching close so the next paragraph stays Prose - // (GitHub #332 / #356). Unclosed still runs to a following blank. + // CommonMark 0.31.2: type 6 and 7 end at the next blank line. + // A matching end tag does not end the block. Void tags still + // stop on the tag line. let rest = html_block_rest(lines[start_idx].text); let tag = if kind == HtmlBlock::Type6 { type6_tag_name(rest) @@ -562,7 +567,8 @@ fn quoted_named_html_end_idx( let mut j = start_idx; loop { if let Some(inner) = quoted_html_inner(lines[j].text, depth) { - if apply_type6_named_tags(inner, tag, &mut nest) { + let closed = apply_type6_named_tags(inner, tag, &mut nest); + if closed && is_html_void_type6(tag) { return j; } } else { @@ -1276,6 +1282,67 @@ fn list_item_empty_pad(rest: &str) -> usize { usize::from(rest == " ") } +/// Block that starts in the text after a list marker: ATX heading, +/// thematic break, HTML block, or GFM table. The returned index is the +/// last source line of that block. A fence is handled by the caller so +/// the body stays `Code`. +fn list_remainder_raw_end(lines: &[Line<'_>], start: usize, rest: &str) -> Option { + let content = rest.trim_start(); + if content.is_empty() || FENCED_CODE_RE.is_match(content) { + return None; + } + if HEADING_RE.is_match(content) || is_thematic_break(content) { + return Some(start); + } + if let Some(kind) = html_block_kind(content) { + return Some(match kind { + HtmlBlock::Type6 | HtmlBlock::Type7 => { + let tag = if kind == HtmlBlock::Type6 { + type6_tag_name(content) + } else { + type7_tag_name(content) + }; + if let Some(tag) = tag { + named_html_end_idx(lines, start, tag) + } else { + let mut j = start; + while j + 1 < lines.len() && !lines[j + 1].text.trim().is_empty() { + j += 1; + } + j + } + } + _ => { + if html_block_line_ends(content, kind) + || html_block_line_ends(lines[start].text, kind) + { + start + } else { + let mut j = start + 1; + while j < lines.len() && !html_block_line_ends(lines[j].text, kind) { + j += 1; + } + j.min(lines.len().saturating_sub(1)) + } + } + }); + } + let header = gfm_table_cells(content)?; + let delim = lines.get(start + 1)?; + let dcells = gfm_table_cells(delim.text)?; + if header.len() != dcells.len() || !dcells.iter().all(|c| is_gfm_delimiter_cell(c)) { + return None; + } + let mut end = start + 1; + for (j, line) in lines.iter().enumerate().skip(start + 2) { + if !is_gfm_table_row(line.text) { + break; + } + end = j; + } + Some(end) +} + /// CommonMark 5.2 hang: marker width after quote markers (`- ` is 2, /// `1. ` is 3, lone `-` is W+1). A setext underline shallower than this /// hang is lazy paragraph text, not a closer (GitHub #261). @@ -1533,18 +1600,26 @@ fn quoted_setext_ok(title: &str, underline: &str, prev: Option<&str>) -> bool { } /// Closing fence: same marker char, length at least the opener, indent at -/// most `max(3, opener_indent)`. CommonMark allows 0–3 spaces on a closer; -/// list-nested openers keep their own indent so a matching 4-space closer -/// still ends the block. Deeper inner fences stay content. +/// most `max(3, opener_indent)`. CommonMark 0.31.2 allows 0–3 spaces on a +/// closer and only spaces or tabs after the marker. List-nested openers +/// keep their own indent so a matching 4-space closer still ends the +/// block. Deeper inner fences stay content. A tail such as +/// `` ``` not a closer `` is still code. fn is_closing_fence(line: &str, fence_marker: &str, opener_indent: usize) -> bool { if line_indent(line) > opener_indent.max(3) { return false; } - let Some(caps) = FENCED_CODE_RE.captures(line.trim_start()) else { + let trimmed = line.trim_start(); + let Some(caps) = FENCED_CODE_RE.captures(trimmed) else { return false; }; let marker = caps.get(1).unwrap().as_str(); - marker.chars().next() == fence_marker.chars().next() && marker.len() >= fence_marker.len() + if marker.chars().next() != fence_marker.chars().next() || marker.len() < fence_marker.len() { + return false; + } + trimmed[marker.len()..] + .bytes() + .all(|b| b == b' ' || b == b'\t') } /// True when `s` contains an unescaped `|` (GFM table cell separator). @@ -2374,6 +2449,17 @@ impl FormatParser for MarkdownParser { let row = &lines[j]; let hang = line_indent(row.text); let inner = row.text.get(hang..).unwrap_or(""); + if HEADING_RE.is_match(inner) { + flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + if let Some(span) = list_term.take() { + if !span.is_empty() { + regions.push(SpannedRegion::structure(input, span)); + } + } + regions.push(SpannedRegion::structure(input, row.span())); + j += 1; + continue; + } if FENCED_CODE_RE.is_match(inner.trim_start()) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); let fence = FENCED_CODE_RE @@ -2593,8 +2679,8 @@ impl FormatParser for MarkdownParser { } // CommonMark 4.6 HTML blocks types 1 and 3–7. Type 2 is above. - // Unclosed type-7 cannot interrupt a paragraph. Closed type-7 - // (`…`) is a leaf island (GitHub #356). + // Unclosed type-7 cannot interrupt a paragraph. Type 6 and 7 + // run to the next blank line. if let Some(kind) = html_block_kind(line_text) { let in_paragraph = !current_prose.is_empty() || in_list_item; if kind.can_interrupt() @@ -2925,6 +3011,37 @@ impl FormatParser for MarkdownParser { // item stays empty and the next unindented line is not // lazy continuation (GitHub #337). let marker_len = marker.len() + list_item_empty_pad(rest); + let content = rest.get(list_item_empty_pad(rest)..).unwrap_or(""); + // pulldown parses the remainder as blocks. A fence, + // heading, thematic break, HTML block, or GFM table + // there is not item prose. + if let Some(fcaps) = FENCED_CODE_RE.captures(content.trim_start()) { + let spaces = content.len() - content.trim_start().len(); + fence_marker = fcaps.get(1).unwrap().as_str().to_string(); + fence_indent = marker_len + spaces; + fence_quote_depth = 0; + in_fenced_code = true; + in_list_item = false; + list_hang = None; + code_lang = FENCED_LANG_RE + .captures(content.trim_start()) + .map(|c| c.get(1).unwrap().as_str().to_string()); + code_header = line.span(); + code_body_start = line.end; + i += 1; + continue; + } + if let Some(end) = list_remainder_raw_end(&lines, i, content) { + in_list_item = true; + list_hang = Some(list_marker_hang(marker)); + list_after_blank = false; + in_definition_list = false; + for row in &lines[i..=end] { + regions.push(SpannedRegion::structure(input, row.span())); + } + i = end + 1; + continue; + } let marker_span = ByteSpan::new(line.start, line.start + marker_len); regions.push(SpannedRegion::structure(input, marker_span)); in_list_item = true; @@ -3029,11 +3146,17 @@ impl FormatParser for MarkdownParser { } last_was_def_term = false; - // hang+4 indented code inside a list item, with or without a - // blank. CM 5.2: indent ≥ hang+4 is code, not lazy paragraph. + // hang+4 indented code inside a list item only after the + // paragraph has closed. CommonMark 4.4: indented code cannot + // interrupt an open paragraph, so a hang+4 line with no blank + // stays in the item. A blank flushes prose; the next hang+4 + // line is code. if in_list_item { if let Some(hang) = list_hang { - if is_indented_code_line(line_text) && line_indent(line_text) >= hang + 4 { + if current_prose.is_empty() + && is_indented_code_line(line_text) + && line_indent(line_text) >= hang + 4 + { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); if let Some(span) = list_term.take() { if !span.is_empty() { @@ -3293,6 +3416,137 @@ mod tests { assert_eq!(format_text(input, &cfg).unwrap(), input); } + #[test] + fn list_marker_fence_remainder_stays_code() { + // pulldown parses the text after a list marker as blocks. + // The fence opener on that line is not item prose. + let input = concat!( + "- ```\n", + " Keep this sentence in the fence. Keep this one too.\n", + " ```\n", + ); + let regions = MarkdownParser.parse(input); + match regions.iter().find(|r| matches!(r, Region::Code { .. })) { + Some(Region::Code { body, footer, .. }) => { + assert!( + body.contains("Keep this sentence in the fence. Keep this one too.\n"), + "fence body stays one code line, got {body:?}" + ); + assert!( + footer.contains("```"), + "indented closer ends the fence, got {footer:?}" + ); + } + other => panic!("list-marker fence must be Code, got {other:?} in {regions:?}"), + } + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Keep this"))), + "fence body must not be prose: {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Keep this sentence in the fence. Keep this one too.\n"), + "fence body must not sentence-split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn list_marker_html_remainder_stays_raw() { + let input = concat!( + "-
\n", + " Inside the block. Second sentence.\n", + "
\n", + "\n", + "After. Next.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Inside the block. Second sentence.\n"), + "html remainder must not sentence-split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the html block must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn list_marker_table_remainder_stays_raw() { + let input = concat!( + "- | a | b |\n", + " | --- | --- |\n", + " | one. | two. |\n", + "\n", + "After. Next.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("| one. | two. |\n"), + "table remainder must stay one row, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the table must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn list_marker_heading_remainder_stays_one_line() { + let input = "- # Heading one. Heading two must not split.\n\nAfter. Next.\n"; + let regions = MarkdownParser.parse(input); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("Heading one. Heading two")) + ), + "heading remainder stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Heading two"))), + "heading remainder must not be prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("- # Heading one. Heading two must not split.\n"), + "heading must stay one line, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn list_nested_fence_still_closes_at_opener_indent() { let input = concat!( @@ -3333,6 +3587,85 @@ mod tests { } } + #[test] + fn closing_fence_tail_is_still_code() { + // CommonMark 0.31.2: a closing fence may be followed only by + // spaces or tabs. ` ``` not a closer` stays inside the block. + for closer in ["``` not a closer", "~~~ not a closer", "```not"] { + let fence = &closer[..3]; + let input = format!( + "{fence}\nKeep this sentence in the fence. Keep this one too.\n{closer}\nStill inside the fence. Must not split.\n{fence}\n" + ); + let regions = MarkdownParser.parse(&input); + match ®ions[0] { + Region::Code { body, footer, .. } => { + assert!( + body.contains(closer), + "tailed fence {closer:?} stays in the body, got {body:?}" + ); + assert!( + body.contains("Still inside the fence. Must not split.\n"), + "text after {closer:?} stays in the body, got {body:?}" + ); + assert_eq!(footer.as_str(), format!("{fence}\n").as_str()); + } + other => panic!("tailed fence {closer:?} must not close, got {other:?}"), + } + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Must not split"))), + "fence body must not become prose for {closer:?}: {regions:?}" + ); + } + } + + #[test] + fn closing_fence_tail_is_identity_under_format() { + use crate::format::Format; + use crate::{FormatConfig, format_text}; + + let input = concat!( + "```\n", + "Keep this sentence in the fence. Keep this one too.\n", + "``` not a closer\n", + "Still inside the fence. Must not split.\n", + "```\n", + ); + let cfg = FormatConfig { + format: Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = format_text(input, &cfg).unwrap(); + assert!( + out.contains("Still inside the fence. Must not split.\n"), + "fence body must not sentence-split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn closing_fence_trailing_blank_still_closes() { + for closer in ["``` ", "```\t", "~~~~ "] { + let fence = if closer.starts_with('`') { + "```" + } else { + "~~~~" + }; + let input = format!("{fence}\ncode\n{closer}\n"); + let regions = MarkdownParser.parse(&input); + match ®ions[0] { + Region::Code { body, footer, .. } => { + assert_eq!(body, "code\n"); + assert_eq!(footer.as_str(), format!("{closer}\n").as_str()); + } + other => panic!("blank tail {closer:?} must close, got {other:?}"), + } + } + } + #[test] fn frontmatter_preserved() { let input = "---\ntitle: Test\nauthor: Someone\n---\n\nSome text."; @@ -3966,6 +4299,41 @@ mod tests { ); } + #[test] + fn hang_plus_four_without_blank_joins_the_paragraph() { + // CommonMark 4.4: indented code cannot interrupt a paragraph. + // hang+4 with no blank stays in the open item and splits. + let input = "- foo bar sentence.\n continues the item. Second sentence.\n"; + let regions = MarkdownParser.parse(input); + assert!( + !regions.iter().any(|r| matches!(r, Region::Code { .. })), + "open paragraph must not become indented code, got {regions:?}" + ); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("foo bar sentence.") && p.contains("continues the item.") + )), + "hang+4 line joins the item paragraph, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("continues the item.\n"), + "joined paragraph must sentence-split, got:\n{out}" + ); + assert!( + !out.contains(" continues"), + "the line must not stay a six-space code line, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn hang_plus_four_after_blank_is_indented_code() { let input = "- Item one.\n\n indented code inside the item\n"; @@ -5261,8 +5629,8 @@ mod tests { ); } - /// GitHub #332 / snapper-v85k: closed type-6 must not swallow - /// the following paragraph. + /// CommonMark type 6 runs to the next blank line. Text after + /// `
` with no blank stays in the block. fn ticket_html_type6_close_fixture() -> &'static str { concat!( "Intro sentence here. Another intro sentence.\n", @@ -5285,23 +5653,16 @@ mod tests { assert!(div.contains("First. Second."), "{div}"); assert!(div.contains("
"), "{div}"); assert!( - !div.contains("After html"), - "closed type-6 must end at , got {div}" + div.contains("After html. Next."), + "text before a blank stays in the type-6 block, got {div}" ); assert!( !regions.iter().any(|r| matches!( r, - Region::Prose(p) if p.contains("First.") || p.contains("", ""] { + let input = + format!("Intro sentence here. Another intro sentence.\n{tag}\nAfter html. Next.\n"); + let regions = MarkdownParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains(tag) + )), + "{tag} must be Structure, got {regions:?}" + ); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains(tag) && s.contains("After html") + )), + "void type-6 {tag} must not swallow following prose, got {regions:?}" + ); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("After html.") && p.contains("Next.") + )), + "following paragraph after {tag} must stay Prose: {regions:?}" + ); + } + } + + #[test] + fn leftover_html_type6_void_param_following_prose_still_splits() { + use crate::{FormatConfig, format_text}; + let cfg = FormatConfig { + format: crate::format::Format::Markdown, + ..Default::default() + } + .without_safety_backstops(); + let input = "Intro sentence here. Another intro sentence.\n\nAfter html. Next.\n"; + let out = format_text(input, &cfg).unwrap(); + assert!( + out.contains(""), + "void type-6 tag must stay, got:\n{out}" + ); + assert!( + out.contains("After html.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg).unwrap(), out); + } + #[test] fn html_script_block_is_code() { let regions = MarkdownParser.parse(ticket_html_blocks_fixture()); @@ -5531,8 +5946,8 @@ mod tests { ); } - /// GitHub #356 / snapper-615s: closed type-7 must not swallow - /// the following paragraph. + /// CommonMark type 7 runs to the next blank line. Text after + /// `` with no blank stays in the block. fn ticket_html_type7_close_fixture() -> &'static str { concat!( "Intro sentence here. Another intro sentence.\n", @@ -5555,23 +5970,16 @@ mod tests { assert!(span.contains("First. Second."), "{span}"); assert!(span.contains(""), "{span}"); assert!( - !span.contains("After html"), - "closed type-7 must end at , got {span}" + span.contains("After html. Next."), + "text before a blank stays in the type-7 block, got {span}" ); assert!( !regions.iter().any(|r| matches!( r, - Region::Prose(p) if p.contains("First.") || p.contains(", got:\n{out}" ); assert!( - out.contains("After html.\nNext.\n"), - "following paragraph must stay Prose and split, got:\n{out}" + out.contains("After html. Next.\n"), + "text before a blank stays in the span block, got:\n{out}" + ); + assert!( + !out.contains("After html.\nNext."), + "type-7 text must not sentence-split before a blank, got:\n{out}" ); assert!( !out.contains("First.\nSecond."), @@ -5678,8 +6090,12 @@ mod tests { "div HTML block must stay raw through , got:\n{out}" ); assert!( - out.contains("After html.\nNext.\n"), - "following paragraph must stay Prose and split, got:\n{out}" + out.contains("After html. Next.\n"), + "text before a blank stays in the div block, got:\n{out}" + ); + assert!( + !out.contains("After html.\nNext."), + "type-6 text must not sentence-split before a blank, got:\n{out}" ); assert!( !out.contains("First.\nSecond."), @@ -6669,6 +7085,93 @@ mod tests { ); } + #[test] + fn html_type6_runs_to_a_blank_line() { + // CommonMark 0.31.2 type 6 ends at a blank line. The matching + // close tag does not end the block. + let input = "
\nFirst. Second.\n
\nAfter html. Next.\n"; + let regions = MarkdownParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) + if s.contains("") && s.contains("After html. Next.") + )), + "text after stays in the html block, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("After html"))), + "text before the blank must not be prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("After html. Next.\n"), + "html block must not sentence-split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + + let blanked = "
\nFirst. Second.\n
\n\nAfter html. Next.\n"; + let blanked_out = crate::format_text(blanked, &cfg).unwrap(); + assert!( + blanked_out.contains("After html.\nNext.\n"), + "a blank line ends the block, got:\n{blanked_out}" + ); + let hr = "
\nAfter html. Next.\n"; + let hr_out = crate::format_text(hr, &cfg).unwrap(); + assert!( + hr_out.contains("After html.\nNext.\n"), + "a void tag does not swallow the next line, got:\n{hr_out}" + ); + } + + #[test] + fn footnote_continuation_heading_stays_one_line() { + let input = concat!( + "[^1]: Note text.\n", + "\n", + " # Heading one. Heading two must not split.\n", + "\n", + "After. Next.\n", + ); + let regions = MarkdownParser.parse(input); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("# Heading one. Heading two")) + ), + "footnote heading stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Heading two"))), + "footnote heading must not be prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("# Heading one. Heading two must not split.\n"), + "heading must stay one line, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the footnote must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn ticket_fixture_link_ref_and_footnote_do_not_reflow() { use crate::format::Format; diff --git a/src/parser/org.rs b/src/parser/org.rs index c7a1de3..f5cb89c 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -9,12 +9,13 @@ use crate::parser::{ static HEADLINE_RE: LazyLock = LazyLock::new(|| Regex::new(r"^(\*+\s+(?:TODO\s+|DONE\s+|NEXT\s+|WAIT\s+)?)(.*)$").unwrap()); -/// Org unordered/ordered marker plus a trailing space or EOL. +/// Org unordered/ordered marker plus a trailing space, tab, or EOL. /// Emacs 30.2 `org-item-re` is bullet then `[ \t]+` or `$` (GitHub #320). /// org-syntax 4.2.6 / orgize: `*` is a bullet only when indent > 0; /// column-0 `*` is a headline (`HEADLINE_RE` is matched first). -static LIST_ITEM_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^(\s*(?:[-+]|\d+[.)])(?: |$)|[ \t]+\*(?: |$))(.*)$").unwrap()); +static LIST_ITEM_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(\s*(?:[-+]|\d+[.)])(?:[ \t]|$)|[ \t]+\*(?:[ \t]|$))(.*)$").unwrap() +}); /// org-element-export-snippet-parser prefix: `@@BACKEND:VALUE@@`. /// Backend is `[-A-Za-z0-9]+`. Value runs to the next `@@` (may contain @@ -168,11 +169,13 @@ struct OpenGreater { /// or Unicode word characters (letters and digits). `:END:` is the closer. /// org-element plain-link prefixes at column 0. `file+emacs:` / /// `file+sys:` must be matched before `file:`. `man:` / `docview:` / -/// `shortdoc:` are ol.el built-ins (snapper-9aio). +/// `shortdoc:` are ol.el built-ins (snapper-9aio). `bibtex:` / `eshell:` / +/// `w3m:` are ol-bibtex / ol-eshell / ol-w3m (snapper-zmue). const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "file+emacs:", "file+sys:", "file:", + "eshell:", "shell:", "elisp:", "help:", @@ -195,6 +198,8 @@ const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "attachment:", "docview:", "shortdoc:", + "bibtex:", + "w3m:", "id:", ]; @@ -921,6 +926,12 @@ impl FormatParser for OrgParser { // Track list item context: indent level of the marker text. // Continuation lines indented at or beyond this level belong to the item. let mut list_item_indent: Option = None; + // Bullet columns, outer first. A line indented at or before a + // bullet ends that item; an outer bullet stays open. + let mut list_stack: Vec = Vec::new(); + // org-list-end-re ends a plain list on two blanks. One blank + // keeps the item open for an indented paragraph. + let mut list_saw_blank = false; // org-element footnote-separator is headline / next `[fn:]` / // two consecutive blanks. One blank plus an indented // continuation stays in the definition. @@ -1165,10 +1176,14 @@ impl FormatParser for OrgParser { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); if in_footnote_def && !footnote_saw_blank { footnote_saw_blank = true; + } else if !in_footnote_def && list_item_indent.is_some() && !list_saw_blank { + list_saw_blank = true; } else { in_footnote_def = false; footnote_saw_blank = false; + list_stack.clear(); list_item_indent = None; + list_saw_blank = false; } regions.push(SpannedRegion::blank(input, line.span())); continue; @@ -1201,7 +1216,9 @@ impl FormatParser for OrgParser { // value hangs. `#+NAME:` / `#+ATTR_*` stay whole-line. if let Some(marker_len) = org_caption_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = Some(marker_len); + list_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker_len); regions.push(SpannedRegion::structure(input, marker_span)); Self::emit_hung_text(input, &line, marker_len, &mut regions); @@ -1233,7 +1250,9 @@ impl FormatParser for OrgParser { // the path is hung Prose (org-element plain link). if let Some(marker_len) = org_plain_link_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = None; + list_saw_blank = false; let body = &line_text[marker_len..]; if body.trim().is_empty() { regions.push(SpannedRegion::structure(input, line.span())); @@ -1248,7 +1267,9 @@ impl FormatParser for OrgParser { } if Self::is_standalone_org_link(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = None; + list_saw_blank = false; regions.push(SpannedRegion::structure(input, line.span())); continue; } @@ -1277,7 +1298,9 @@ impl FormatParser for OrgParser { input, ByteSpan::new(line.start, line.start + marker_len), )); + list_stack.clear(); list_item_indent = Some(marker_len); + list_saw_blank = false; Self::emit_hung_text(input, &line, marker_len, &mut regions); } continue; @@ -1300,7 +1323,9 @@ impl FormatParser for OrgParser { // Marker is Structure; same-line body is hung Prose (GitHub #180). if let Some(marker_len) = org_footnote_definition_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = Some(marker_len); + list_saw_blank = false; in_footnote_def = true; footnote_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker_len); @@ -1314,7 +1339,9 @@ impl FormatParser for OrgParser { let leading = line_text.len() - line_text.trim_start().len(); if leading > 0 { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = Some(leading); + list_saw_blank = false; footnote_saw_blank = false; regions.push(SpannedRegion::structure( input, @@ -1331,8 +1358,16 @@ impl FormatParser for OrgParser { if let Some(caps) = LIST_ITEM_RE.captures(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); let marker = caps.get(1).unwrap().as_str(); - // Track indent for continuation detection: text starts at marker length - list_item_indent = Some(marker.len()); + // An item ends at a line indented <= its bullet. The text + // column (`1. ` is three) is the hang, not that test, so a + // two-space line stays in a column-0 item. + let bullet_col = marker.len() - marker.trim_start().len(); + while list_stack.last().is_some_and(|&col| col >= bullet_col) { + list_stack.pop(); + } + list_stack.push(bullet_col); + list_item_indent = Some(bullet_col + 1); + list_saw_blank = false; in_footnote_def = false; footnote_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker.len()); @@ -1341,9 +1376,25 @@ impl FormatParser for OrgParser { continue; } - // List item continuation: indented line following a list item + // List item continuation: indented line following a list item. + // Pop bullets this line ends, then keep an outer item whose + // bullet is still to the left. + let leading = line_text.len() - line_text.trim_start().len(); + // Popping a child must not append this line onto the child's prose. + let mut returned_to_outer = false; + if !list_stack.is_empty() { + while list_stack.last().is_some_and(|&col| leading <= col) { + list_stack.pop(); + returned_to_outer = true; + } + if list_stack.is_empty() { + list_item_indent = None; + list_saw_blank = false; + } else { + list_item_indent = Some(list_stack.last().copied().unwrap() + 1); + } + } if let Some(indent) = list_item_indent { - let leading = line_text.len() - line_text.trim_start().len(); if leading >= indent && !line_text.trim().is_empty() && Self::find_unescaped_display_bracket(line_text, 0, b'[').is_none() @@ -1365,6 +1416,17 @@ impl FormatParser for OrgParser { .. }) if is_org_line_break_structure(s) ); + if returned_to_outer { + if leading > 0 { + regions.push(SpannedRegion::structure( + input, + ByteSpan::new(line.start, line.start + leading), + )); + } + Self::emit_hung_text(input, &line, leading, &mut regions); + list_saw_blank = false; + continue; + } if is_term { regions.pop(); if let Some(break_at) = org_line_break_at(line_text) { @@ -1409,11 +1471,26 @@ impl FormatParser for OrgParser { )); } Self::emit_hung_text(input, &line, leading, &mut regions); + list_saw_blank = false; + continue; + } + // One blank, then an indented paragraph: still the item. + if list_saw_blank { + if leading > 0 { + regions.push(SpannedRegion::structure( + input, + ByteSpan::new(line.start, line.start + leading), + )); + } + Self::emit_hung_text(input, &line, leading, &mut regions); + list_saw_blank = false; continue; } } // Not a continuation: leave list context + list_stack.clear(); list_item_indent = None; + list_saw_blank = false; } // org-syntax 5.2: `\[CONTENTS\]` may be mid-line (Kang: Structure). @@ -1668,6 +1745,145 @@ mod tests { ); } + #[test] + fn tab_after_item_bullet_stays_a_list() { + // Emacs org-item-re: bullet then [ \t]+ or EOL. + let input = "See the note below and keep reading.\n-\tTab after the bullet. Second sentence stays in the item.\n"; + let regions = OrgParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s == "-\t")), + "tab after a dash is the item marker, got {regions:?}" + ); + assert!( + regions.iter().any(|r| { + matches!(r, Region::Prose(p) if p.contains("Tab after the bullet.") && !p.contains("keep reading")) + }), + "item body stays apart from the intro, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("-\tTab after the bullet.\n"), + "item body still splits, got:\n{out}" + ); + assert!( + out.contains("Second sentence stays in the item."), + "second sentence stays in the item, got:\n{out}" + ); + assert!( + !out.lines() + .any(|l| l.contains("keep reading") && l.contains("Tab after")), + "tab item must not join the intro, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn org_nested_item_returns_to_the_parent() { + // A line indented like the child bullet ends the child and + // stays in the parent. It must not join the flush paragraph. + let input = "- Parent item\n 1. Child item\n back in the parent\nAfter.\n"; + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 40, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines() + .any(|l| l.contains("back in the parent") && l.contains("After")), + "parent continuation must not join the following paragraph, got:\n{out}" + ); + assert!( + out.contains("back in the parent"), + "parent line must stay, got:\n{out}" + ); + assert!( + !out.lines() + .any(|l| l.contains("Child") && l.contains("back in the parent")), + "the line returns to the parent, not the child, got:\n{out}" + ); + assert!( + out.lines().any(|l| l.starts_with("After")), + "flush line ends the list, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn org_item_ends_at_bullet_column() { + // Org manual: an item ends before the next line indented less + // than or equal to its bullet. A two-space line stays in a + // column-0 ordered item. The flush line does not. + let input = "1. Ordered item text\n still inside here\nplus outside text.\n"; + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines() + .any(|l| l.contains("still") && l.contains("plus")), + "continuation must not join the following paragraph, got:\n{out}" + ); + assert!( + out.contains("still inside"), + "indented line must stay, got:\n{out}" + ); + assert!( + out.lines().any(|l| l.starts_with("plus outside")), + "flush line ends the item, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn org_list_second_paragraph_stays_in_the_item() { + // org-list-end-re: one blank stays in the item; two blanks end it. + let input = concat!( + "- First paragraph in the item. It has two sentences.\n", + "\n", + " Second paragraph stays in the item. Another sentence.\n", + "\n", + "\n", + "After the list. Next sentence.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + let second = out + .lines() + .find(|l| l.contains("Second paragraph")) + .unwrap_or(""); + assert!( + second.starts_with(' ') || second.starts_with('\t'), + "second item paragraph must stay indented, got:\n{out}" + ); + assert!( + out.lines().any(|l| l.starts_with("After the list.")), + "two blanks end the list, got:\n{out}" + ); + assert!( + out.contains("Next sentence."), + "prose after the list must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn numbered_empty_item_at_eol_is_structure_not_uax() { let input = diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 13cbd10..07f1e1c 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -79,6 +79,10 @@ fn parse_line_based(input: &str) -> Vec { // A blank closes the comment (GitHub #176). let mut in_comment = false; let mut comment_indent: usize = 0; + // Anonymous-target link block: indented lines after `__` / `__ uri` + // stay Structure until a blank (Docutils get_first_known_indented). + let mut in_anon_block = false; + let mut anon_indent: usize = 0; // Hang column of the current list item (`- ` → 2) or block quote. // Continuation paragraphs after a blank stay in the item when // indented this far. @@ -267,6 +271,22 @@ fn parse_line_based(input: &str) -> Vec { } } + // Anonymous-target link block. A blank ends it, same as a comment, + // so a later indent is a block quote rather than more target text. + if in_anon_block { + if line_text.trim().is_empty() { + in_anon_block = false; + } else { + let leading = line_text.len() - line_text.trim_start().len(); + if leading >= anon_indent { + regions.push(SpannedRegion::structure(input, line.span())); + i += 1; + continue; + } + in_anon_block = false; + } + } + // Blank line if line_text.trim().is_empty() { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); @@ -443,6 +463,9 @@ fn parse_line_based(input: &str) -> Vec { if is_rst_anonymous_target(trimmed) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); regions.push(SpannedRegion::structure(input, line.span())); + let leading = line_text.len() - trimmed.len(); + anon_indent = leading + 1; + in_anon_block = true; i += 1; continue; } @@ -482,8 +505,13 @@ fn parse_line_based(input: &str) -> Vec { continue; } - // Section underline - if is_underline(line_text) { + // Section underline. A paragraph whose text is only `::`, at the + // start of the document or after a blank, is a literal marker + // (Docutils), not a two-colon adornment. `Hi\n::` has a non-blank + // previous line, so `is_underline` still promotes that title. + let bare_colon_literal = + line_text.trim() == "::" && (i == 0 || lines[i - 1].text.trim().is_empty()); + if is_underline(line_text) && !bare_colon_literal { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); regions.push(SpannedRegion::structure(input, line.span())); i += 1; @@ -1264,7 +1292,7 @@ pub(crate) fn rst_line_block_marker_len(line: &str) -> Option { } /// Docutils `Body.grid_table_top_pat`: `\+-[-+]+-\+ *$`. -fn is_rst_grid_table_top(trimmed: &str) -> bool { +pub(crate) fn is_rst_grid_table_top(trimmed: &str) -> bool { GRID_TABLE_TOP_RE.is_match(trimmed) } @@ -1500,7 +1528,8 @@ pub(crate) fn is_underline(line: &str) -> bool { /// RST simple-table border: `=` column groups separated by spaces /// (`===== =====`). A solid `=====` is a section underline, not a table. -fn is_simple_table_border(line: &str) -> bool { +/// Wrap skip-cut uses this so leftover `===== =====` cannot park at column 0. +pub(crate) fn is_simple_table_border(line: &str) -> bool { let t = line.trim(); if t.len() < 3 { return false; @@ -1847,6 +1876,88 @@ mod tests { assert!(structure_count >= 3); } + #[test] + fn bare_colon_paragraph_opens_a_literal_block() { + // Docutils: a paragraph whose text is only `::` is an empty + // paragraph plus a literal block. It is not a section underline. + let input = "::\n\n kept verbatim. Not wrapped.\n\nAfter. Next.\n"; + let regions = RstParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "::")), + "bare :: stays the literal marker, got {regions:?}" + ); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("kept verbatim. Not wrapped.")) + ), + "indented body stays literal structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("kept verbatim"))), + "literal body must not be a block quote, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("kept verbatim. Not wrapped.\n"), + "literal body must not sentence-split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the literal must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn two_char_colon_underline_stays_a_section() { + let input = "Hi\n::\n\nAfter. Next.\n"; + let regions = RstParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "Hi")), + "two-character title stays structure, got {regions:?}" + ); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "::")), + "colon underline stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("kept") || p.trim() == "Hi")), + "title must not become prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Hi\n::\n"), + "section adornment must stay, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the section must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn quoted_literal_block_lines_are_structure() { let input = concat!( @@ -5198,6 +5309,47 @@ mod tests { ); } + #[test] + fn anonymous_target_keeps_its_indented_link_block() { + // Docutils anonymous_target: get_first_known_indented until a blank. + let input = "__\n See the target. Next sentence.\n\nAfter. Next.\n"; + let regions = RstParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "__")), + "anonymous opener stays structure, got {regions:?}" + ); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("See the target. Next sentence.")) + ), + "indented link block stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("See the target"))), + "link block must not be a block quote, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("See the target. Next sentence.\n"), + "link block must not sentence-split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the target must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn indented_anonymous_target_is_structure() { let input = " __ https://example.com/a/very/long/path\n"; diff --git a/src/reflow.rs b/src/reflow.rs index 2112a0e..17ee507 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -443,23 +443,32 @@ fn is_ordered_list_marker(word: &str) -> bool { (1..=9).contains(&digits.len()) && digits.iter().all(|b| b.is_ascii_digit()) } +/// Org ordered item. Emacs `org-item-re` is `[0-9]+`, not CommonMark's +/// 1–9 digit cap (`md_ordered_list_start`). fn ordered_list_start(text: &str) -> bool { let bytes = text.as_bytes(); let mut i = 0; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } - if i == 0 || i > 9 { + if i == 0 { return false; } - matches!(bytes.get(i), Some(b'.') | Some(b')')) && matches!(bytes.get(i + 1), Some(b' ') | None) + matches!(bytes.get(i), Some(b'.') | Some(b')')) + && matches!(bytes.get(i + 1), Some(b' ') | Some(b'\t') | None) } fn thematic_or_setext_token(text: &str) -> bool { let first = text.split_whitespace().next().unwrap_or(""); - if first.len() >= 3 { + if !first.is_empty() { let b = first.as_bytes()[0]; - if matches!(b, b'-' | b'=' | b'*' | b'_') && first.bytes().all(|c| c == b) { + let solid = first.bytes().all(|c| c == b); + // CommonMark setext underlines are one or more `=` or `-`. + // Thematic breaks (`*`, `_`, and `-` of length >= 3) stay below. + if solid && matches!(b, b'=' | b'-') { + return true; + } + if solid && first.len() >= 3 && matches!(b, b'*' | b'_') { return true; } } @@ -674,7 +683,14 @@ fn org_opens_block(line: &str) -> bool { } // Emacs org-item-re: `-`/`+` then space or EOL. Lone markers must // not be wrap-created at column 0 (GitHub #320). - if t == "-" || t == "+" || t.starts_with("- ") || t.starts_with("+ ") { + // Emacs org-item-re: bullet then space, tab, or EOL. + if t == "-" + || t == "+" + || t.starts_with("- ") + || t.starts_with("+ ") + || t.starts_with("-\t") + || t.starts_with("+\t") + { return true; } if t.starts_with("$$") { @@ -768,11 +784,16 @@ fn latex_opens_block(line: &str) -> bool { "\\subsubsection", "\\paragraph", "\\subparagraph", + "\\addsec", + "\\addchap", + "\\addpart", ]; for cmd in CMDS { if let Some(after) = t.strip_prefix(cmd) { + // Optional short title is `[...]` before the brace (KOMA too). if after.is_empty() || after.starts_with('{') + || after.starts_with('[') || after.starts_with('*') || after.starts_with(' ') { @@ -809,6 +830,32 @@ fn rst_opens_block(line: &str) -> bool { if crate::parser::rst::is_underline(t) { return true; } + // Docutils isolate_simple_table: `===== =====` has interior spaces, + // so is_underline is false. Wrap-created leftover must skip-cut. + if crate::parser::rst::is_simple_table_border(t) { + return true; + } + // Docutils grid_table_top: `+---+` is not an underline and not a + // simple-table border. A wrap cut that parks it at column 0 mints a table. + if crate::parser::rst::is_rst_grid_table_top(t) { + return true; + } + // sphinx-jinja / Jinja2 `{% ... %}`. Not an underline. A wrap cut + // that parks the statement at column 0 mints a structure line. + if crate::parser::rst::is_rst_jinja_statement(t) { + return true; + } + // Docutils anonymous hyperlink target `__` / `__ uri`. A solid `__` + // is already an underline; `__ uri` is not. + if crate::parser::rst::is_rst_anonymous_target(t) { + return true; + } + // Leftover `+` fragment. A grid-table top is `+---+` and a list + // marker is `+ `. Any other column-0 `+` line (`+===+`, `+foo`) + // is Structure, including the words after the fragment. + if t.starts_with('+') { + return true; + } false } @@ -3150,6 +3197,81 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn latex_verb_with_space_is_not_split_by_wrap() { + // Sentence splitting keeps `\verb|foo bar|` whole. Width wrap + // still splits on the space inside the delimiter. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 16, + ..Default::default() + } + .without_safety_backstops(); + let input = "Use \\verb|foo bar| here. Next sentence.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("\\verb|foo bar|"), + "verb span must stay one token, got:\n{out}" + ); + assert!( + !out.contains("\\verb|foo\n") && !out.contains("\\verb|foo\r"), + "wrap must not break inside the verb, got:\n{out}" + ); + assert!( + out.contains("Next sentence."), + "following sentence must remain, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn wrap_created_latex_section_optional_and_koma_are_not_blocks() { + // Optional [short] and KOMA \addsec/\addchap/\addpart are sectioning + // lines. Width 23 would park the command at column 0. + for token in [ + "\\section[Short]{Long}", + "\\addsec{Title}", + "\\addchap{Title}", + "\\addpart{Title}", + ] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words."), + 23, + crate::format::Format::Latex, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "LaTeX skip-cut keeps {token}:\n{result}" + ); + } + } + + #[test] + fn wrap_created_latex_section_optional_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples \\section[Short]{Long} extra words.\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.starts_with("\\section")), + "wrap must not park a column-0 section command:\n{out}" + ); + assert!( + out.contains("apples \\section[Short]{Long}"), + "skip-cut must keep the section command:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_rst_directive_is_not_a_block() { // G. RST .. @@ -3208,6 +3330,173 @@ They are endowed with reason and conscience and should act towards one another i assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn wrap_created_rst_simple_table_border_is_not_a_block() { + // snapper-2knx: Docutils isolate_simple_table. Interior spaces + // mean is_underline is false, so skip-cut must name the border. + let result = wrap_fmt( + "The options are apples ===== =====", + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &["===== =====", "====="]); + assert!( + result.contains("apples ===== ====="), + "RST skip-cut keeps the simple-table border:\n{result}" + ); + } + + #[test] + fn wrap_created_rst_simple_table_border_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples ===== =====\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines() + .any(|l| l.trim() == "===== =====" || l.trim() == "====="), + "wrap must not park a column-0 simple-table border:\n{out}" + ); + assert!( + out.contains("apples ===== ====="), + "skip-cut must keep ===== ===== with the previous line:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn wrap_created_rst_grid_table_top_is_not_a_block() { + // Docutils Body.grid_table_top. Width 23 would park +---+ at column 0. + for token in ["+---+", "+---+---+"] { + let result = wrap_fmt( + &format!("The options are apples {token}"), + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "RST skip-cut keeps the {token} grid top:\n{result}" + ); + } + } + + #[test] + fn wrap_created_rst_grid_table_top_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples +---+\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.trim() == "+---+"), + "wrap must not park a column-0 grid-table top:\n{out}" + ); + assert!( + out.contains("apples +---+"), + "skip-cut must keep +---+ with the previous line:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn wrap_created_rst_jinja_statement_is_not_a_block() { + for token in ["{%endfor%}", "{%-endfor-%}", "{%+endfor+%}"] { + let result = wrap_fmt( + &format!("The options are apples {token}"), + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "RST skip-cut keeps the {token} jinja statement:\n{result}" + ); + } + let spaced = wrap_fmt( + r#"The options are apples {% set x = 1 %}"#, + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&spaced, &["{% set x = 1 %}", "{%"]); + assert!( + spaced.contains("{%"), + "jinja opener stays with the previous line:\n{spaced}" + ); + } + + #[test] + fn wrap_created_rst_anonymous_target_is_not_a_block() { + let result = wrap_fmt( + "The options are apples __ https://x.test", + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &["__ https://x.test", "__"]); + assert!( + result.contains("apples __"), + "anonymous target marker stays with the previous line:\n{result}" + ); + } + + #[test] + fn wrap_created_rst_plus_fragment_is_not_a_block() { + // Leftover `+` that is not `grid_table_top` (`+---+`). Width 23 + // would park `+===+` at column 0 and freeze the rest of the line. + for token in ["+===+", "+===+===+", "+foo"] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words."), + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &[token, "+"]); + assert!( + result.contains(&format!("apples {token}")), + "RST skip-cut keeps the {token} plus fragment:\n{result}" + ); + } + } + + #[test] + fn wrap_created_rst_plus_fragment_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples +===+ extra words.\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.starts_with('+')), + "wrap must not park a column-0 plus fragment:\n{out}" + ); + assert!( + out.contains("apples +===+"), + "skip-cut must keep +===+ with the previous line:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_org_table_pipe_is_not_a_block() { // H. Org | @@ -3293,6 +3582,9 @@ They are endowed with reason and conscience and should act towards one another i "man:org", "docview:/tmp/a.pdf", "shortdoc:org", + "bibtex:file.bib", + "eshell:ls", + "w3m:index.html", ] { let result = wrap_fmt( &format!("The options are apples {token} extra words here."), @@ -3325,6 +3617,50 @@ They are endowed with reason and conscience and should act towards one another i } } + #[test] + fn wrap_created_md_short_setext_is_escaped() { + // CommonMark setext underlines are one or more = or -. A run of + // length 1 or 2 is not a thematic break, and it still promotes + // the previous line when it is the whole next line. + for token in ["=", "==", "--"] { + let result = wrap_fmt( + &format!("The options are apples {token}"), + 23, + crate::format::Format::Markdown, + ); + assert!( + !result.lines().any(|l| l.trim() == token), + "wrap must not leave a column-0 setext underline {token:?}:\n{result}" + ); + let escaped = format!("\\{token}"); + assert!( + result.lines().any(|l| l.trim() == escaped), + "short setext {token:?} must be markdown-escaped:\n{result}" + ); + } + } + + #[test] + fn wrap_created_md_short_setext_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples ==\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.trim() == "=="), + "wrap must not emit a setext underline:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_md_empty_list_marker_is_not_a_block() { for token in ["-", "*", "+"] { @@ -3344,6 +3680,47 @@ They are endowed with reason and conscience and should act towards one another i } } + #[test] + fn wrap_created_org_long_ordered_marker_is_not_a_block() { + // Org ordered markers have no digit cap. Width 23 would park + // 1234567890. at column 0 and the next parse reads an item. + let result = wrap_fmt( + "The options are apples 1234567890. extra words here.", + 23, + crate::format::Format::Org, + ); + assert_no_col0_block(&result, &["1234567890.", "1234567890"]); + assert!( + result.contains("apples 1234567890."), + "Org skip-cut keeps the long marker:\n{result}" + ); + } + + #[test] + fn wrap_created_org_long_ordered_marker_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples 1234567890. extra words here.\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.starts_with("1234567890.")), + "wrap must not park a column-0 ordered marker:\n{out}" + ); + assert!( + out.contains("apples 1234567890."), + "skip-cut must keep the marker:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_org_empty_list_marker_is_not_a_block() { for token in ["-", "+", "1.", "1)"] { diff --git a/src/sentence/unicode.rs b/src/sentence/unicode.rs index 2e7dfb7..35823ce 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -2929,6 +2929,7 @@ fn find_md_code_span(text: &str, open_at: usize) -> Option { /// Org `{{{name}}}` / `{{{name(args)}}}`, Org timestamps /// (``, `[YYYY-MM-DD…]`, ranges `--`, diary `<%%(...)>`), /// Org latex-fragments (`\(...\)`, `$...$`, `\cmd{arg}`, `\cmd[opt]{arg}`), +/// LaTeX `\verb|...|` and the other delimiter verbs, /// Org `src_lang{...}` / `call_name(...)`, Org brace `H_{...}` / `x^{...}` /// (org-match-substring-regexp), RST `|fig. 1|` / `|name|_` / `|name|__`, /// paired spans). @@ -2963,6 +2964,15 @@ pub fn atomic_inline_spans(text: &str) -> Vec<(usize, usize)> { i = end; continue; } + if bytes[i] == b'\\' { + // `\verb|foo bar|` is one token. Sentence splitting already + // hides it; width wrap still sees the restored spaces. + if let Some(end) = latex_verb_span_end_with(text, i, &[]) { + spans.push((i, end)); + i = end; + continue; + } + } if bytes[i] == b'`' { if let Some(end) = find_md_code_span(text, i) { spans.push((i, end)); diff --git a/tests/fixtures/abbreviations.txt b/tests/fixtures/abbreviations.txt new file mode 100644 index 0000000..547a32e --- /dev/null +++ b/tests/fixtures/abbreviations.txt @@ -0,0 +1 @@ +Dr. Smith went home. He was tired. See Fig. 3 for details. Use e.g. snapper here. diff --git a/tests/fixtures/math.tex b/tests/fixtures/math.tex new file mode 100644 index 0000000..3934cbd --- /dev/null +++ b/tests/fixtures/math.tex @@ -0,0 +1,7 @@ +See $a. b$ inline. Next. + +\begin{equation} +E = mc^2. not prose +\end{equation} + +After the equation. Next. diff --git a/tests/fixtures/md_fences.md b/tests/fixtures/md_fences.md new file mode 100644 index 0000000..51d1f78 --- /dev/null +++ b/tests/fixtures/md_fences.md @@ -0,0 +1,8 @@ +Before the fence. Next sentence. + +```python +x = 1. 2 +print("stay") +``` + +After the fence. Next sentence. diff --git a/tests/fixtures/org_blocks.org b/tests/fixtures/org_blocks.org new file mode 100644 index 0000000..27eb134 --- /dev/null +++ b/tests/fixtures/org_blocks.org @@ -0,0 +1,10 @@ +#+BEGIN_SRC python +x = 1. 2 +print("stay") +#+END_SRC + +#+BEGIN_NOTE +Quoted one. Quoted two. +#+END_NOTE + +After. Next. diff --git a/tests/fixtures/wrapped_lists.md b/tests/fixtures/wrapped_lists.md new file mode 100644 index 0000000..429bb49 --- /dev/null +++ b/tests/fixtures/wrapped_lists.md @@ -0,0 +1,5 @@ +- First item is a long sentence that should wrap at a modest width without minting a new block. Second sentence stays in the item. + +- Second item is shorter. Another. + +After the list. Next. diff --git a/tests/md_html_type6_close.rs b/tests/md_html_type6_close.rs index 7bcc836..1eb05dc 100644 --- a/tests/md_html_type6_close.rs +++ b/tests/md_html_type6_close.rs @@ -1,6 +1,6 @@ -//! GitHub #332 / snapper-v85k: CommonMark 0.31.2 sec 4.6 HTML type 6. -//! A closed `
…
` must not include the next paragraph. -//! `After html.` / `Next.` stay Prose and still split. Type-1 `
`
+//! CommonMark 0.31.2 sec 4.6 HTML type 6 ends at a blank line.
+//! Text after `` and before a blank stays in the block.
+//! A blank line lets the next paragraph split. Type-1 `
`
 //! still ends at `
`. use snapper_fmt::format::Format; @@ -35,8 +35,7 @@ fn expected_ticket() -> &'static str { "
\n", "First. Second.\n", "
\n", - "After html.\n", - "Next.\n", + "After html. Next.\n", ) } @@ -52,8 +51,8 @@ fn closed_div_is_structure_through_close() { assert!(div.contains("First. Second."), "{div}"); assert!(div.contains(""), "{div}"); assert!( - !div.contains("After html"), - "closed type-6 must end at , got {div}" + div.contains("After html. Next."), + "text before a blank stays in the type-6 block, got {div}" ); assert!( !regions.iter().any(|r| matches!( @@ -65,14 +64,26 @@ fn closed_div_is_structure_through_close() { } #[test] -fn after_html_stays_prose() { - let regions = MarkdownParser.parse(ticket_fixture()); +fn blank_line_ends_type6_and_the_next_paragraph_splits() { + let input = concat!( + "
\n", + "First. Second.\n", + "
\n", + "\n", + "After html. Next.\n", + ); + let regions = MarkdownParser.parse(input); assert!( regions.iter().any(|r| matches!( r, Region::Prose(p) if p.contains("After html.") && p.contains("Next.") )), - "After html. / Next. must stay Prose, got {regions:?}" + "a blank line ends the block, got {regions:?}" + ); + let out = format_text(input, &md_cfg()).unwrap(); + assert!( + out.contains("After html.\nNext.\n"), + "paragraph after the blank still splits, got:\n{out}" ); } diff --git a/tests/md_html_type7_close.rs b/tests/md_html_type7_close.rs index d846494..e2d51d4 100644 --- a/tests/md_html_type7_close.rs +++ b/tests/md_html_type7_close.rs @@ -1,7 +1,5 @@ -//! GitHub #356 / snapper-615s: CommonMark 0.31.2 sec 4.6 HTML type 7. -//! A closed `…` must not include the next paragraph. -//! `After html.` / `Next.` stay Prose and still split. Type-6 close -//! (`
…
`) and quoted HTML stay intact. +//! CommonMark 0.31.2 sec 4.6 HTML type 7 ends at a blank line. +//! Text after `` and before a blank stays in the block. use snapper_fmt::format::Format; use snapper_fmt::parser::markdown::MarkdownParser; @@ -35,8 +33,7 @@ fn expected_ticket() -> &'static str { "\n", "First. Second.\n", "\n", - "After html.\n", - "Next.\n", + "After html. Next.\n", ) } @@ -52,8 +49,8 @@ fn closed_span_is_structure_through_close() { assert!(span.contains("First. Second."), "{span}"); assert!(span.contains(""), "{span}"); assert!( - !span.contains("After html"), - "closed type-7 must end at , got {span}" + span.contains("After html. Next."), + "text before a blank stays in the type-7 block, got {span}" ); assert!( !regions.iter().any(|r| matches!( @@ -65,14 +62,21 @@ fn closed_span_is_structure_through_close() { } #[test] -fn after_html_stays_prose() { - let regions = MarkdownParser.parse(ticket_fixture()); +fn blank_line_ends_type7_and_the_next_paragraph_splits() { + let input = concat!( + "\n", + "First. Second.\n", + "\n", + "\n", + "After html. Next.\n", + ); + let regions = MarkdownParser.parse(input); assert!( regions.iter().any(|r| matches!( r, Region::Prose(p) if p.contains("After html.") && p.contains("Next.") )), - "After html. / Next. must stay Prose, got {regions:?}" + "a blank line ends the block, got {regions:?}" ); } @@ -85,7 +89,7 @@ fn ticket_fixture_keeps_span_and_splits_next() { } #[test] -fn type6_closed_div_still_ends_at_close() { +fn type6_closed_div_runs_to_a_blank_line() { let input = concat!( "Intro sentence here. Another intro sentence.\n", "
\n", @@ -101,8 +105,8 @@ fn type6_closed_div_still_ends_at_close() { let div = div.expect(&format!("div block must be Structure, got {regions:?}")); assert!(div.contains("
"), "{div}"); assert!( - !div.contains("After html"), - "type-6 close intact, got {div}" + div.contains("After html. Next."), + "type-6 runs to a blank line, got {div}" ); let out = format_text(input, &md_cfg()).unwrap(); assert_eq!( @@ -113,10 +117,9 @@ fn type6_closed_div_still_ends_at_close() { "
\n", "First. Second.\n", "
\n", - "After html.\n", - "Next.\n", + "After html. Next.\n", ), - "type-6 close must stay intact, got:\n{out}" + "type-6 runs to a blank line, got:\n{out}" ); } diff --git a/tests/md_list_hang_code.rs b/tests/md_list_hang_code.rs index c0e3769..27139bd 100644 --- a/tests/md_list_hang_code.rs +++ b/tests/md_list_hang_code.rs @@ -1,5 +1,6 @@ -//! List item then hang+4 indented code with no blank line stays Code. -//! Following item prose still splits. pulldown / CommonMark 5.2. +//! hang+4 with no blank continues the open list paragraph. +//! CommonMark 4.4: indented code cannot interrupt a paragraph. +//! A blank line, then hang+4, stays indented code. use snapper_fmt::format::Format; use snapper_fmt::parser::markdown::MarkdownParser; @@ -24,21 +25,18 @@ fn ticket_fixture() -> &'static str { } #[test] -fn hang_plus_four_without_blank_is_code() { +fn hang_plus_four_without_blank_joins_the_item() { let regions = MarkdownParser.parse(ticket_fixture()); assert!( - regions.iter().any(|r| matches!( - r, - Region::Code { body, .. } if body.contains("indented. not split") - )), - "hang+4 without a blank must be Code, got {regions:?}" + !regions.iter().any(|r| matches!(r, Region::Code { .. })), + "hang+4 without a blank must not be Code, got {regions:?}" ); assert!( - !regions.iter().any(|r| matches!( + regions.iter().any(|r| matches!( r, Region::Prose(p) if p.contains("indented. not split") )), - "hang+4 line must not be Prose, got {regions:?}" + "hang+4 line joins the item, got {regions:?}" ); } @@ -47,12 +45,12 @@ fn hang_plus_four_stays_and_following_item_splits() { let input = ticket_fixture(); let out = format_text(input, &md_cfg()).unwrap(); assert!( - out.contains(" indented. not split\n"), - "hang+4 code must stay intact, got:\n{out}" + out.contains("indented. not split"), + "joined hang+4 text must stay in the item, got:\n{out}" ); assert!( - !out.contains(" indented.\n"), - "must not sentence-split hang+4 code, got:\n{out}" + !out.contains(" indented"), + "hang+4 without a blank must not stay a code line, got:\n{out}" ); assert!( out.contains("- Item one is a sentence.\n Second sentence."), diff --git a/tests/org_file_token_punct.rs b/tests/org_file_token_punct.rs index b2d08be..a4f929e 100644 --- a/tests/org_file_token_punct.rs +++ b/tests/org_file_token_punct.rs @@ -431,6 +431,35 @@ fn leftover_shortdoc_plain_link_after_path_hangs_and_splits() { assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); } +#[test] +fn leftover_bibtex_eshell_w3m_plain_link_after_path_hangs_and_splits() { + // snapper-zmue: ol-bibtex / ol-eshell / ol-w3m prefixes were missing. + for (path, needle) in [ + ("bibtex:file.bib leftover. Next.\n", "bibtex:file.bib"), + ("eshell:ls leftover. Next.\n", "eshell:ls"), + ("w3m:index.html leftover. Next.\n", "w3m:index.html"), + ] { + let input = format!("{path}After. Next.\n"); + let regions = OrgParser.parse(&input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.contains(needle))), + "{needle} path must stay Structure, got {regions:?}" + ); + let out = format_text(&input, &org_cfg()).unwrap(); + assert!( + !out.contains(&format!("{needle} leftover. Next.")), + "leftover after {needle} must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split after {needle}, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); + } +} + #[test] fn org_file_token_same_line_splits() { let two_line = "See file:/tmp/foo.\nNext sentence.\n"; diff --git a/tests/snapper_19jf.rs b/tests/snapper_19jf.rs new file mode 100644 index 0000000..b89244f --- /dev/null +++ b/tests/snapper_19jf.rs @@ -0,0 +1,388 @@ +//! Fixtures for abbreviations, LaTeX math, Org blocks, Markdown fences, +//! and wrapped lists. `sdiff` is empty when only wrapping differs. +//! Default break rules stay unless a fixture requires a change. +//! Both `snapper` and `snapper-fmt` binaries stay. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use snapper_fmt::format::Format; +use snapper_fmt::parser::latex::LatexParser; +use snapper_fmt::parser::markdown::MarkdownParser; +use snapper_fmt::parser::org::OrgParser; +use snapper_fmt::parser::{FormatParser, Region}; +use snapper_fmt::sdiff::sentence_diff; +use snapper_fmt::{FormatConfig, format_text}; + +fn cfg(format: Format) -> FormatConfig { + FormatConfig { + format, + max_width: 0, + ..Default::default() + } + .without_safety_backstops() +} + +fn wrap_cfg(format: Format, max_width: usize) -> FormatConfig { + FormatConfig { + format, + max_width, + ..Default::default() + } + .without_safety_backstops() +} + +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +fn fixture(name: &str) -> String { + fs::read_to_string(fixture_path(name)).unwrap() +} + +fn sdiff_texts(old: &str, new: &str, format: Format, stem: &str) -> String { + let dir = std::env::temp_dir(); + let old_path = dir.join(format!("snapper_19jf_{stem}_old.txt")); + let new_path = dir.join(format!("snapper_19jf_{stem}_new.txt")); + fs::write(&old_path, old).unwrap(); + fs::write(&new_path, new).unwrap(); + let result = sentence_diff(&old_path, &new_path, Some(format), false).unwrap(); + let _ = fs::remove_file(&old_path); + let _ = fs::remove_file(&new_path); + result +} + +fn assert_sdiff_empty_wrap_only(input: &str, format: Format, stem: &str, wrap_width: usize) { + let sentence = format_text(input, &cfg(format)).unwrap(); + let wrapped = format_text(input, &wrap_cfg(format, wrap_width)).unwrap(); + let vs_sentence = sdiff_texts(input, &sentence, format, &format!("{stem}_sent")); + assert!( + vs_sentence.is_empty(), + "sdiff must be empty after sentence wrap ({stem}), got:\n{vs_sentence}\n--- input ---\n{input}--- formatted ---\n{sentence}" + ); + let vs_width = sdiff_texts(input, &wrapped, format, &format!("{stem}_width")); + assert!( + vs_width.is_empty(), + "sdiff must be empty after max_width wrap ({stem}), got:\n{vs_width}\n--- input ---\n{input}--- wrapped ---\n{wrapped}" + ); +} + +#[test] +fn abbreviations_do_not_split_titles_or_latin() { + let input = fixture("abbreviations.txt"); + let out = format_text(&input, &cfg(Format::Plaintext)).unwrap(); + assert!( + out.contains("Dr. Smith went home.\n"), + "Dr. must stay in the title sentence, got:\n{out}" + ); + assert!( + !out.contains("Dr.\n"), + "Dr. must not be its own sentence, got:\n{out}" + ); + assert!( + out.contains("See Fig. 3 for details.\n"), + "Fig. must stay with the figure number, got:\n{out}" + ); + assert!( + out.contains("Use e.g. snapper here.\n"), + "e.g. must stay with the example, got:\n{out}" + ); + assert!( + out.contains("He was tired.\n"), + "the sentence after Dr. must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Plaintext)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Plaintext, "abbr", 24); +} + +#[test] +fn latex_math_stays_atomic_and_following_splits() { + let input = fixture("math.tex"); + let regions = LatexParser::default().parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains("E = mc^2. not prose") + )), + "equation body must stay Structure, got {regions:?}" + ); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("mc^2. not prose") + )), + "equation body must not be Prose, got {regions:?}" + ); + let out = format_text(&input, &cfg(Format::Latex)).unwrap(); + assert!( + out.contains("$a. b$"), + "inline math with an interior period must stay one token, got:\n{out}" + ); + assert!( + !out.contains("$a.\n"), + "must not wrap inside inline math, got:\n{out}" + ); + assert!( + out.contains("E = mc^2. not prose"), + "display math must not reflow as prose, got:\n{out}" + ); + assert!( + out.contains("See $a. b$ inline.\nNext.\n"), + "prose around inline math must still split, got:\n{out}" + ); + assert!( + out.contains("After the equation.\nNext.\n"), + "prose after display math must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Latex)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Latex, "math", 20); +} + +#[test] +fn org_blocks_keep_src_and_split_note() { + let input = fixture("org_blocks.org"); + let regions = OrgParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Code { body, .. } if body.contains("x = 1. 2") + )), + "BEGIN_SRC body must be Code, got {regions:?}" + ); + let out = format_text(&input, &cfg(Format::Org)).unwrap(); + assert!( + out.contains("x = 1. 2\nprint(\"stay\")\n"), + "source block body must not split on interior periods, got:\n{out}" + ); + assert!( + out.contains("#+BEGIN_NOTE\nQuoted one.\nQuoted two.\n#+END_NOTE\n"), + "NOTE fences stay; inner prose must split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext.\n"), + "prose after the blocks must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Org)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Org, "org_blocks", 20); +} + +#[test] +fn markdown_fences_stay_and_following_splits() { + let input = fixture("md_fences.md"); + let regions = MarkdownParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Code { body, .. } if body.contains("x = 1. 2") + )), + "fenced body must be Code, got {regions:?}" + ); + let out = format_text(&input, &cfg(Format::Markdown)).unwrap(); + assert!( + out.contains("```python\nx = 1. 2\nprint(\"stay\")\n```\n"), + "fence body must not split on interior periods, got:\n{out}" + ); + assert!( + out.contains("Before the fence.\nNext sentence.\n"), + "prose before the fence must still split, got:\n{out}" + ); + assert!( + out.contains("After the fence.\nNext sentence.\n"), + "prose after the fence must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Markdown)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Markdown, "md_fences", 20); +} + +#[test] +fn wrapped_lists_hang_and_do_not_mint_a_block() { + let input = fixture("wrapped_lists.md"); + let out = format_text(&input, &cfg(Format::Markdown)).unwrap(); + assert!( + out.contains("- First item is a long sentence that should wrap at a modest width without minting a new block.\n Second sentence stays in the item.\n"), + "list item must hang the second sentence, got:\n{out}" + ); + assert!( + out.contains("After the list.\nNext.\n"), + "prose after the list must still split, got:\n{out}" + ); + let wrapped = format_text(&input, &wrap_cfg(Format::Markdown, 28)).unwrap(); + assert!( + wrapped.contains("- First item"), + "list marker must stay, got:\n{wrapped}" + ); + let markers = |s: &str| s.lines().filter(|l| l.starts_with("- ")).count(); + assert_eq!( + markers(&wrapped), + markers(&input), + "wrap must not mint a new list marker, got:\n{wrapped}" + ); + assert!( + !wrapped.contains("```") && !wrapped.contains("# "), + "wrap must not mint a fence or heading, got:\n{wrapped}" + ); + assert_eq!(format_text(&out, &cfg(Format::Markdown)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Markdown, "lists", 28); +} + +#[test] +fn default_clause_breaks_stay_off() { + let default = FormatConfig::default(); + assert!( + !default.clause_breaks, + "clause_breaks default must stay false" + ); + assert_eq!(default.max_width, 0, "max_width default must stay 0"); + let input = "Hello, world; still one sentence.\n"; + let out = format_text(input, &cfg(Format::Plaintext)).unwrap(); + assert_eq!( + out, input, + "default break rules must not split on comma or semicolon, got:\n{out}" + ); +} + +#[test] +fn snapper_and_snapper_fmt_binaries_exist() { + let snapper = Path::new(env!("CARGO_BIN_EXE_snapper")); + let snapper_fmt = snapper.with_file_name("snapper-fmt"); + assert!( + snapper.exists(), + "snapper binary missing at {}", + snapper.display() + ); + assert!( + snapper_fmt.exists(), + "snapper-fmt binary missing at {}", + snapper_fmt.display() + ); + for bin in [snapper, snapper_fmt.as_path()] { + let output = Command::new(bin) + .arg("--version") + .output() + .unwrap_or_else(|e| panic!("{} --version failed: {e}", bin.display())); + assert!( + output.status.success(), + "{} --version status {:?}", + bin.display(), + output.status + ); + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("snapper"), + "{} --version must name snapper, got {text}", + bin.display() + ); + } +} + +#[test] +fn init_writes_working_git_hook() { + let dir = tempfile::tempdir().unwrap(); + let git_init = Command::new("git") + .args(["-c", "init.templateDir=", "init"]) + .current_dir(dir.path()) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .output() + .expect("git init"); + assert!( + git_init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&git_init.stderr) + ); + fs::write(dir.path().join("note.md"), "One. Two.\n").unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_snapper")) + .arg("init") + .current_dir(dir.path()) + .output() + .expect("snapper init"); + assert!( + output.status.success(), + "snapper init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("pre-commit"), + "init must mention the hook, stderr={stderr}" + ); + + let hooks = Command::new("git") + .args(["rev-parse", "--git-path", "hooks"]) + .current_dir(dir.path()) + .output() + .expect("git rev-parse hooks"); + assert!(hooks.status.success()); + let hooks_rel = String::from_utf8_lossy(&hooks.stdout).trim().to_string(); + let hooks_dir = if Path::new(&hooks_rel).is_absolute() { + PathBuf::from(&hooks_rel) + } else { + dir.path().join(&hooks_rel) + }; + let hook = hooks_dir.join("pre-commit"); + assert!(hook.exists(), "init must write {}", hook.display()); + let hook_text = fs::read_to_string(&hook).unwrap(); + assert!( + hook_text.contains("Generated by `snapper init`"), + "hook must be the generated script, got:\n{hook_text}" + ); + assert!( + hook_text.contains("--native"), + "working hook must force native parsers, got:\n{hook_text}" + ); + assert!( + hook_text.contains("snapper-fmt"), + "hook must fall back to snapper-fmt, got:\n{hook_text}" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&hook).unwrap().permissions().mode(); + assert!(mode & 0o111 != 0, "hook must be executable, mode={mode:o}"); + } + + let filter = Command::new("git") + .args(["config", "--get", "filter.snapper.clean"]) + .current_dir(dir.path()) + .output() + .expect("git config"); + let clean = String::from_utf8_lossy(&filter.stdout); + assert!( + clean.contains("snapper-fmt --native --stdin-filepath %f"), + "init must configure a working clean filter, got {clean}" + ); + + let bin_dir = Path::new(env!("CARGO_BIN_EXE_snapper")) + .parent() + .expect("snapper bin dir"); + let mut paths = vec![bin_dir.to_path_buf()]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + let path = std::env::join_paths(paths).expect("PATH join"); + let add = Command::new("git") + .args(["-c", "filter.snapper.clean=cat", "add", "note.md"]) + .current_dir(dir.path()) + .status() + .unwrap(); + assert!(add.success(), "git add note.md must succeed"); + let hook_run = Command::new(&hook) + .current_dir(dir.path()) + .env("PATH", &path) + .output() + .expect("run generated hook"); + assert!( + hook_run.status.success(), + "generated hook must run, stderr={} stdout={}", + String::from_utf8_lossy(&hook_run.stderr), + String::from_utf8_lossy(&hook_run.stdout) + ); + let staged = fs::read_to_string(dir.path().join("note.md")).unwrap(); + assert_eq!( + staged, "One.\nTwo.\n", + "working hook must format staged prose, got {staged:?}" + ); +}