quirework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
quirework/text/mdwrap.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wrap Markdown according to project-selected line conventions.
|
|
3
|
+
|
|
4
|
+
Prose uses semantic line breaks, with one sentence per source line. Technical
|
|
5
|
+
documents use bounded columns. A configured classifier chooses the mode for a
|
|
6
|
+
path, so no consumer directory layout is built into this module.
|
|
7
|
+
|
|
8
|
+
Every mode also strips trailing whitespace from every line. Files that are
|
|
9
|
+
generated or verbatim (the provenance transcripts/indexes, the panel raw output)
|
|
10
|
+
are skipped entirely; other hand-authored files we don't reflow (SOPs, the
|
|
11
|
+
preface variants, the verbatim third-party `paper-full-text.md` dumps) still
|
|
12
|
+
get trailing-whitespace cleanup ("trailing" mode).
|
|
13
|
+
|
|
14
|
+
The mode is chosen by path (`classify`), so both the CLI and the pre-commit hook
|
|
15
|
+
just pass files and get the right treatment. A hard self-check (`_canonical`)
|
|
16
|
+
compares the file before and after with all whitespace collapsed; if anything
|
|
17
|
+
other than whitespace changed, the run aborts and writes nothing.
|
|
18
|
+
|
|
19
|
+
This module and its sibling artifact_paths helper are stdlib-only, so the file
|
|
20
|
+
also runs directly, without installing the package. Paths below are relative to
|
|
21
|
+
this repository; from a project that vendors it, prefix its checkout.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python src/quirework/text/mdwrap.py FILE [FILE ...] # rewrap in place
|
|
25
|
+
python src/quirework/text/mdwrap.py --check FILE [...] # exit 1 if not wrapped
|
|
26
|
+
python src/quirework/text/mdwrap.py --width 72 FILE ... # column-mode width
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
if __package__:
|
|
37
|
+
from quirework.core import globs
|
|
38
|
+
from quirework.core.artifact_paths import run_evidence
|
|
39
|
+
else: # Direct script invocation loads the stdlib-only sibling helpers.
|
|
40
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
41
|
+
from quirework.core import globs
|
|
42
|
+
from quirework.core.artifact_paths import run_evidence
|
|
43
|
+
|
|
44
|
+
DEFAULT_WIDTH = 72
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Sentence mode (semantic line breaks) — the canonical book prose.
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# Long sentences are additionally broken at semicolons; short ones are left whole.
|
|
51
|
+
_CLAUSE_SPLIT_MIN_LEN = 100
|
|
52
|
+
|
|
53
|
+
# Abbreviations whose trailing "." must not be read as a sentence end.
|
|
54
|
+
_ABBREVS = [
|
|
55
|
+
"e.g.",
|
|
56
|
+
"i.e.",
|
|
57
|
+
"etc.",
|
|
58
|
+
"vs.",
|
|
59
|
+
"cf.",
|
|
60
|
+
"al.",
|
|
61
|
+
"esp.",
|
|
62
|
+
"approx.",
|
|
63
|
+
"Mr.",
|
|
64
|
+
"Mrs.",
|
|
65
|
+
"Ms.",
|
|
66
|
+
"Dr.",
|
|
67
|
+
"Prof.",
|
|
68
|
+
"St.",
|
|
69
|
+
"Fig.",
|
|
70
|
+
"Inc.",
|
|
71
|
+
"Ltd.",
|
|
72
|
+
"Jr.",
|
|
73
|
+
"Sr.",
|
|
74
|
+
"Ph.D.",
|
|
75
|
+
"a.m.",
|
|
76
|
+
"p.m.",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# Matched as whole words, never as a substring. A bare `str.replace` finds the
|
|
80
|
+
# "al." of "et al." inside "real.", "moral.", "animal.", "final." — protecting a
|
|
81
|
+
# period that really does end a sentence, so the break after it silently
|
|
82
|
+
# disappears. In a book about morals and animals that is not an edge case.
|
|
83
|
+
_ABBREV_RE = re.compile(
|
|
84
|
+
r"(?<![A-Za-z0-9.])(?:"
|
|
85
|
+
+ "|".join(re.escape(a) for a in sorted(_ABBREVS, key=len, reverse=True))
|
|
86
|
+
+ ")"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Abbreviations read as such only before a number: "No. 5", "ch. 4",
|
|
90
|
+
# "pp. 12". "No." is the numero sign only there. Listed with the others it
|
|
91
|
+
# also swallowed the answer "No.", so "Did the fuse hold? No. The lights went
|
|
92
|
+
# out." was joined onto one line, and the joined form was then what --check
|
|
93
|
+
# demanded. The rest are never words, but a break after "ch." carried an
|
|
94
|
+
# emphasized span across a source line.
|
|
95
|
+
_NUMBERED = ("no", "ch", "chap", "p", "pp", "vol", "pt", "sec", "fig", "eq")
|
|
96
|
+
_NUMBERED_RE = re.compile(
|
|
97
|
+
r"(?<![A-Za-z0-9.])(?i:" + "|".join(_NUMBERED) + r")\.(?=\s*[0-9#])"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Single-capital-letter initials: "W. V. O. Quine", "U.S. Army". A capital
|
|
101
|
+
# after a slash or a hyphen is the end of a term, not an initial: "I/O."
|
|
102
|
+
# closes a sentence as surely as "disk." does.
|
|
103
|
+
_INITIAL_RE = re.compile(r"(?<![\w/-])([A-Z])\.(?=\s)")
|
|
104
|
+
_NEXT_INITIAL_RE = re.compile(r"\s+[A-Z]\.")
|
|
105
|
+
|
|
106
|
+
_SENT = "\x00" # sentinel standing in for a protected "."
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _initial(m: re.Match) -> str:
|
|
110
|
+
"""Protect an initial's period, but not the pronoun's.
|
|
111
|
+
|
|
112
|
+
"so have I. Here" ends a sentence on "I". After a lowercase word, "I" is
|
|
113
|
+
read as the pronoun, unless another initial follows it ("the architect I.
|
|
114
|
+
M. Pei"). A single initial I after a lowercase word, as in "by I. Kant",
|
|
115
|
+
is broken after in consequence; a soft break renders as a space.
|
|
116
|
+
"""
|
|
117
|
+
text, start = m.string, m.start()
|
|
118
|
+
if m.group(1) == "I" and not _NEXT_INITIAL_RE.match(text, m.end()):
|
|
119
|
+
k = start
|
|
120
|
+
while k > 0 and text[k - 1].isspace():
|
|
121
|
+
k -= 1
|
|
122
|
+
if 0 < k < start and text[k - 1].islower():
|
|
123
|
+
return m.group(0)
|
|
124
|
+
return m.group(1) + _SENT
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _protect(text: str) -> str:
|
|
128
|
+
"""Replace dots that are not sentence ends with a sentinel.
|
|
129
|
+
|
|
130
|
+
The result is as long as the text, character for character, so a caller
|
|
131
|
+
may protect a longer text than it needs and slice the part it asks about.
|
|
132
|
+
"""
|
|
133
|
+
text = _ABBREV_RE.sub(lambda m: m.group(0).replace(".", _SENT), text)
|
|
134
|
+
text = _NUMBERED_RE.sub(lambda m: m.group(0).replace(".", _SENT), text)
|
|
135
|
+
return _INITIAL_RE.sub(_initial, text)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _restore(text: str) -> str:
|
|
139
|
+
return text.replace(_SENT, ".")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# A boundary is whitespace that follows .!? (optionally then a closing quote)
|
|
143
|
+
# and precedes something that can start a sentence.
|
|
144
|
+
#
|
|
145
|
+
# Known limitation: a quoted title ending in ? or ! that continues mid-sentence
|
|
146
|
+
# with a space then ( or a digit reads as a boundary here, so `"...Bat?" (1974)`
|
|
147
|
+
# splits and orphans the year. It is cosmetic (a soft break renders as a space),
|
|
148
|
+
# but write such citations in a project-selected house style --
|
|
149
|
+
# `"Title?", 1974` (comma butting the closing quote), or year-in-front -- which
|
|
150
|
+
# leaves no space+quote boundary to trip. Not fixed in the splitter on purpose:
|
|
151
|
+
# `?"`/`!"` before a capital is genuinely ambiguous (title mid-sentence vs. a
|
|
152
|
+
# quoted question ending a sentence), and any heuristic trades this cosmetic
|
|
153
|
+
# miss for an equally cosmetic one. The one shape read otherwise is a dialogue
|
|
154
|
+
# tag, `"Which one?" I said.`, below.
|
|
155
|
+
#
|
|
156
|
+
# What may trail the terminal punctuation and still belong to the sentence it
|
|
157
|
+
# ends: a closing quote or parenthesis, and footnote references, which
|
|
158
|
+
# Markdown writes as `[^label]` right after the period ("...a resource.[^intro]
|
|
159
|
+
# That"). A bare `]` is not on the list: a Pandoc inline note `^[...]` closes
|
|
160
|
+
# on one, and whether a sentence ends after the note is the author's call, so
|
|
161
|
+
# the text around it is left as written.
|
|
162
|
+
_TRAILERS = re.compile(r"""(?:\[\^[^\]\s]+\]|["”’')])*\Z""")
|
|
163
|
+
_STARTER = re.compile(r'[A-Z0-9"“(\[*]')
|
|
164
|
+
_SEMI = re.compile(r"(?<=;)\s+")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# A quoted question or exclamation and its tag are one sentence: `"Which
|
|
168
|
+
# one?" I said.` A tag opens on a capital only when its speaker is "I" or a
|
|
169
|
+
# name, so one capitalized word followed by a verb of speech is read as a tag.
|
|
170
|
+
_QUOTED_END = re.compile(r"""[?!]["”’']+\Z""")
|
|
171
|
+
_TAG = re.compile(
|
|
172
|
+
r"[A-Z][\w’'-]*\s+(?:"
|
|
173
|
+
r"say|says|said|ask|asks|asked|reply|replies|replied|answer|answers|"
|
|
174
|
+
r"answered|whispered|shouted|cried|called|yelled|muttered|wondered|"
|
|
175
|
+
r"added|repeated|write|writes|wrote|type|types|typed"
|
|
176
|
+
r")\b"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ends_sentence(protected: str) -> bool:
|
|
181
|
+
"""Whether protected text ends with terminal punctuation, trailers aside.
|
|
182
|
+
|
|
183
|
+
Abbreviations and initials were turned into sentinels by `_protect`, so a
|
|
184
|
+
period still standing here is one a sentence ends on.
|
|
185
|
+
"""
|
|
186
|
+
core = protected[: _TRAILERS.search(protected).start()]
|
|
187
|
+
return core[-1:] in (".", "!", "?")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _breaks(before: str, protected: str, resume: int) -> bool:
|
|
191
|
+
"""Whether a sentence ends after `before`, when the text goes on at
|
|
192
|
+
`protected[resume]`. A quoted question's dialogue tag goes on with it."""
|
|
193
|
+
if not _ends_sentence(before):
|
|
194
|
+
return False
|
|
195
|
+
return not (_QUOTED_END.search(before) and _TAG.match(protected, resume))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _sentences(protected: str) -> list[str]:
|
|
199
|
+
"""Split protected text at whitespace that ends a sentence and precedes
|
|
200
|
+
something that can start one."""
|
|
201
|
+
out, start = [], 0
|
|
202
|
+
for gap in re.finditer(r"\s+", protected):
|
|
203
|
+
after = protected[gap.end() : gap.end() + 1]
|
|
204
|
+
before = protected[start : gap.start()]
|
|
205
|
+
if _STARTER.match(after) and _breaks(before, protected, gap.end()):
|
|
206
|
+
out.append(before)
|
|
207
|
+
start = gap.end()
|
|
208
|
+
out.append(protected[start:])
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _split_paragraph(text: str) -> list[str]:
|
|
213
|
+
"""One prose paragraph (already joined to a single string) -> lines."""
|
|
214
|
+
protected = _protect(text)
|
|
215
|
+
sentences = _sentences(protected)
|
|
216
|
+
lines: list[str] = []
|
|
217
|
+
for s in sentences:
|
|
218
|
+
s = s.strip()
|
|
219
|
+
if not s:
|
|
220
|
+
continue
|
|
221
|
+
if len(s) > _CLAUSE_SPLIT_MIN_LEN and ";" in s:
|
|
222
|
+
lines.extend(part.strip() for part in _SEMI.split(s) if part.strip())
|
|
223
|
+
else:
|
|
224
|
+
lines.append(s)
|
|
225
|
+
return [_restore(s) for s in lines]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_blank(line: str) -> bool:
|
|
229
|
+
return line.strip() == ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Lines that begin a non-prose block and must pass through verbatim (sentence
|
|
233
|
+
# mode leaves list items alone, unlike column mode which reflows them).
|
|
234
|
+
_PASSTHROUGH = re.compile(
|
|
235
|
+
r"""^\s*(
|
|
236
|
+
\# | # heading
|
|
237
|
+
!\[ | # block-level image (figure): caption + attrs stay verbatim
|
|
238
|
+
:{3,} | # pandoc/quarto fenced div (::: callout)
|
|
239
|
+
> | # blockquote
|
|
240
|
+
\| | # table row
|
|
241
|
+
[-*+]\s | # bullet list item
|
|
242
|
+
\d+[.)]\s | # ordered list item
|
|
243
|
+
---+\s*$ | # thematic break / front-matter rule
|
|
244
|
+
\*\*\*+\s*$ | # thematic break
|
|
245
|
+
___+\s*$ | # thematic break
|
|
246
|
+
\{\{[<%] | # a Hugo shortcode on its own line
|
|
247
|
+
\[[^\]]+\]: # link reference definition
|
|
248
|
+
)""",
|
|
249
|
+
re.VERBOSE,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _passthrough_line(line: str) -> bool:
|
|
254
|
+
# Any indented line is left alone: prose paragraphs start at column 0, so an
|
|
255
|
+
# indented line is a list-item continuation or indented code — never prose to
|
|
256
|
+
# reflow. (This keeps hard-wrapped, list-heavy docs safe.)
|
|
257
|
+
if line[:1].isspace():
|
|
258
|
+
return True
|
|
259
|
+
return bool(_PASSTHROUGH.match(line))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _consume_frontmatter(lines: list[str], out: list[str]) -> list[str]:
|
|
263
|
+
"""If `lines` opens with a frontmatter block, copy it verbatim to `out`.
|
|
264
|
+
|
|
265
|
+
Either convention: YAML between `---` lines, or TOML between `+++` lines,
|
|
266
|
+
which is what Hugo writes by default. The block closes on its own fence.
|
|
267
|
+
Returns the remaining lines. Shared by both modes so a multi-key block is
|
|
268
|
+
never reflowed into prose. Leading single-line HTML comments (such as the
|
|
269
|
+
``Added:`` stamp) are skipped when looking for the opening fence.
|
|
270
|
+
"""
|
|
271
|
+
start = 0
|
|
272
|
+
while (
|
|
273
|
+
start < len(lines)
|
|
274
|
+
and lines[start].strip().startswith("<!--")
|
|
275
|
+
and lines[start].strip().endswith("-->")
|
|
276
|
+
):
|
|
277
|
+
start += 1
|
|
278
|
+
if start < len(lines) and lines[start].strip() in ("---", "+++"):
|
|
279
|
+
fence = lines[start].strip()
|
|
280
|
+
for j in range(start):
|
|
281
|
+
out.append(lines[j])
|
|
282
|
+
out.append(lines[start])
|
|
283
|
+
i = start + 1
|
|
284
|
+
while i < len(lines):
|
|
285
|
+
out.append(lines[i])
|
|
286
|
+
i += 1
|
|
287
|
+
if lines[i - 1].strip() == fence:
|
|
288
|
+
break
|
|
289
|
+
return lines[i:]
|
|
290
|
+
return lines
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_FENCE_OPEN = re.compile(r"^\s*(`{3,}|~{3,})")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Fences:
|
|
297
|
+
"""Whether a line is in fenced code, decided as CommonMark decides it.
|
|
298
|
+
|
|
299
|
+
A fence opens on three or more backticks or tildes and closes only on a
|
|
300
|
+
run of the same character at least as long, with nothing after it but
|
|
301
|
+
space. A fence quoted inside a longer one is therefore text, not a close:
|
|
302
|
+
toggling on every fence-like line lost track there and reflowed the code
|
|
303
|
+
that followed as prose.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def __init__(self):
|
|
307
|
+
self.open = None # (character, length) of the open fence
|
|
308
|
+
|
|
309
|
+
def step(self, line):
|
|
310
|
+
"""Whether ``line`` opens or closes a fence, updating the state."""
|
|
311
|
+
found = _FENCE_OPEN.match(line)
|
|
312
|
+
if not found:
|
|
313
|
+
return False
|
|
314
|
+
run = found.group(1)
|
|
315
|
+
if self.open is None:
|
|
316
|
+
self.open = (run[0], len(run))
|
|
317
|
+
return True
|
|
318
|
+
char, length = self.open
|
|
319
|
+
if run[0] == char and len(run) >= length and not line[found.end() :].strip():
|
|
320
|
+
self.open = None
|
|
321
|
+
return True
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def inside(self):
|
|
326
|
+
return self.open is not None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _find_open(s: str) -> int:
|
|
330
|
+
"""Index of the next `<!--` that is a real delimiter, or -1.
|
|
331
|
+
|
|
332
|
+
A `<!--` inside an inline code span is text about a comment, not a comment.
|
|
333
|
+
"""
|
|
334
|
+
i = s.find("<!--")
|
|
335
|
+
while i >= 0:
|
|
336
|
+
if s.count("`", 0, i) % 2 == 0:
|
|
337
|
+
return i
|
|
338
|
+
i = s.find("<!--", i + 4)
|
|
339
|
+
return -1
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _split_comment_lines(lines: list[str]) -> list[str]:
|
|
343
|
+
"""Give every HTML comment whole lines of its own.
|
|
344
|
+
|
|
345
|
+
The comment machinery in both modes assumes a comment starts a line and that
|
|
346
|
+
the line holding `-->` ends it. Two shapes break that assumption, and both
|
|
347
|
+
occur in practice when an annotation is hand-deleted from a stack of them:
|
|
348
|
+
|
|
349
|
+
* `... v1.0. --><!-- @ref ...` — one comment closes and the next opens on the
|
|
350
|
+
same line. The scanner would end the comment there and hand the tail to
|
|
351
|
+
`_reflow_comment`, which cannot parse it and passes the whole mess through
|
|
352
|
+
verbatim; the joined line then survives `--check` untouched.
|
|
353
|
+
* `Some prose. <!-- @todo ... -->` — a comment opening after prose is not
|
|
354
|
+
seen as a comment at all. Sentence mode reflows it *as prose*, splitting
|
|
355
|
+
the annotation at its internal sentence breaks.
|
|
356
|
+
|
|
357
|
+
Splitting at the delimiters first makes both shapes ordinary. Fenced code is
|
|
358
|
+
untouched, and an unterminated `<!--` keeps the rest of the input verbatim.
|
|
359
|
+
"""
|
|
360
|
+
out: list[str] = []
|
|
361
|
+
fences = _Fences()
|
|
362
|
+
in_comment = False
|
|
363
|
+
for line in lines:
|
|
364
|
+
if fences.step(line):
|
|
365
|
+
out.append(line)
|
|
366
|
+
continue
|
|
367
|
+
if fences.inside or not line.strip():
|
|
368
|
+
out.append(line)
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
rest = line
|
|
372
|
+
pieces: list[str] = []
|
|
373
|
+
while rest:
|
|
374
|
+
if in_comment:
|
|
375
|
+
i = rest.find("-->")
|
|
376
|
+
if i < 0:
|
|
377
|
+
pieces.append(rest)
|
|
378
|
+
break
|
|
379
|
+
pieces.append(rest[: i + 3])
|
|
380
|
+
# Whatever follows the close starts fresh at column 0: left
|
|
381
|
+
# indented, sentence mode would read it as a passthrough block.
|
|
382
|
+
rest = rest[i + 3 :].lstrip()
|
|
383
|
+
in_comment = False
|
|
384
|
+
else:
|
|
385
|
+
i = _find_open(rest)
|
|
386
|
+
if i < 0:
|
|
387
|
+
pieces.append(rest)
|
|
388
|
+
break
|
|
389
|
+
if rest[:i].strip():
|
|
390
|
+
pieces.append(rest[:i].rstrip())
|
|
391
|
+
rest = rest[i:]
|
|
392
|
+
in_comment = True
|
|
393
|
+
out.extend(pieces or [line])
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def rewrap(text: str) -> str:
|
|
398
|
+
"""Sentence-per-line rewrap (the canonical-prose mode)."""
|
|
399
|
+
lines = text.split("\n")
|
|
400
|
+
out: list[str] = []
|
|
401
|
+
para: list[str] = []
|
|
402
|
+
fences = _Fences()
|
|
403
|
+
in_comment = False
|
|
404
|
+
comment_acc: list[str] = []
|
|
405
|
+
|
|
406
|
+
lines = _split_comment_lines(_consume_frontmatter(lines, out))
|
|
407
|
+
|
|
408
|
+
def flush() -> None:
|
|
409
|
+
# A line the author already ended on a sentence end stays ended, even
|
|
410
|
+
# when the next one opens in lowercase or code ("virtio-blk is a block
|
|
411
|
+
# device.", "`cache=none` is ..."), which no capital-letter rule can
|
|
412
|
+
# recognize as a new sentence. Only the lines between are joined.
|
|
413
|
+
# Whether a line ends a sentence is judged with the next line in view,
|
|
414
|
+
# as it is inside a line: "ch." before "4" ends nothing, and neither
|
|
415
|
+
# does a quoted question before its tag.
|
|
416
|
+
group: list[str] = []
|
|
417
|
+
for k, line in enumerate(para):
|
|
418
|
+
here = line.strip()
|
|
419
|
+
group.append(here)
|
|
420
|
+
after = para[k + 1].strip() if k + 1 < len(para) else ""
|
|
421
|
+
protected = _protect(here + " " + after)
|
|
422
|
+
if _breaks(protected[: len(here)], protected, len(here) + 1):
|
|
423
|
+
out.extend(_split_paragraph(" ".join(group)))
|
|
424
|
+
group = []
|
|
425
|
+
if group:
|
|
426
|
+
out.extend(_split_paragraph(" ".join(group)))
|
|
427
|
+
para.clear()
|
|
428
|
+
|
|
429
|
+
for line in lines:
|
|
430
|
+
# Fenced code blocks: copy verbatim.
|
|
431
|
+
if fences.step(line):
|
|
432
|
+
flush()
|
|
433
|
+
out.append(line)
|
|
434
|
+
continue
|
|
435
|
+
if fences.inside:
|
|
436
|
+
out.append(line)
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
# HTML comments (possibly multi-line): copy verbatim, except for @-annotations.
|
|
440
|
+
if in_comment:
|
|
441
|
+
comment_acc.append(line)
|
|
442
|
+
if "-->" in line:
|
|
443
|
+
in_comment = False
|
|
444
|
+
out.extend(_reflow_comment(comment_acc, DEFAULT_WIDTH))
|
|
445
|
+
continue
|
|
446
|
+
if line.lstrip().startswith("<!--"):
|
|
447
|
+
flush()
|
|
448
|
+
if "-->" not in line:
|
|
449
|
+
in_comment = True
|
|
450
|
+
comment_acc = [line]
|
|
451
|
+
else:
|
|
452
|
+
out.extend(_reflow_comment([line], DEFAULT_WIDTH))
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
if _is_blank(line):
|
|
456
|
+
flush()
|
|
457
|
+
out.append(line)
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if _passthrough_line(line):
|
|
461
|
+
flush()
|
|
462
|
+
out.append(line)
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
# Plain prose line: accumulate into the current paragraph.
|
|
466
|
+
para.append(line)
|
|
467
|
+
|
|
468
|
+
if in_comment: # unterminated comment: keep it verbatim, never drop it
|
|
469
|
+
out.extend(comment_acc)
|
|
470
|
+
flush()
|
|
471
|
+
return "\n".join(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Column mode (hard wrap at N columns) — the technical docs.
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
# Inline spans that must never be split across a line break: a soft break inside
|
|
479
|
+
# them changes what Markdown renders (a link destination or code span cannot hold
|
|
480
|
+
# a newline). Everything else breaks at whitespace. The `_canonical` self-check
|
|
481
|
+
# is the backstop: any accidental break inside one of these inserts a space that
|
|
482
|
+
# was not there and aborts the run rather than corrupting the file.
|
|
483
|
+
_ATOMIC = re.compile(
|
|
484
|
+
r"""
|
|
485
|
+
`[^`\n]*` | # inline code span
|
|
486
|
+
!?\[[^\]]*\]\([^)]*\) | # inline link / image
|
|
487
|
+
!?\[[^\]]*\]\[[^\]]*\] | # reference-style link / image
|
|
488
|
+
<[^>\s]+> # autolink
|
|
489
|
+
""",
|
|
490
|
+
re.VERBOSE,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# A list item: indent, marker (bullet or ordered), then its text.
|
|
494
|
+
_LIST_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
|
|
495
|
+
|
|
496
|
+
# A token that must never be the first on a wrapped line: at line-start (followed
|
|
497
|
+
# by a space) it would be parsed as a new block — a list marker, heading,
|
|
498
|
+
# blockquote, table row, or thematic/setext rule. A mid-prose "+" (as in "comments
|
|
499
|
+
# + tracked runs") landing at line-start would silently become a sublist. When a
|
|
500
|
+
# break would put one of these first, we keep it on the current line instead.
|
|
501
|
+
#
|
|
502
|
+
# This is about Markdown, so it applies to Markdown. Inside an HTML comment no
|
|
503
|
+
# block can start, and the guard buys nothing there but over-width lines: the
|
|
504
|
+
# repo's annotations use " -- " as an em-dash and cite years as "(Russell 1975)",
|
|
505
|
+
# and both "--" and "1975)" match here. `_reflow_comment` waives it.
|
|
506
|
+
_UNSAFE_START = re.compile(
|
|
507
|
+
r"^(#{1,6}|[-*+]|\d{1,9}[.)]|>|\|+|-{2,}|={2,}|_{3,}|\*{3,})$"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Non-list blocks that pass through verbatim in column mode.
|
|
511
|
+
_PASS_COL = re.compile(
|
|
512
|
+
r"""^\s*(
|
|
513
|
+
\# | # heading
|
|
514
|
+
!\[ | # block-level image
|
|
515
|
+
:{3,} | # fenced div
|
|
516
|
+
> | # blockquote (kept verbatim: often a quote)
|
|
517
|
+
\| | # table row
|
|
518
|
+
---+\s*$ | # thematic break
|
|
519
|
+
\*\*\*+\s*$ | # thematic break
|
|
520
|
+
___+\s*$ | # thematic break
|
|
521
|
+
\{\{[<%] | # a Hugo shortcode on its own line
|
|
522
|
+
\[[^\]]+\]: # link reference definition
|
|
523
|
+
)""",
|
|
524
|
+
re.VERBOSE,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _tokenize(text: str) -> list[str]:
|
|
529
|
+
"""Split into whitespace-separated tokens, keeping atomic spans whole.
|
|
530
|
+
|
|
531
|
+
Whitespace *inside* an atomic span (a link title, a code span) is not a break
|
|
532
|
+
point; whitespace outside it is. Atomic spans stay glued to any adjacent
|
|
533
|
+
non-space characters (so `see [x](y).` keeps the trailing period attached).
|
|
534
|
+
"""
|
|
535
|
+
tokens: list[str] = []
|
|
536
|
+
cur: list[str] = []
|
|
537
|
+
i, n = 0, len(text)
|
|
538
|
+
while i < n:
|
|
539
|
+
ch = text[i]
|
|
540
|
+
if ch.isspace():
|
|
541
|
+
if cur:
|
|
542
|
+
tokens.append("".join(cur))
|
|
543
|
+
cur = []
|
|
544
|
+
i += 1
|
|
545
|
+
continue
|
|
546
|
+
m = _ATOMIC.match(text, i)
|
|
547
|
+
if m and m.end() > i:
|
|
548
|
+
cur.append(m.group(0))
|
|
549
|
+
i = m.end()
|
|
550
|
+
continue
|
|
551
|
+
cur.append(ch)
|
|
552
|
+
i += 1
|
|
553
|
+
if cur:
|
|
554
|
+
tokens.append("".join(cur))
|
|
555
|
+
return tokens
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _wrap_tokens(
|
|
559
|
+
tokens: list[str],
|
|
560
|
+
first_prefix: str,
|
|
561
|
+
cont_prefix: str,
|
|
562
|
+
width: int,
|
|
563
|
+
guard_line_start: bool = True,
|
|
564
|
+
) -> list[str]:
|
|
565
|
+
"""Greedy-wrap `tokens` to `width`, with a first-line and continuation prefix.
|
|
566
|
+
|
|
567
|
+
A token that alone overflows `width` (a long URL, a long code span) still
|
|
568
|
+
goes on its own line rather than being broken.
|
|
569
|
+
|
|
570
|
+
`guard_line_start` keeps a block-starting token off the front of a wrapped
|
|
571
|
+
line (see `_UNSAFE_START`). Callers wrapping text that Markdown never parses
|
|
572
|
+
as blocks — the inside of an HTML comment — pass False and get the width
|
|
573
|
+
honored instead.
|
|
574
|
+
"""
|
|
575
|
+
if not tokens:
|
|
576
|
+
return [first_prefix]
|
|
577
|
+
lines: list[str] = []
|
|
578
|
+
cur = first_prefix + tokens[0]
|
|
579
|
+
for tok in tokens[1:]:
|
|
580
|
+
# A break before a block-starting token would corrupt the render, so keep
|
|
581
|
+
# such a token on the current line (accepting an over-width line) rather
|
|
582
|
+
# than letting it lead a wrapped line.
|
|
583
|
+
unsafe = guard_line_start and _UNSAFE_START.match(tok)
|
|
584
|
+
if len(cur) + 1 + len(tok) > width and not unsafe:
|
|
585
|
+
lines.append(cur)
|
|
586
|
+
cur = cont_prefix + tok
|
|
587
|
+
else:
|
|
588
|
+
cur += " " + tok
|
|
589
|
+
lines.append(cur)
|
|
590
|
+
return lines
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def reflow_columns(text: str, width: int = DEFAULT_WIDTH) -> str:
|
|
594
|
+
"""Hard-wrap prose paragraphs and list items to `width` columns.
|
|
595
|
+
|
|
596
|
+
Verbatim (never reflowed): fenced code, HTML comments, YAML frontmatter,
|
|
597
|
+
tables, blockquotes, headings, thematic breaks, block images, fenced-div
|
|
598
|
+
markers, link reference definitions. List items reflow with a hanging indent
|
|
599
|
+
aligned under their text; nested items keep their indent.
|
|
600
|
+
"""
|
|
601
|
+
lines = text.split("\n")
|
|
602
|
+
out: list[str] = []
|
|
603
|
+
|
|
604
|
+
acc: list[str] = []
|
|
605
|
+
first_prefix = ""
|
|
606
|
+
cont_prefix = ""
|
|
607
|
+
in_list = False
|
|
608
|
+
# The continuation indent of the list item we are inside, if any. Unlike
|
|
609
|
+
# `in_list`, this survives a blank line: a list item may hold several
|
|
610
|
+
# paragraphs, and the blank between them ends the paragraph, not the item.
|
|
611
|
+
list_indent = ""
|
|
612
|
+
|
|
613
|
+
def flush() -> None:
|
|
614
|
+
nonlocal acc, first_prefix, cont_prefix, in_list
|
|
615
|
+
if acc:
|
|
616
|
+
tokens = _tokenize(" ".join(s.strip() for s in acc))
|
|
617
|
+
out.extend(_wrap_tokens(tokens, first_prefix, cont_prefix, width))
|
|
618
|
+
acc = []
|
|
619
|
+
first_prefix = ""
|
|
620
|
+
cont_prefix = ""
|
|
621
|
+
in_list = False
|
|
622
|
+
|
|
623
|
+
fences = _Fences()
|
|
624
|
+
in_comment = False
|
|
625
|
+
comment_acc: list[str] = []
|
|
626
|
+
|
|
627
|
+
lines = _split_comment_lines(_consume_frontmatter(lines, out))
|
|
628
|
+
|
|
629
|
+
for line in lines:
|
|
630
|
+
if fences.step(line):
|
|
631
|
+
flush()
|
|
632
|
+
if not line[:1].isspace(): # a column-0 fence ends any list
|
|
633
|
+
list_indent = ""
|
|
634
|
+
out.append(line)
|
|
635
|
+
continue
|
|
636
|
+
if fences.inside:
|
|
637
|
+
out.append(line)
|
|
638
|
+
continue
|
|
639
|
+
|
|
640
|
+
if in_comment:
|
|
641
|
+
comment_acc.append(line)
|
|
642
|
+
if "-->" in line:
|
|
643
|
+
in_comment = False
|
|
644
|
+
out.extend(_reflow_comment(comment_acc, width))
|
|
645
|
+
continue
|
|
646
|
+
if line.lstrip().startswith("<!--"):
|
|
647
|
+
flush()
|
|
648
|
+
if not line[:1].isspace(): # a column-0 comment ends any list
|
|
649
|
+
list_indent = ""
|
|
650
|
+
if "-->" not in line:
|
|
651
|
+
in_comment = True
|
|
652
|
+
comment_acc = [line]
|
|
653
|
+
else:
|
|
654
|
+
out.extend(_reflow_comment([line], width))
|
|
655
|
+
continue
|
|
656
|
+
|
|
657
|
+
if _is_blank(line):
|
|
658
|
+
flush()
|
|
659
|
+
out.append(line)
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
m = _LIST_RE.match(line)
|
|
663
|
+
if m:
|
|
664
|
+
# A new list item (nested items match too, by their deeper indent).
|
|
665
|
+
flush()
|
|
666
|
+
indent, marker, rest = m.group(1), m.group(2), m.group(3)
|
|
667
|
+
first_prefix = f"{indent}{marker} "
|
|
668
|
+
cont_prefix = " " * len(first_prefix)
|
|
669
|
+
acc = [rest]
|
|
670
|
+
in_list = True
|
|
671
|
+
list_indent = cont_prefix
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
# A continuation line of the current list item: indented, not a marker.
|
|
675
|
+
if in_list and line[:1].isspace():
|
|
676
|
+
acc.append(line)
|
|
677
|
+
continue
|
|
678
|
+
|
|
679
|
+
# A further paragraph of the list item we are still inside, after the
|
|
680
|
+
# blank line that ended the previous one. Markdown draws the line by
|
|
681
|
+
# indent: content indented to the item's own text continues the item,
|
|
682
|
+
# and four columns beyond that is an indented code block. So reflow the
|
|
683
|
+
# first case with the item's hanging indent and leave the second alone.
|
|
684
|
+
if list_indent and not in_list and line[:1].isspace():
|
|
685
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
686
|
+
if len(list_indent) <= len(indent) < len(list_indent) + 4:
|
|
687
|
+
first_prefix = cont_prefix = list_indent
|
|
688
|
+
acc = [line]
|
|
689
|
+
in_list = True
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
# Any other indented line (not a list continuation): could be indented
|
|
693
|
+
# code or a block we don't reflow. Keep verbatim — never dedent it. The
|
|
694
|
+
# `_canonical` check can't guard this (it collapses leading whitespace).
|
|
695
|
+
if line[:1].isspace():
|
|
696
|
+
flush()
|
|
697
|
+
out.append(line)
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
if _PASS_COL.match(line):
|
|
701
|
+
flush()
|
|
702
|
+
list_indent = ""
|
|
703
|
+
out.append(line)
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
# Column-0 prose. If we were in a list, this ends it.
|
|
707
|
+
if in_list:
|
|
708
|
+
flush()
|
|
709
|
+
list_indent = ""
|
|
710
|
+
acc.append(line)
|
|
711
|
+
|
|
712
|
+
flush()
|
|
713
|
+
if in_comment: # unterminated comment: keep it verbatim, never drop it
|
|
714
|
+
out.extend(comment_acc)
|
|
715
|
+
return "\n".join(out)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _reflow_comment(lines: list[str], width: int) -> list[str]:
|
|
719
|
+
"""Hard-wrap an HTML comment at `width` if it's an @-annotation.
|
|
720
|
+
|
|
721
|
+
Other HTML comments pass through verbatim.
|
|
722
|
+
"""
|
|
723
|
+
raw = "\n".join(lines)
|
|
724
|
+
if not re.search(r"<!--\s*@", raw):
|
|
725
|
+
return lines
|
|
726
|
+
|
|
727
|
+
m = re.match(r"^(\s*<!--)(.*?)(-->\s*)$", raw, re.DOTALL)
|
|
728
|
+
if not m:
|
|
729
|
+
return lines
|
|
730
|
+
|
|
731
|
+
start_marker, inner, end_marker = m.groups()
|
|
732
|
+
inner = re.sub(r"\s+", " ", inner).strip()
|
|
733
|
+
|
|
734
|
+
if not inner:
|
|
735
|
+
return [start_marker + " " + end_marker.strip()]
|
|
736
|
+
|
|
737
|
+
tokens = _tokenize(inner)
|
|
738
|
+
first_prefix = start_marker + " "
|
|
739
|
+
cont_prefix = " " * len(first_prefix)
|
|
740
|
+
|
|
741
|
+
wrapped = _wrap_tokens(
|
|
742
|
+
tokens, first_prefix, cont_prefix, width, guard_line_start=False
|
|
743
|
+
)
|
|
744
|
+
if wrapped:
|
|
745
|
+
# The closer rides on the last line and may carry it to width + 4. That
|
|
746
|
+
# is deliberate (see AGENTS.md, "Markdown conventions"): `-->` is a
|
|
747
|
+
# delimiter, not content, and wrapping it as a token would orphan it
|
|
748
|
+
# onto a line of its own. Pinned by a test — don't "fix" it.
|
|
749
|
+
wrapped[-1] += " -->"
|
|
750
|
+
|
|
751
|
+
return wrapped
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ---------------------------------------------------------------------------
|
|
755
|
+
# Shared: self-check, path classification, file processing.
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# Whitespace between one comment's close and the next one's open. Nothing
|
|
760
|
+
# renders there, and `_split_comment_lines` puts a newline into `--><!--` on
|
|
761
|
+
# purpose, so the self-check must not read that newline as content.
|
|
762
|
+
_BETWEEN_COMMENTS = re.compile(r"-->\s*<!--")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _canonical(text: str) -> list[str]:
|
|
766
|
+
"""Paragraph blocks with all whitespace collapsed.
|
|
767
|
+
|
|
768
|
+
Two texts with the same canonical form differ only in whitespace and so
|
|
769
|
+
render identically and carry identical content.
|
|
770
|
+
"""
|
|
771
|
+
blocks: list[str] = []
|
|
772
|
+
cur: list[str] = []
|
|
773
|
+
|
|
774
|
+
def close() -> None:
|
|
775
|
+
block = re.sub(r"\s+", " ", " ".join(cur)).strip()
|
|
776
|
+
blocks.append(_BETWEEN_COMMENTS.sub("--><!--", block))
|
|
777
|
+
cur.clear()
|
|
778
|
+
|
|
779
|
+
for line in text.split("\n"):
|
|
780
|
+
if _is_blank(line):
|
|
781
|
+
if cur:
|
|
782
|
+
close()
|
|
783
|
+
else:
|
|
784
|
+
cur.append(line)
|
|
785
|
+
if cur:
|
|
786
|
+
close()
|
|
787
|
+
return blocks
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _strip_trailing(text: str) -> str:
|
|
791
|
+
return "\n".join(line.rstrip() for line in text.split("\n"))
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def project_relative(path: str) -> str:
|
|
795
|
+
"""Path relative to the selected project's root, posix.
|
|
796
|
+
|
|
797
|
+
Public because a project's own `text.classifier` needs exactly this to
|
|
798
|
+
decide anything by location, and the alternative is each consumer
|
|
799
|
+
re-deriving the project root and disagreeing with the tool about it.
|
|
800
|
+
|
|
801
|
+
Every other path setting is written relative to the directory holding
|
|
802
|
+
`quirework.toml`, and this one has to agree with them, because what it
|
|
803
|
+
produces is compared against `runs.evidence_roots`. It used to anchor on
|
|
804
|
+
the nearest `pyproject.toml` instead, which is the same directory for a
|
|
805
|
+
book that is its own repository and a different one for a book kept inside
|
|
806
|
+
a larger project: there, a declared evidence root matched nothing, saved
|
|
807
|
+
run records were reflowed, and the configuration looked right on the page.
|
|
808
|
+
|
|
809
|
+
Falls back to the path as given when it lies outside the project, so
|
|
810
|
+
classification still works on a bare filename.
|
|
811
|
+
"""
|
|
812
|
+
from quirework.project import current_project
|
|
813
|
+
|
|
814
|
+
p = Path(path)
|
|
815
|
+
try:
|
|
816
|
+
resolved = p.resolve()
|
|
817
|
+
except OSError:
|
|
818
|
+
resolved = p
|
|
819
|
+
try:
|
|
820
|
+
return resolved.relative_to(current_project().root).as_posix()
|
|
821
|
+
except ValueError:
|
|
822
|
+
return p.as_posix()
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
_MODE_NAMES = ("sentence", "column", "trailing", "skip")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _declared_mode(path: str, table) -> str | None:
|
|
829
|
+
"""The first `[glob, mode]` row of `text.modes` matching the path, if any."""
|
|
830
|
+
rel = project_relative(path)
|
|
831
|
+
for row in table:
|
|
832
|
+
if not (isinstance(row, (list, tuple)) and len(row) == 2):
|
|
833
|
+
raise ValueError(f"text.modes rows are [glob, mode] pairs, not {row!r}")
|
|
834
|
+
glob, mode = row
|
|
835
|
+
if mode not in _MODE_NAMES:
|
|
836
|
+
raise ValueError(
|
|
837
|
+
f"text.modes: {mode!r} is not a mode ({', '.join(_MODE_NAMES)})"
|
|
838
|
+
)
|
|
839
|
+
if globs.matches(rel, glob):
|
|
840
|
+
return mode
|
|
841
|
+
return None
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def classify(path: str) -> str:
|
|
845
|
+
"""Choose a format: a saved run record is never touched, then the project's
|
|
846
|
+
own rule (an extension or a table of globs), then the built-in default."""
|
|
847
|
+
from quirework.project import ConfigurationError, current_project, extension
|
|
848
|
+
|
|
849
|
+
project = current_project()
|
|
850
|
+
configured = project.get("text.classifier")
|
|
851
|
+
table = project.get("text.modes")
|
|
852
|
+
if configured and table:
|
|
853
|
+
raise ConfigurationError(
|
|
854
|
+
"text.classifier and text.modes both choose wrap modes; keep one"
|
|
855
|
+
)
|
|
856
|
+
if configured:
|
|
857
|
+
mode = extension(configured)(path)
|
|
858
|
+
if mode not in _MODE_NAMES:
|
|
859
|
+
raise ValueError(f"text classifier returned an unknown mode: {mode}")
|
|
860
|
+
return mode
|
|
861
|
+
if run_evidence(project_relative(path)):
|
|
862
|
+
return "skip"
|
|
863
|
+
if table:
|
|
864
|
+
mode = _declared_mode(path, table)
|
|
865
|
+
if mode is not None:
|
|
866
|
+
return mode
|
|
867
|
+
if Path(path).name == "GEMINI.md":
|
|
868
|
+
return "trailing"
|
|
869
|
+
if path.endswith(".qmd"):
|
|
870
|
+
return "sentence"
|
|
871
|
+
if path.endswith(".md"):
|
|
872
|
+
return "column"
|
|
873
|
+
return "skip"
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _wrapped(path: str, width: int) -> tuple[str, str] | None:
|
|
877
|
+
"""The file's text and what its convention makes of it; None if skipped."""
|
|
878
|
+
mode = classify(path)
|
|
879
|
+
if mode == "skip":
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
with open(path, encoding="utf-8") as fh:
|
|
883
|
+
original = fh.read()
|
|
884
|
+
trailing_nl = original.endswith("\n")
|
|
885
|
+
body = original.rstrip("\n")
|
|
886
|
+
|
|
887
|
+
if mode == "sentence":
|
|
888
|
+
result = rewrap(body)
|
|
889
|
+
elif mode == "column":
|
|
890
|
+
result = reflow_columns(body, width)
|
|
891
|
+
else: # trailing
|
|
892
|
+
result = body
|
|
893
|
+
result = _strip_trailing(result)
|
|
894
|
+
if trailing_nl:
|
|
895
|
+
result += "\n"
|
|
896
|
+
|
|
897
|
+
if _canonical(original) != _canonical(result):
|
|
898
|
+
raise SystemExit(
|
|
899
|
+
f"ABORT: {path}: content would change (not just whitespace). "
|
|
900
|
+
"This is a bug in the wrapper; nothing written."
|
|
901
|
+
)
|
|
902
|
+
return original, result
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def at_fixed_point(path: str, width: int = DEFAULT_WIDTH) -> bool:
|
|
906
|
+
"""Whether wrapping the file would leave it exactly as it is."""
|
|
907
|
+
texts = _wrapped(path, width)
|
|
908
|
+
return texts is None or texts[0] == texts[1]
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def process(path: str, check: bool, width: int = DEFAULT_WIDTH) -> bool:
|
|
912
|
+
"""Return True if the file already matches its convention."""
|
|
913
|
+
texts = _wrapped(path, width)
|
|
914
|
+
if texts is None:
|
|
915
|
+
return True
|
|
916
|
+
original, result = texts
|
|
917
|
+
if result == original:
|
|
918
|
+
return True
|
|
919
|
+
if check:
|
|
920
|
+
print(f"needs wrapping: {path}")
|
|
921
|
+
return False
|
|
922
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
923
|
+
fh.write(result)
|
|
924
|
+
print(f"wrapped: {path}")
|
|
925
|
+
return False
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
MODES = """\
|
|
929
|
+
The mode is chosen by path, so just pass files and each gets the right one:
|
|
930
|
+
|
|
931
|
+
sentence one sentence per line (semantic line breaks)
|
|
932
|
+
chapter prose: any .qmd
|
|
933
|
+
column hard wrap at 72 columns
|
|
934
|
+
every other Markdown file, including guides, READMEs, the
|
|
935
|
+
spec, and the per-chapter metadata sidecars (_marks/*.md),
|
|
936
|
+
whose @-mark bodies are reflowed to the width with a
|
|
937
|
+
hanging indent
|
|
938
|
+
trailing strip trailing whitespace, never reflow
|
|
939
|
+
GEMINI.md
|
|
940
|
+
skip never touched
|
|
941
|
+
files inside a saved run record, which stay verbatim
|
|
942
|
+
|
|
943
|
+
A project chooses differently in its configuration. text.modes is a
|
|
944
|
+
list of [glob, mode] rows over project-relative paths, first match
|
|
945
|
+
wins, and anything unmatched falls back to the rules above:
|
|
946
|
+
|
|
947
|
+
[text]
|
|
948
|
+
modes = [["content/**/*.md", "sentence"], ["**/*.md", "column"]]
|
|
949
|
+
|
|
950
|
+
text.classifier instead selects a function that takes a path and
|
|
951
|
+
returns one of these four names, for rules a glob cannot say.
|
|
952
|
+
Files inside a saved run record are skipped under either.
|
|
953
|
+
"""
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def main(argv: list[str]) -> int:
|
|
957
|
+
# argparse (stdlib, so the module stays standalone for the hook) rather
|
|
958
|
+
# than the old hand-rolled loop: that loop silently swallowed any unknown
|
|
959
|
+
# --flag, so a typo like `--chek` REWROTE the files it was asked to check.
|
|
960
|
+
# For a tool whose safety hinges on one flag, unknown flags must be fatal.
|
|
961
|
+
ap = argparse.ArgumentParser(
|
|
962
|
+
prog="quirework text wrap",
|
|
963
|
+
description="Wrap Markdown to the repo's line conventions and strip "
|
|
964
|
+
"trailing whitespace.",
|
|
965
|
+
epilog=MODES,
|
|
966
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
967
|
+
)
|
|
968
|
+
ap.add_argument(
|
|
969
|
+
"files", nargs="+", metavar="FILE", help="Markdown file(s) to wrap in place"
|
|
970
|
+
)
|
|
971
|
+
ap.add_argument(
|
|
972
|
+
"--check",
|
|
973
|
+
action="store_true",
|
|
974
|
+
help="exit non-zero if a file is unwrapped or has trailing "
|
|
975
|
+
"whitespace; write nothing (what the pre-commit hook runs)",
|
|
976
|
+
)
|
|
977
|
+
ap.add_argument(
|
|
978
|
+
"--width",
|
|
979
|
+
type=int,
|
|
980
|
+
default=DEFAULT_WIDTH,
|
|
981
|
+
metavar="N",
|
|
982
|
+
help="override the 72-column target for column mode",
|
|
983
|
+
)
|
|
984
|
+
args = ap.parse_args(argv)
|
|
985
|
+
|
|
986
|
+
ok = True
|
|
987
|
+
missing = False
|
|
988
|
+
for path in args.files:
|
|
989
|
+
try:
|
|
990
|
+
if not process(path, args.check, args.width):
|
|
991
|
+
ok = False
|
|
992
|
+
except OSError as e:
|
|
993
|
+
print(f"wrap: cannot read {path}: {e.strerror or e}", file=sys.stderr)
|
|
994
|
+
missing = True
|
|
995
|
+
if missing:
|
|
996
|
+
return 2
|
|
997
|
+
return 0 if (ok or not args.check) else 1
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
if __name__ == "__main__":
|
|
1001
|
+
raise SystemExit(main(sys.argv[1:]))
|