xtask/codegen/
assists_doc_tests.rs

1//! Generates `assists_generated.md` documentation.
2
3use std::{fmt, fs, path::Path};
4
5use stdx::format_to_acc;
6
7use crate::{
8    codegen::{CommentBlock, Location, add_preamble, ensure_file_contents, reformat},
9    project_root,
10    util::list_rust_files,
11};
12
13pub(crate) fn generate(check: bool) {
14    let assists = Assist::collect();
15
16    {
17        // Generate doctests.
18
19        let mut buf = "
20use super::check_doc_test;
21"
22        .to_owned();
23        for assist in assists.iter() {
24            for (idx, section) in assist.sections.iter().enumerate() {
25                let test_id =
26                    if idx == 0 { assist.id.clone() } else { format!("{}_{idx}", &assist.id) };
27                let test = format!(
28                    r######"
29#[test]
30fn doctest_{}() {{
31    check_doc_test(
32        "{}",
33r#####"
34{}"#####, r#####"
35{}"#####)
36}}
37"######,
38                    &test_id,
39                    &assist.id,
40                    reveal_hash_comments(&section.before),
41                    reveal_hash_comments(&section.after)
42                );
43
44                buf.push_str(&test)
45            }
46        }
47        let buf = add_preamble(crate::flags::CodegenType::AssistsDocTests, reformat(buf));
48        ensure_file_contents(
49            crate::flags::CodegenType::AssistsDocTests,
50            &project_root().join("crates/ide-assists/src/tests/generated.rs"),
51            &buf,
52            check,
53        );
54    }
55
56    // Do not generate assists manual when run with `--check`
57    if check {
58        return;
59    }
60
61    {
62        // Generate assists manual. Note that we do _not_ commit manual to the
63        // git repo. Instead, `cargo xtask release` runs this test before making
64        // a release.
65
66        let contents = add_preamble(
67            crate::flags::CodegenType::AssistsDocTests,
68            assists.into_iter().map(|it| it.to_string()).collect::<Vec<_>>().join("\n\n"),
69        );
70        let dst = project_root().join("docs/book/src/assists_generated.md");
71        fs::write(dst, contents).unwrap();
72    }
73}
74
75#[derive(Debug)]
76struct Section {
77    doc: String,
78    before: String,
79    after: String,
80}
81
82#[derive(Debug)]
83struct Assist {
84    id: String,
85    location: Location,
86    sections: Vec<Section>,
87}
88
89impl Assist {
90    fn collect() -> Vec<Assist> {
91        let handlers_dir = project_root().join("crates/ide-assists/src/handlers");
92
93        let mut res = Vec::new();
94        for path in list_rust_files(&handlers_dir) {
95            collect_file(&mut res, path.as_path());
96        }
97        res.sort_by(|lhs, rhs| lhs.id.cmp(&rhs.id));
98        return res;
99
100        fn collect_file(acc: &mut Vec<Assist>, path: &Path) {
101            let text = fs::read_to_string(path).unwrap();
102            let comment_blocks = CommentBlock::extract("Assist", &text);
103
104            for block in comment_blocks {
105                let id = block.id;
106                assert!(
107                    id.chars().all(|it| it.is_ascii_lowercase() || it == '_'),
108                    "invalid assist id: {id:?}"
109                );
110                let mut lines = block.contents.iter().peekable();
111                let location = Location { file: path.to_path_buf(), line: block.line };
112                let mut assist = Assist { id, location, sections: Vec::new() };
113
114                while lines.peek().is_some() {
115                    let doc = take_until(lines.by_ref(), "```").trim().to_owned();
116                    assert!(
117                        (doc.chars().next().unwrap().is_ascii_uppercase() && doc.ends_with('.'))
118                            || !assist.sections.is_empty(),
119                        "\n\n{}: assist docs should be proper sentences, with capitalization and a full stop at the end.\n\n{}\n\n",
120                        &assist.id,
121                        doc,
122                    );
123
124                    let before = take_until(lines.by_ref(), "```");
125
126                    assert_eq!(lines.next().unwrap().as_str(), "->");
127                    assert_eq!(lines.next().unwrap().as_str(), "```");
128                    let after = take_until(lines.by_ref(), "```");
129
130                    assist.sections.push(Section { doc, before, after });
131                }
132
133                acc.push(assist)
134            }
135        }
136
137        fn take_until<'a>(lines: impl Iterator<Item = &'a String>, marker: &str) -> String {
138            let mut buf = Vec::new();
139            for line in lines {
140                if line == marker {
141                    break;
142                }
143                buf.push(line.clone());
144            }
145            buf.join("\n")
146        }
147    }
148}
149
150impl fmt::Display for Assist {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        let _ = writeln!(
153            f,
154            "### `{}`
155**Source:** {}",
156            self.id, self.location,
157        );
158
159        for section in &self.sections {
160            let before = section.before.replace("$0", "┃"); // Unicode pseudo-graphics bar
161            let after = section.after.replace("$0", "┃");
162            let _ = writeln!(
163                f,
164                "
165{}
166
167#### Before
168```rust
169{}```
170
171#### After
172```rust
173{}```",
174                section.doc,
175                hide_hash_comments(&before),
176                hide_hash_comments(&after)
177            );
178        }
179
180        Ok(())
181    }
182}
183
184fn hide_hash_comments(text: &str) -> String {
185    text.split('\n') // want final newline
186        .filter(|&it| !(it.starts_with("# ") || it == "#"))
187        .fold(String::new(), |mut acc, it| format_to_acc!(acc, "{it}\n"))
188}
189
190fn reveal_hash_comments(text: &str) -> String {
191    text.split('\n') // want final newline
192        .map(|it| {
193            if let Some(stripped) = it.strip_prefix("# ") {
194                stripped
195            } else if it == "#" {
196                ""
197            } else {
198                it
199            }
200        })
201        .fold(String::new(), |mut acc, it| format_to_acc!(acc, "{it}\n"))
202}
203
204#[test]
205fn test() {
206    generate(true);
207}