1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
use crate::docs::markdown::renderer::MarkdownRenderer;
use crate::docs::models::SpecConfig;
use crate::error::UsageErr;
impl MarkdownRenderer {
/// The settings reference for a spec's `config` block.
///
/// Empty string when there is nothing to say, so a caller can concatenate it without
/// checking — a CLI with no settings should not grow a blank section.
pub fn render_config(&self) -> Result<String, UsageErr> {
// From the raw block, not from `self.spec.config`: that one was rendered by `new`
// before the builder options were set, and rendering is once-only, so taking it would
// silently ignore `replace_pre_with_code_fences`. Same reason `render_cmd` converts
// from the raw command it is given.
let mut config = SpecConfig::from(&self.raw_config);
if config.is_empty() {
return Ok(String::new());
}
config.render_md(self);
self.render_with("config_template.md.tera", |ctx| {
ctx.insert("config", &config)
})
}
}
#[cfg(test)]
mod tests {
use crate::docs::markdown::renderer::MarkdownRenderer;
use insta::assert_snapshot;
fn rendered(src: &str) -> String {
let spec: crate::Spec = src.parse().unwrap();
MarkdownRenderer::new(spec)
.with_replace_pre_with_code_fences(true)
.render_config()
.unwrap()
}
#[test]
fn a_cli_with_no_settings_renders_nothing() {
assert_eq!(rendered("name \"ex\"\nbin \"ex\"\n"), "");
}
#[test]
fn explicit_optionality_and_aliases_are_documented() {
let page = rendered(
r#"
name "ex"
bin "ex"
config {
prop "jobs" type="uint" optional=#false {
alias "parallelism" "threads"
}
}
"#,
);
assert!(
page.contains("**aliases**: `parallelism`, `threads`"),
"{page}"
);
assert!(page.contains("**optional**: false"), "{page}");
}
#[test]
fn the_facts_list_starts_on_its_own_line_whatever_its_first_item_is() {
// The blank line that opens the list was emitted inside the `type_` branch, so a prop
// with a default and no declared type put its first list item straight against the
// heading — and a deprecated one put it against the admonition's closing `:::`, where
// some renderers read it as part of the admonition rather than as a list.
let page = rendered(
r##"
name "ex"
bin "ex"
config {
prop "untyped" default=1 help="No declared type"
prop "gone" default=2 deprecated="Use untyped." help="Also no declared type"
}
"##,
);
assert!(
page.contains("## `untyped`\n\n- **default**: `1`"),
"the list item is against the heading:\n{page:?}"
);
assert!(
page.contains(":::\n\n- **default**: `2`"),
"the list item is against the admonition:\n{page:?}"
);
}
#[test]
fn the_settings_section_sits_under_the_title_on_the_single_file_page() {
// Two things were wrong here at once, and the second hid the first: `{%- include %}`
// stripped the blank line before the section, so its heading was glued onto the last
// line of the command above it — `Run# Configuration` — and a `header_level` decrement
// put that heading at level 1, beside the document's own title.
let spec: crate::Spec = r##"
name "ex"
bin "ex"
config {
prop "jobs" type="uint" help="How many"
}
cmd "run" help="Run"
"##
.parse()
.unwrap();
let page = MarkdownRenderer::new(spec).render_spec().unwrap();
assert!(
page.contains("\n\n## Configuration\n"),
"the heading is glued to the line above it, or at the wrong level:\n{page}"
);
// And the settings sit below it, a level deeper than the commands' own level.
assert!(page.contains("### `jobs`"), "{page}");
}
#[test]
fn a_setting_s_long_help_gets_the_rendering_options_it_was_asked_for() {
// `MarkdownRenderer::new` renders the whole docs model eagerly — before the builder
// methods that set the options — and rendering marks each item done, so a second pass
// no-ops. Taking the already-rendered config meant `replace_pre_with_code_fences` did
// nothing at all to a setting's long help, silently, while doing its job everywhere
// else on the same page.
let src = r##"
name "ex"
bin "ex"
config {
prop "shell" help="Which shell" {
long_help "Run it like this:\n\n ex --shell bash\n"
}
}
"##;
let spec: crate::Spec = src.parse().unwrap();
let with_fences = MarkdownRenderer::new(spec.clone())
.with_replace_pre_with_code_fences(true)
.render_config()
.unwrap();
assert!(
with_fences.contains("```"),
"the option did not reach the setting's help:\n{with_fences}"
);
// And without it the block stays indented, so the assertion above is about the option
// rather than about something else in the pipeline.
let without = MarkdownRenderer::new(spec.clone()).render_config().unwrap();
assert!(!without.contains("```"), "{without}");
assert!(without.contains(" ex --shell bash"), "{without}");
// The single-file page renders the same model by its own path, and had the same bug.
let whole = MarkdownRenderer::new(spec)
.with_replace_pre_with_code_fences(true)
.render_spec()
.unwrap();
assert!(
whole.contains("```"),
"the option did not reach the settings section of the whole-spec page:\n{whole}"
);
}
#[test]
fn the_index_links_the_settings_page_beside_it() {
// `--multi` writes settings.md next to index.md, and a reader who starts at the
// index — which is what an index is for — has to be able to get there.
let with_settings: crate::Spec = r##"
name "ex"
bin "ex"
config {
prop "jobs" type="uint"
}
cmd "run" help="Run"
"##
.parse()
.unwrap();
let renderer = MarkdownRenderer::new(with_settings);
let index = renderer.render_index().unwrap();
assert!(
index.contains(&format!("[Settings](/{})", renderer.config_page())),
"{index}"
);
// The page name does not move when a `settings` command appears — mise has exactly that
// command, and `settings.md` would have been its page. A name that switched would leave
// the abandoned one behind as a stale page on the next run.
let collides: crate::Spec = r##"
name "ex"
bin "ex"
config {
prop "jobs" type="uint"
}
cmd "settings" help="Manage settings"
"##
.parse()
.unwrap();
let renderer = MarkdownRenderer::new(collides);
assert_eq!(renderer.config_page(), "configuration.md");
assert_eq!(renderer.config_page_collision(), None);
// And the one collision left is reported rather than silent.
let clash: crate::Spec = r##"
name "ex"
bin "ex"
config {
prop "jobs" type="uint"
}
cmd "configuration" help="Somebody really did this"
"##
.parse()
.unwrap();
assert_eq!(
MarkdownRenderer::new(clash).config_page_collision(),
Some("configuration")
);
// And a CLI with no settings gets no link, because there is no page: the two are
// gated on the same condition so the index cannot point at a file nothing wrote.
let without: crate::Spec = "name \"ex\"\nbin \"ex\"\ncmd \"run\" help=\"Run\"\n"
.parse()
.unwrap();
let renderer = MarkdownRenderer::new(without);
let index = renderer.render_index().unwrap();
// Against the name the page is actually written under, not a name nothing uses any
// more: asserting the absence of `settings.md` passed happily while a broken gate
// emitted a link to `configuration.md`.
assert!(
!index.contains(&format!("[Settings](/{}", renderer.config_page())),
"{index}"
);
}
#[test]
fn a_block_of_only_files_reaches_every_output() {
// Where the config files live is the part a reader cannot guess, and a CLI may
// describe the chain before it declares its first setting. Three output paths render
// this model — its own page, the single-file page, and the manpage — and each had its
// own idea of when there was something to render: two gated on props, so the same
// spec documented its files in one place and not the others.
let src = r##"
name "ex"
bin "ex"
config {
file "/etc/ex/config.toml" scope="system"
file "ex.toml" findup=#true
}
"##;
let spec: crate::Spec = src.parse().unwrap();
let renderer = MarkdownRenderer::new(spec);
let page = renderer.render_config().unwrap();
assert!(page.contains("ex.toml"), "{page}");
let whole = renderer.render_spec().unwrap();
assert!(
whole.contains("ex.toml"),
"the single-file page dropped the file chain:\n{whole}"
);
}
#[test]
fn every_part_of_a_prop_reaches_the_page() {
assert_snapshot!(rendered(
r##"
name "hk"
bin "hk"
config {
source "git" name="git config" doc_hint="git config `{key}`"
file "~/.config/hk/config.toml" scope="global"
file "hk.toml" findup=#true
prop "jobs" type="uint" default=0 default_note="0 = auto-detect" \
help="Number of parallel jobs" since="1.0.0" help_heading="Performance" {
cli "--jobs" "-j"
env "HK_JOBS" "HK_JOB"
source "git" "hk.jobs"
example "hk check --jobs 4"
}
prop "exclude" type="list<string>" merge="union" help="Patterns to skip" {
default "target" "node_modules"
env "HK_EXCLUDE"
}
prop "stash" type="string" help="How to stash" {
choices {
choice "git" help="Use `git stash`"
choice "none" help="No stashing"
}
}
prop "trusted" type="bool" scope="global" help="Trust the config"
prop "old" deprecated="Use jobs instead." deprecated_remove_at="2027.12.0" help="Old"
prop "secret" hide=#true help="Not for the page"
}
"##
));
}
}