Skip to content

Commit a83653f

Browse files
David-Noble-at-workDavid Noble
andauthored
A command's Long wraps to the terminal, and keeps its shape (#902)
Flag usage has reflowed to the terminal since #755; a command's Long still printed verbatim through cobra's help template, hand-wrapped to whatever width its author guessed, so at COLUMNS=60 the flags wrapped at 60 while the description ran to 83. #755 left Long alone because its prose has shape -- writ verify's policy ladder, writ deploy's conflict table, writ reconcile's entry states -- that a word-wrapper would fold into paragraphs. wrapLong reflows Long under one convention every Long in the tree already follows: an unindented line is prose and joins its neighbors into a paragraph wrapped to the width; an indented line is structure and keeps its break and its indent, wrapping only if it is itself too long, hanging under its text column as a usage line does. Authored breaks in prose are not load-bearing; an author who wants a line kept indents it. wrappedLong sits beside wrappedFlagUsages on the shared root's help template, so every command of every program inherits it. Man pages and the generated reference read Long as authored and do not change. Section 12 of the specification states the rule for usage and Long together. Closes #759 Co-authored-by: David Noble <[email protected]>
1 parent 0e78c7f commit a83653f

4 files changed

Lines changed: 394 additions & 2 deletions

File tree

‎cmd/internal/cli/help_wrap_test.go‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,111 @@ func TestWrapUsage_ANarrowWidthGivesTextTheWholeLine(t *testing.T) {
9999
}
100100
}
101101

102+
// TestWrapLong_ProseReflowsToTheWidth is #759's premise: a paragraph an author wrapped at 80 reflows to the
103+
// terminal, at 60 and at 100 alike, with no line over the width.
104+
func TestWrapLong_ProseReflowsToTheWidth(t *testing.T) {
105+
106+
const long = "Report deployed state: what should be present, where it should have come from, and\n" +
107+
"what's missing or different.\n" +
108+
"\n" +
109+
"The report is derived from the store (the run index plus the persisted graphs and\n" +
110+
"traces) -- never from a directory scan -- and has four sections: the registered layer\n" +
111+
"tree, the deployed inventory classified against the live filesystem, the package\n" +
112+
"operations writ's runs performed, and store health."
113+
114+
for _, width := range []int{60, 100} {
115+
got := strings.Split(wrapLong(long, width), "\n")
116+
for i, l := range got {
117+
if len([]rune(l)) > width {
118+
t.Errorf("width %d: line %d is %d columns: %q", width, i, len([]rune(l)), l)
119+
}
120+
}
121+
blank := 0
122+
for _, l := range got {
123+
if l == "" {
124+
blank++
125+
}
126+
}
127+
if blank != 1 {
128+
t.Errorf("width %d: %d blank lines, want the one that separates the paragraphs:\n%s", width, blank, strings.Join(got, "\n"))
129+
}
130+
}
131+
// The author broke the first line at 82 columns; at 100 the paragraph packs past it.
132+
if first := strings.SplitN(wrapLong(long, 100), "\n", 2)[0]; len([]rune(first)) <= 82 {
133+
t.Errorf("at 100 the first paragraph did not reflow past the authored break: %q", first)
134+
}
135+
}
136+
137+
// TestWrapLong_StructureKeepsItsBreaks is why #755 left `Long` alone: a policy ladder is one entry per line, and
138+
// stays so at any width, indent intact.
139+
func TestWrapLong_StructureKeepsItsBreaks(t *testing.T) {
140+
141+
const long = "What each outcome does to the exit status is the signing policy ladder:\n" +
142+
"\n" +
143+
" ignore No verification at all\n" +
144+
" report (default) Report every outcome; never fail\n" +
145+
" reject Reject anything that is not valid"
146+
147+
got := strings.Split(wrapLong(long, 100), "\n")
148+
want := strings.Split(long, "\n")
149+
if len(got) != len(want) {
150+
t.Fatalf("got %d lines, want %d:\n%s", len(got), len(want), strings.Join(got, "\n"))
151+
}
152+
for i := range want {
153+
if got[i] != want[i] {
154+
t.Errorf("line %d changed:\n got %q\nwant %q", i, got[i], want[i])
155+
}
156+
}
157+
}
158+
159+
// TestWrapLong_AnOverLongStructureLineHangsUnderItsColumn: a table row too wide for the terminal wraps as a
160+
// usage line does, its continuation under the sentence, not under the name.
161+
func TestWrapLong_AnOverLongStructureLineHangsUnderItsColumn(t *testing.T) {
162+
163+
const long = " modified-or-stale Differs, but the run predates recorded content identity: attribution indeterminate"
164+
165+
got := strings.Split(wrapLong(long, 60), "\n")
166+
if len(got) < 2 {
167+
t.Fatalf("did not wrap at 60: %q", got)
168+
}
169+
for i, l := range got {
170+
if len([]rune(l)) > 60 {
171+
t.Errorf("line %d is %d columns: %q", i, len([]rune(l)), l)
172+
}
173+
}
174+
if !strings.HasPrefix(got[1], strings.Repeat(" ", 21)) || strings.HasPrefix(got[1], strings.Repeat(" ", 22)) {
175+
t.Errorf("continuation does not hang under the sentence column (21): %q", got[1])
176+
}
177+
}
178+
179+
// TestWrapLong_ZeroWidthIsUntouched mirrors pflag's meaning of zero: no wrapping at all.
180+
func TestWrapLong_ZeroWidthIsUntouched(t *testing.T) {
181+
182+
const long = "a line the author wrapped\nand its continuation"
183+
if got := wrapLong(long, 0); got != long {
184+
t.Errorf("width 0 changed the text: %q", got)
185+
}
186+
}
187+
188+
// TestWrapLong_MeasuresRunes: a multi-byte character counts as one column, so the wrap lands where it looks.
189+
func TestWrapLong_MeasuresRunes(t *testing.T) {
190+
191+
long := strings.Repeat("é ", 25) + "fin"
192+
for i, l := range strings.Split(wrapLong(long, 20), "\n") {
193+
if len([]rune(l)) > 20 {
194+
t.Errorf("line %d is %d columns: %q", i, len([]rune(l)), l)
195+
}
196+
}
197+
}
198+
199+
// TestWrapLong_TrailingWhitespaceGoes: every line is right-trimmed, so nothing an author left survives.
200+
func TestWrapLong_TrailingWhitespaceGoes(t *testing.T) {
201+
202+
for _, l := range strings.Split(wrapLong("prose with a trailing space \n structure too \n", 80), "\n") {
203+
if strings.TrimRight(l, " ") != l {
204+
t.Errorf("trailing whitespace survived: %q", l)
205+
}
206+
}
207+
}
208+
102209
// endregion

‎cmd/internal/cli/root.go‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,13 @@ func initRootConfig(cmd *cobra.Command, name string) error {
201201
// leading indent and gives the text the whole width instead.
202202
const helpMinimumTextWidth = 24
203203

204-
// wrapHelp makes flag usage wrap to the terminal, keeping any column structure the usage text has.
204+
// wrapHelp makes flag usage and a command's `Long` wrap to the terminal, keeping any structure the text has.
205205
//
206206
// Cobra's default template calls [pflag.FlagSet.FlagUsages], which is `FlagUsagesWrapped(0)`, and zero means
207207
// no wrapping at all -- so without this every line's width is the author's to maintain by hand, correct only
208-
// on a terminal at least as wide as the constant they guessed (#755).
208+
// on a terminal at least as wide as the constant they guessed (#755). Its help template prints `Long` verbatim
209+
// the same way (#759); [wrapLong] reflows it under one convention: an unindented line is prose and reflows
210+
// with its neighbors, an indented line is structure and keeps its break.
209211
//
210212
// pflag's own wrapping is not the answer either. It indents every continuation to the flag's description
211213
// column, having one indent level and no notion of structure, so a two-column usage -- `--output`'s ten
@@ -228,6 +230,14 @@ func wrapHelp(cmd *cobra.Command) {
228230
template = strings.ReplaceAll(template, ".LocalFlags.FlagUsages", "wrappedFlagUsages .LocalFlags")
229231
template = strings.ReplaceAll(template, ".InheritedFlags.FlagUsages", "wrappedFlagUsages .InheritedFlags")
230232
cmd.SetUsageTemplate(template)
233+
234+
cobra.AddTemplateFunc("wrappedLong", func(long string) string {
235+
return wrapLong(long, displayWidth())
236+
})
237+
238+
help := cmd.HelpTemplate()
239+
help = strings.ReplaceAll(help, "{{. | trimTrailingWhitespaces}}", "{{. | wrappedLong | trimTrailingWhitespaces}}")
240+
cmd.SetHelpTemplate(help)
231241
}
232242

233243
// wrapUsage wraps pflag's laid-out usage block to width, line by line.
@@ -333,4 +343,82 @@ func usageTextColumn(line string) int {
333343
return leading
334344
}
335345

346+
// wrapLong reflows a command's `Long` to width under the help convention (#759).
347+
//
348+
// A `Long` is blocks separated by blank lines. Within a block, a line whose first character is not whitespace
349+
// is prose: consecutive prose lines are one paragraph, joined on single spaces and wrapped to width. A line that
350+
// begins with whitespace is structure -- a policy ladder, a table, a list, an example -- and keeps its break and
351+
// its indent; only when it is itself too long does it wrap, as [wrapUsageLine] wraps a usage line. Authored
352+
// breaks in prose are therefore not load-bearing, which is the point: an author who wants a line kept indents it.
353+
//
354+
// Parameters:
355+
// - `long`: the command's `Long`, as authored.
356+
// - `width`: the column count to wrap to; zero or less leaves the text untouched.
357+
//
358+
// Returns:
359+
// - `string`: the reflowed text, with every line right-trimmed.
360+
func wrapLong(long string, width int) string {
361+
362+
if width <= 0 {
363+
return long
364+
}
365+
366+
var lines []string
367+
var paragraph []string
368+
flush := func() {
369+
if len(paragraph) > 0 {
370+
lines = append(lines, wrapProse(strings.Join(paragraph, " "), width)...)
371+
paragraph = nil
372+
}
373+
}
374+
375+
for _, line := range strings.Split(long, "\n") {
376+
line = strings.TrimRight(line, " \t")
377+
switch {
378+
case line == "":
379+
flush()
380+
lines = append(lines, "")
381+
case line[0] == ' ' || line[0] == '\t':
382+
flush()
383+
lines = append(lines, wrapUsageLine(line, width))
384+
default:
385+
paragraph = append(paragraph, line)
386+
}
387+
}
388+
flush()
389+
390+
return strings.Join(lines, "\n")
391+
}
392+
393+
// wrapProse word-wraps one paragraph to width, measuring in runes.
394+
//
395+
// Parameters:
396+
// - `text`: the paragraph; runs of whitespace collapse to one space.
397+
// - `width`: the column count to wrap to.
398+
//
399+
// Returns:
400+
// - `[]string`: the paragraph's lines, none over width unless a single word is.
401+
func wrapProse(text string, width int) []string {
402+
403+
var lines []string
404+
current := ""
405+
406+
for _, word := range strings.Fields(text) {
407+
switch {
408+
case current == "":
409+
current = word
410+
case len([]rune(current))+1+len([]rune(word)) > width:
411+
lines = append(lines, current)
412+
current = word
413+
default:
414+
current += " " + word
415+
}
416+
}
417+
if current != "" {
418+
lines = append(lines, current)
419+
}
420+
421+
return lines
422+
}
423+
336424
// endregion

‎docs/architecture/10-command-line-interface.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,15 @@ does not.
852852
Help text is the specification a user reads. It states what the command does, what its flags mean, and what
853853
its output is — including, for a multi-artifact command, the names of the artifacts it writes.
854854

855+
**Help wraps to the terminal, and an indent means "keep this line".** Flag usage reflows to the width the
856+
program reports — `COLUMNS`, else the terminal capped at 120, else 100 — with continuations hanging under the
857+
text they continue ([#755](https://github.com/NobleFactor/devlore-cli/issues/755)). A command's `Long` reflows
858+
under one convention ([#759](https://github.com/NobleFactor/devlore-cli/issues/759)): an unindented line is
859+
prose and joins its neighbors into a paragraph; an indented line is structure — a policy ladder, a table, a
860+
list, an example — and keeps its break and its indent, wrapping only if it is itself too long. Authored breaks
861+
in prose are not load-bearing; an author who wants a line kept indents it. Man pages and the generated
862+
reference read `Long` as authored.
863+
855864
**`man` is the one route to man pages (ruled 2026-09-02).** The shared `man [command]` from [NewRootCmd]
856865
generates the pages and displays or installs them, the same on all four programs, and `self install` writes
857866
them under the prefix. `star docs man` duplicated that route and `star docs markdown` duplicated `make docs`;

0 commit comments

Comments
 (0)