Skip to content

Commit 432aa61

Browse files
author
merge-queue-bot
committed
Merge PR #709: Add opt-in reflow auto-fix to line-length (MDS001)
2 parents 86dc716 + 23f1eb0 commit 432aa61

17 files changed

Lines changed: 1125 additions & 11 deletions

docs/background/markdown-linters.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ lack determinism.
665665
| ------------------ | --------------------- | ------------------------ | ------------ | --------------- | ---- | --------------------- | ----------------- |
666666
| Autofix CLI | `fix` | `--write` | `--fix` | `--fix` / `fmt` | no | `format` | yes (AST rewrite) |
667667
| Table alignment | [MDS025][mds025] | yes | no | MD055/56/58 | no | yes | via plugin |
668-
| Prose wrapping | no | [`proseWrap`][prosewrap] | no | no | no | no | no |
668+
| Prose wrapping | opt-in (reflow) | [`proseWrap`][prosewrap] | no | no | no | no | no |
669669
| Embedded code fmt | no | JS/TS/CSS/JSON | no | no | no | delegates to external | no |
670670
| Multi-pass fix | yes | single pass | single pass | single pass | no | single pass | single pass |
671671
| Generated sections | catalog, include, toc | no | no | no | no | no | no |
@@ -678,9 +678,11 @@ line per paragraph), or `preserve` (leave as-is, the
678678
default). remark-lint has no prose-wrap setting, but it
679679
serializes through its AST when fixing, so paragraphs
680680
can be incidentally rewrapped to match its stringify
681-
defaults. The others — mdsmith, markdownlint, rumdl,
682-
mado, panache — diagnose long lines but preserve the
683-
existing breaks.
681+
defaults. mdsmith adds an opt-in reflow fix
682+
(`line-length.reflow`). It rewraps prose to the width
683+
you set, and leaves breaks alone otherwise.
684+
markdownlint, rumdl, mado, and panache flag long
685+
lines but keep the existing breaks.
684686

685687
Prettier is the strongest pure formatter. rumdl and
686688
panache bring native autofix to the Rust side; mado is

docs/features/auto-fix.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ rule, re-parses, and repeats until the document stops changing or
2121
it has run ten passes. Stabilization means one fix never undoes
2222
another.
2323

24+
Paragraph reflow is the one fix that stays off by default. Set
25+
`rules.line-length.reflow: true` to let `mdsmith fix` rewrap
26+
over-long prose paragraphs to the `max` width. The wrap is
27+
abbreviation-aware — it never breaks `e.g.` or initials such as
28+
`J. R. R. Tolkien` across lines — and it leaves headings, lists,
29+
tables, code, and generated sections untouched.
30+
2431
`mdsmith check` runs the same rules without writing. It is the
2532
read-only sibling for CI, returning a non-zero exit code when any
2633
rule fails so a pipeline can block the merge.

docs/guides/coexist-with-prettier.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,16 @@ in `.mdsmith.yml`). Both indent with two spaces
8383
(`tabWidth` and `rules.list-indent.spaces`).
8484
Prettier's default `proseWrap: "preserve"` leaves
8585
existing line breaks alone. mdsmith's `line-length`
86-
rule reports long lines but does not rewrap them.
87-
At defaults, neither tool reflows paragraphs. Long
88-
lines are yours to wrap by hand.
86+
rule reports long lines but does not rewrap them
87+
unless you opt in. At defaults, neither tool
88+
reflows paragraphs. Long lines are yours to wrap by
89+
hand.
90+
91+
Set `rules.line-length.reflow: true` to let
92+
`mdsmith fix` rewrap prose paragraphs to `max`. Keep
93+
Prettier on its default `proseWrap: "preserve"` so
94+
the two tools do not fight over the same line
95+
breaks.
8996

9097
If you set `proseWrap: "always"`, Prettier rewraps
9198
paragraphs to `printWidth`. Keep `printWidth` no

internal/punkt/abbrtoken_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package punkt
2+
3+
import "testing"
4+
5+
func TestIsAbbrevToken(t *testing.T) {
6+
s := NewEnglish().Storage
7+
cases := []struct {
8+
tok string
9+
want bool
10+
}{
11+
{"J.", true}, // single-letter initial
12+
{"e.g.", true}, // dotted abbreviation pattern
13+
{"i.e.", true}, // dotted abbreviation pattern
14+
{"U.S.A.", true}, // dotted abbreviation pattern
15+
{"Dr.", true}, // trained AbbrevTypes (dr)
16+
{"vs.", true}, // trained AbbrevTypes (vs)
17+
{"Mr.", true}, // trained AbbrevTypes (mr)
18+
{"e.g.,", true}, // trailing comma ignored
19+
{"Dr.;", true}, // trailing semicolon ignored
20+
{"cat.", false}, // ordinary word ending a sentence
21+
{"Go.", false}, // ordinary word ending a sentence
22+
{"plain", false}, // no trailing period
23+
{"e.g", false}, // no trailing period, not in AbbrevTypes
24+
{",", false}, // trims to empty
25+
{"", false}, // empty
26+
{"unknown.", false}, // period-final but untrained
27+
}
28+
for _, c := range cases {
29+
if got := s.IsAbbrevToken(c.tok); got != c.want {
30+
t.Errorf("IsAbbrevToken(%q) = %v, want %v", c.tok, got, c.want)
31+
}
32+
}
33+
}
34+
35+
// TestIsAbbrevToken_SyntheticStorage drives the AbbrevTypes branch with
36+
// a hermetic Storage so the trained-asset cases above are not the only
37+
// coverage of the lowercase/drop-period lookup.
38+
func TestIsAbbrevToken_SyntheticStorage(t *testing.T) {
39+
s := NewStorage()
40+
s.AbbrevTypes.Add("approx")
41+
if !s.IsAbbrevToken("approx.") {
42+
t.Errorf("IsAbbrevToken(approx.) should be true for a seeded type")
43+
}
44+
if !s.IsAbbrevToken("Approx.") {
45+
t.Errorf("IsAbbrevToken(Approx.) should lowercase before lookup")
46+
}
47+
if s.IsAbbrevToken("approxx.") {
48+
t.Errorf("IsAbbrevToken(approxx.) should be false; not a seeded type")
49+
}
50+
}

internal/punkt/storage.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package punkt
22

33
import (
44
"encoding/json"
5+
"strings"
6+
"unicode/utf8"
57
)
68

79
// SetString is a string-keyed set matching upstream's JSON shape:
@@ -69,6 +71,38 @@ func (s *Storage) IsAbbr(tokens ...string) bool {
6971
return false
7072
}
7173

74+
// abbrTokenCutset is the trailing clause punctuation IsAbbrevToken
75+
// ignores, so "e.g.," and "cf.;" are judged like "e.g." and "cf.".
76+
const abbrTokenCutset = ",;:"
77+
78+
// IsAbbrevToken reports whether tok — a single whitespace-delimited
79+
// word such as "Dr.", "e.g.", or "J." — is an abbreviation under this
80+
// trained model, without running the full sentence tokenizer. It
81+
// recognises three shapes the pipeline's type-annotation pass treats as
82+
// abbreviations: a single-letter initial ("J."), a dotted abbreviation
83+
// pattern ("e.g.", "U.S.A."), and a member of the trained AbbrevTypes
84+
// set ("dr", "vs", "no"). Trailing clause punctuation is ignored, and
85+
// the AbbrevTypes lookup drops the final period and lowercases the word,
86+
// matching how typeAnnotation keys the set.
87+
//
88+
// It exists for callers — line-length reflow is the first — that need
89+
// the model's per-token abbreviation judgement to decide a wrap, not a
90+
// sentence boundary.
91+
func (s *Storage) IsAbbrevToken(tok string) bool {
92+
t := strings.TrimRight(tok, abbrTokenCutset)
93+
if t == "" {
94+
return false
95+
}
96+
if isInitial(t) || MatchAbbrPattern(t) {
97+
return true
98+
}
99+
if !HasPeriodFinal(t) {
100+
return false
101+
}
102+
_, sz := utf8.DecodeLastRuneInString(t)
103+
return s.IsAbbr(strings.ToLower(t[:len(t)-sz]))
104+
}
105+
72106
// addOrthoContext sets the ortho flag for typ. Used by training
73107
// loaders; kept here only because tests in this package construct
74108
// Storage values and seed OrthoContext directly.

internal/rules/MDS001-line-length/README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Line exceeds maximum length.
4242
| `code-block-max` | int | -- | Max length for code block lines; inherits `max` when unset |
4343
| `stern` | bool | false | Only flag long lines that contain a space past the limit |
4444
| `exclude` | list | `["code-blocks", "tables", "urls"]` | Categories to exclude from checking |
45+
| `reflow` | bool | false | Auto-fix over-long prose paragraphs by rewrapping to `max` |
46+
| `abbreviations` | list | `[]` | Extra abbreviations the reflow fixer keeps unbroken |
4547

4648
Valid `exclude` values:
4749

@@ -76,6 +78,44 @@ is excluded via `exclude: [code-blocks]` is still skipped regardless of stern.
7678
Stern uses the active max for each line type, so it respects `heading-max` and
7779
`code-block-max` when set.
7880

81+
### Reflow (auto-fix)
82+
83+
By default the rule reports long lines but does not rewrite them: `mdsmith fix`
84+
leaves prose untouched. Set `reflow: true` to opt into the auto-fix, which
85+
rewraps over-long top-level prose paragraphs so every line fits within `max`.
86+
87+
Reflow is deliberately conservative. It rewraps only paragraphs that sit
88+
directly in the document body and contain a flagged long line. It skips
89+
headings, list items, block quotes, tables, generated sections, paragraphs that
90+
carry a Markdown hard line break, and paragraphs that contain inline raw HTML.
91+
Inline code spans are preserved verbatim, and a single word wider than `max` (a
92+
long URL or link) keeps its own over-long line rather than being broken.
93+
94+
Wrapping is abbreviation-aware. It never ends a wrapped line on an abbreviation,
95+
and it never splits a run of initials. So `e.g.` stays with the word it
96+
introduces, and `J. R. R. Tolkien` is never broken across lines. Detection
97+
reuses mdsmith's trained abbreviation model — the same one the readability rules
98+
use to split sentences. It recognises honorifics (`Dr.`, `Mr.`), reference forms
99+
(`vs.`, `No.`), initials (`J.`), and dotted forms (`e.g.`, `i.e.`, `U.S.A.`). The
100+
`abbreviations` setting adds project-specific entries the model does not know
101+
(`etc.`, `approx.`), and append-merges across config layers so a kind can extend
102+
the inherited list without restating it.
103+
104+
The payoff is clearest at the wrap boundary. A naive word wrap of this line at 80
105+
columns splits the spaced acronym across the break:
106+
107+
```text
108+
Historians traced the founding and the early constitutional debates of the U. S.
109+
A. with real care.
110+
```
111+
112+
Reflow breaks before the acronym instead, keeping `U. S. A.` whole on one line:
113+
114+
```text
115+
Historians traced the founding and the early constitutional debates of the
116+
U. S. A. with real care.
117+
```
118+
79119
## Config
80120

81121
Enable (default):
@@ -128,6 +168,18 @@ rules:
128168
exclude: []
129169
```
130170
171+
Custom (enable the reflow auto-fix and add project abbreviations):
172+
173+
```yaml
174+
rules:
175+
line-length:
176+
max: 80
177+
reflow: true
178+
abbreviations:
179+
- etc.
180+
- approx.
181+
```
182+
131183
Custom (skip only code blocks and URLs; check tables):
132184
133185
```yaml
@@ -269,7 +321,7 @@ This line inside a code block is over 80 characters but within the code-block-ma
269321
- **Name**: `line-length`
270322
- **Status**: ready
271323
- **Default**: enabled, max: 80
272-
- **Fixable**: no
324+
- **Fixable**: yes (opt-in via `reflow`)
273325
- **Implementation**:
274326
[source](./)
275327
- **Category**: line
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
settings:
3+
reflow: true
4+
diagnostics:
5+
- line: 3
6+
column: 81
7+
message: "line too long (99 > 80)"
8+
---
9+
# Initials
10+
11+
Historians traced the founding and the early constitutional debates of the U. S. A. with real care.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
settings:
3+
reflow: true
4+
diagnostics:
5+
- line: 3
6+
column: 81
7+
message: "line too long (135 > 80)"
8+
---
9+
# Reflow
10+
11+
The tool wraps each long line of prose. It makes the text fit the width you set. It does not change the way the page looks to a reader.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Initials
2+
3+
Historians traced the founding and the early constitutional debates of the
4+
U. S. A. with real care.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Reflow
2+
3+
The tool wraps each long line of prose. It makes the text fit the width you set.
4+
It does not change the way the page looks to a reader.

0 commit comments

Comments
 (0)