Skip to main content

verbs/
doctor_docs_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure markdown invocation extraction for `heddle doctor docs`.
3//!
4//! Owns tokenization and sample lifting from markdown text. Clap command
5//! resolution, RecoveryAdvice, filesystem walks, and catalog checks stay
6//! CLI-owned.
7
8use std::path::Path;
9
10/// One `heddle …` invocation extracted from markdown.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DocsInvocation {
13    /// 1-based line number in the source buffer.
14    pub line: usize,
15    /// Display form of the invocation (includes the `heddle` prefix).
16    pub raw: String,
17    /// Tokens after the `heddle` prefix (verb, subverbs, flags, values).
18    pub tokens: Vec<String>,
19}
20
21/// Pull `heddle <…>` invocations out of either inline backtick code
22/// (`` `heddle …` ``) or fenced code blocks. Non-backticked prose is ignored.
23pub fn extract_invocations(text: &str) -> Vec<DocsInvocation> {
24    let mut result = Vec::new();
25    let mut in_fence = false;
26    let mut planned_fence = false;
27    let mut skip_next_planned_line = false;
28    for (idx, line) in text.lines().enumerate() {
29        let line_no = idx + 1;
30        let trimmed = line.trim_start();
31        if trimmed.starts_with("```") {
32            if in_fence {
33                in_fence = false;
34                planned_fence = false;
35            } else {
36                in_fence = true;
37                planned_fence = is_planned_docs_marker(trimmed) || skip_next_planned_line;
38                skip_next_planned_line = false;
39            }
40            continue;
41        }
42        if is_planned_docs_marker(line) {
43            skip_next_planned_line = true;
44            continue;
45        }
46        if skip_next_planned_line {
47            if trimmed.is_empty() {
48                continue;
49            }
50            skip_next_planned_line = false;
51            continue;
52        }
53        if planned_fence {
54            continue;
55        }
56        if in_fence {
57            // Inside a code fence: scan whole line for `heddle …`
58            // tokens, stopping at end of line.
59            let lower = line.trim_start();
60            // Strip a leading shell prompt or comment marker.
61            let cleaned = strip_shell_prefix(lower);
62            if let Some(rest) = cleaned.strip_prefix("heddle ")
63                && let Some(tokens) = tokenize(rest)
64            {
65                result.push(DocsInvocation {
66                    line: line_no,
67                    raw: format!("heddle {}", rest.trim_end()),
68                    tokens,
69                });
70            }
71        } else {
72            // Outside a fence: pull out backticked snippets that begin
73            // with `heddle `.
74            let bytes = line.as_bytes();
75            let mut i = 0;
76            while i < bytes.len() {
77                if bytes[i] == b'`' {
78                    let start = i + 1;
79                    let mut end = start;
80                    while end < bytes.len() && bytes[end] != b'`' {
81                        end += 1;
82                    }
83                    if end <= bytes.len() {
84                        let snippet = &line[start..end];
85                        let cleaned = strip_shell_prefix(snippet);
86                        if let Some(rest) = cleaned.strip_prefix("heddle ")
87                            && let Some(tokens) = tokenize(rest)
88                        {
89                            result.push(DocsInvocation {
90                                line: line_no,
91                                raw: format!("heddle {}", rest.trim_end()),
92                                tokens,
93                            });
94                        }
95                        i = end + 1;
96                        continue;
97                    }
98                }
99                i += 1;
100            }
101        }
102    }
103    result
104}
105
106/// Explicit opt-out for planned or illustrative command surfaces.
107///
108/// Place `<!-- doctor-docs:planned -->` immediately before a markdown
109/// line or fence, or include `doctor-docs:planned` in the fence info
110/// string.
111pub fn is_planned_docs_marker(line: &str) -> bool {
112    line.contains("doctor-docs:planned") || line.contains("doctor-docs: planned")
113}
114
115/// Strip a leading shell prompt (`$ `) or comment marker (`# `).
116pub fn strip_shell_prefix(s: &str) -> &str {
117    let s = s.trim_start();
118    s.strip_prefix("$ ")
119        .or_else(|| s.strip_prefix("# "))
120        .unwrap_or(s)
121}
122
123/// Best-effort word-splitter for verbs, subverbs, and `--flag` / `--flag=value`.
124/// Anything inside `<…>` is treated as a placeholder and left intact as a token.
125pub fn tokenize(s: &str) -> Option<Vec<String>> {
126    let mut out = Vec::new();
127    let mut current = String::new();
128    let mut in_single = false;
129    let mut in_double = false;
130    for c in s.chars() {
131        match c {
132            '\'' if !in_double => in_single = !in_single,
133            '"' if !in_single => in_double = !in_double,
134            ' ' | '\t' if !in_single && !in_double => {
135                if !current.is_empty() {
136                    out.push(std::mem::take(&mut current));
137                }
138            }
139            // Stop on shell control characters — these wreck token
140            // boundaries and signal "this is a longer pipeline" we
141            // probably can't reason about cleanly. `<…>` and `>…<`
142            // are NOT in this set: docs routinely use `<name>` and
143            // `<dir>` as placeholders, and those need to remain
144            // intact so the per-token placeholder check can skip
145            // them.
146            '|' | '&' | ';' if !in_single && !in_double => {
147                if !current.is_empty() {
148                    out.push(std::mem::take(&mut current));
149                }
150                break;
151            }
152            _ => current.push(c),
153        }
154    }
155    if !current.is_empty() {
156        out.push(current);
157    }
158    if out.is_empty() {
159        return None;
160    }
161    Some(out)
162}
163
164/// Heuristic: paths, dotted slugs, or quoted strings are values; bare
165/// identifiers are likely subcommand names.
166pub fn looks_like_value(tok: &str) -> bool {
167    tok.contains('.') || tok.contains('/') || tok.starts_with('"')
168}
169
170/// Repo-relative display path for issue reporting.
171pub fn display_path(repo_root: &Path, file: &Path) -> String {
172    file.strip_prefix(repo_root)
173        .unwrap_or(file)
174        .to_string_lossy()
175        .into_owned()
176}
177
178#[cfg(test)]
179mod tests {
180    use std::path::PathBuf;
181
182    use super::*;
183
184    #[test]
185    fn tokenize_splits_flags_and_preserves_placeholders() {
186        let tokens = tokenize("start <name> --path <dir> --workspace ephemeral").unwrap();
187        assert_eq!(
188            tokens,
189            vec![
190                "start",
191                "<name>",
192                "--path",
193                "<dir>",
194                "--workspace",
195                "ephemeral"
196            ]
197        );
198    }
199
200    #[test]
201    fn tokenize_stops_at_shell_control() {
202        let tokens = tokenize("status --output json | jq .").unwrap();
203        assert_eq!(tokens, vec!["status", "--output", "json"]);
204    }
205
206    #[test]
207    fn tokenize_respects_quotes() {
208        let tokens = tokenize("context set -m \"hello world\"").unwrap();
209        assert_eq!(tokens, vec!["context", "set", "-m", "hello world"]);
210    }
211
212    #[test]
213    fn strip_shell_prefix_variants() {
214        assert_eq!(strip_shell_prefix("$ heddle status"), "heddle status");
215        assert_eq!(strip_shell_prefix("# heddle status"), "heddle status");
216        assert_eq!(strip_shell_prefix("  heddle status"), "heddle status");
217    }
218
219    #[test]
220    fn planned_marker_detection() {
221        assert!(is_planned_docs_marker("<!-- doctor-docs:planned -->"));
222        assert!(is_planned_docs_marker("```sh doctor-docs: planned"));
223        assert!(!is_planned_docs_marker("```bash"));
224    }
225
226    #[test]
227    fn extract_inline_and_fenced_invocations() {
228        let text = "\
229Use `heddle status --output json` here.
230
231```bash
232$ heddle start probe --path /tmp
233```
234";
235        let inv = extract_invocations(text);
236        assert_eq!(inv.len(), 2);
237        assert_eq!(inv[0].tokens[0], "status");
238        assert_eq!(inv[0].line, 1);
239        assert_eq!(inv[1].tokens[0], "start");
240        assert!(inv[1].raw.starts_with("heddle start"));
241    }
242
243    #[test]
244    fn planned_marker_skips_next_inline_line() {
245        let text = "\
246<!-- doctor-docs:planned -->
247`heddle frobnicate --foo`
248`heddle status`
249";
250        let inv = extract_invocations(text);
251        assert_eq!(inv.len(), 1);
252        assert_eq!(inv[0].tokens[0], "status");
253    }
254
255    #[test]
256    fn planned_fence_info_skips_block() {
257        let text = "\
258```sh doctor-docs:planned
259heddle frobnicate --foo
260```
261`heddle status --output json`
262";
263        let inv = extract_invocations(text);
264        assert_eq!(inv.len(), 1);
265        assert_eq!(inv[0].tokens[0], "status");
266    }
267
268    #[test]
269    fn ignores_non_backticked_prose() {
270        let inv = extract_invocations("when using heddle status without backticks");
271        assert!(inv.is_empty());
272    }
273
274    #[test]
275    fn looks_like_value_heuristics() {
276        assert!(looks_like_value("src/lib.rs"));
277        assert!(looks_like_value("pkg.mod"));
278        assert!(looks_like_value("\"quoted\""));
279        assert!(!looks_like_value("marker"));
280    }
281
282    #[test]
283    fn display_path_strips_repo_root() {
284        let root = PathBuf::from("/repo");
285        let file = PathBuf::from("/repo/docs/guide.md");
286        assert_eq!(display_path(&root, &file), "docs/guide.md");
287        assert_eq!(
288            display_path(&root, Path::new("/elsewhere/x.md")),
289            "/elsewhere/x.md"
290        );
291    }
292}