Skip to main content

concinnity_dev/docs/
mod.rs

1// `cn docs`: write the asset reference pages under docs/assets/.
2//
3// The prose is rustdoc, serde keys, and `Default` literals, none of which
4// survive compilation, so the reference is read from the engine's own asset
5// sources each time this runs: `schema` parses them, `reference` joins the two
6// trees over the authoring registry and renders each body, `page` assembles the
7// pages. That makes this a command for a checkout of the engine, which is the
8// only place the pages are regenerated.
9//
10// The pages are committed to the repository. Whether they still match the
11// sources is a question about a checkout, not about this code, so it belongs to
12// a repository check rather than a unit test: a test that reads the committed
13// pages passes or fails on files no test wrote.
14
15mod page;
16mod reference;
17mod render;
18mod schema;
19
20use page::{AUTOGEN_MARKER, IndexEntry, render_index, render_page};
21use reference::AssetDoc;
22
23use std::collections::BTreeMap;
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28// Where the pages land, relative to the directory given on the command line.
29const PAGES_DIR: &str = "docs/assets";
30
31// The whole reference as markdown, keyed by page file name (`Prop.md`,
32// `index.md`).
33fn pages(docs: &[AssetDoc]) -> BTreeMap<String, String> {
34    let mut out = BTreeMap::new();
35    for d in docs {
36        out.insert(
37            format!("{}.md", d.type_name),
38            render_page(&d.type_name, &d.full_doc),
39        );
40    }
41
42    let index = |reference_types: bool| -> Vec<IndexEntry> {
43        docs.iter()
44            .filter(|d| d.is_reference_type == reference_types)
45            .map(|d| IndexEntry {
46                name: d.type_name.clone(),
47                summary: d.summary.clone(),
48            })
49            .collect()
50    };
51    out.insert(
52        "index.md".to_string(),
53        render_index(&index(false), &index(true)),
54    );
55    out
56}
57
58// Write the pages under `<root>/docs/assets`, defaulting to the current
59// directory. `<root>` is also the engine checkout the prose is read from.
60// Unchanged pages are left alone, so running this on an up-to-date tree touches
61// nothing.
62/// Regenerate the asset reference pages under `docs/assets/`, read out of the
63/// engine's own schema sources.
64///
65/// `root` is the engine checkout to read from; `None` uses the working
66/// directory.
67pub fn docs(root: Option<&str>) -> io::Result<()> {
68    let engine_root = PathBuf::from(root.unwrap_or("."));
69    let pages = pages(&reference::build(&engine_root)?);
70    let dir = engine_root.join(PAGES_DIR);
71    let (written, removed) = write_pages(&dir, &pages)?;
72
73    println!(
74        "{} asset pages in {} ({written} written, {removed} removed)",
75        pages.len(),
76        dir.display()
77    );
78    Ok(())
79}
80
81// Put `pages` on disk in `dir`, pruning the generated pages no longer among
82// them. Returns how many were written and how many pruned. Unchanged pages are
83// left alone, so running this on an up-to-date tree touches nothing.
84fn write_pages(dir: &Path, pages: &BTreeMap<String, String>) -> io::Result<(usize, usize)> {
85    fs::create_dir_all(dir)?;
86
87    let mut written = 0usize;
88    for (file, content) in pages {
89        let path = dir.join(file);
90        if fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) {
91            continue;
92        }
93        fs::write(&path, content)?;
94        written += 1;
95    }
96    Ok((written, remove_stale_pages(dir, pages)?))
97}
98
99// Drop generated pages no longer in the reference (a renamed or deleted asset).
100// Only files carrying the auto-generated marker are removed, so a hand-authored
101// page dropped in the directory survives.
102fn remove_stale_pages(dir: &Path, keep: &BTreeMap<String, String>) -> io::Result<usize> {
103    let mut removed = 0;
104    for entry in fs::read_dir(dir)? {
105        let path = entry?.path();
106        if path.extension().and_then(|e| e.to_str()) != Some("md") {
107            continue;
108        }
109        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
110            continue;
111        };
112        if keep.contains_key(name) {
113            continue;
114        }
115        if fs::read_to_string(&path).is_ok_and(|s| s.starts_with(AUTOGEN_MARKER)) {
116            fs::remove_file(&path)?;
117            removed += 1;
118        }
119    }
120    Ok(removed)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    // A vocabulary the test wrote, in the shape the extractor reads: rustdoc on
128    // the struct and on each field, and a `Default` impl for the defaults the
129    // parameter table renders.
130    //
131    // Reading the engine's own sources instead would tie these to whichever
132    // assets it happens to declare, and would assert nothing a source edit
133    // could not silently satisfy: the anchor-link check below only means
134    // something because this vocabulary contains an anchor link.
135    const SOURCES: &str = r#"
136        /// A widget in the world.
137        ///
138        /// The shape it embeds is a [collider](#widgetcollider).
139        pub struct Widget {
140            /// The mesh to draw.
141            pub mesh: String,
142            /// The shape it collides with.
143            pub collider: Option<WidgetCollider>,
144        }
145        impl Default for Widget {
146            fn default() -> Self {
147                Self { mesh: "cube".to_string(), collider: None }
148            }
149        }
150
151        /// A collider shape a widget embeds.
152        pub struct WidgetCollider {
153            /// Half the box's size on each axis.
154            pub half_extents: [f32; 3],
155        }
156        impl Default for WidgetCollider {
157            fn default() -> Self {
158                Self { half_extents: [0.5, 0.5, 0.5] }
159            }
160        }
161
162        /// A gadget that makes noise.
163        pub struct Gadget {
164            /// How loud, from silent to full.
165            pub volume: f32,
166        }
167        impl Default for Gadget {
168            fn default() -> Self {
169                Self { volume: 1.0 }
170            }
171        }
172
173        /// Engine bookkeeping no world declares.
174        pub struct Internal {
175            /// A counter.
176            pub ticks: u32,
177        }
178        impl Default for Internal {
179            fn default() -> Self {
180                Self { ticks: 0 }
181            }
182        }
183    "#;
184
185    // The reference over `SOURCES`, and the tree it was read from. The tree is
186    // returned so it outlives the borrow-free `Vec` the caller works with.
187    fn synthetic_reference() -> (concinnity_testing::TempTree, Vec<AssetDoc>) {
188        let tree = concinnity_testing::TempTree::new();
189        tree.write("schema/vocabulary.rs", SOURCES);
190        // A non-Rust neighbour the walk must skip.
191        tree.write("schema/notes.md", "not rust");
192
193        let components = [
194            reference::ComponentMeta::pass_through("Widget", "External"),
195            reference::ComponentMeta::pass_through("Gadget", "External"),
196            // Never declared in a world, so it must get no page.
197            reference::ComponentMeta::pass_through("Internal", "RuntimeOnly"),
198        ];
199        let docs = reference::build_from(&[tree.join("schema")], &components)
200            .expect("the synthetic sources parse");
201        (tree, docs)
202    }
203
204    // Writing into a fresh directory produces the whole page set; a stale
205    // generated page is pruned on the next run and a hand-authored one is not.
206    #[test]
207    fn writing_is_complete_and_prunes_only_generated_pages() {
208        let tree = concinnity_testing::TempTree::new();
209        let dir = tree.path();
210
211        let pages: BTreeMap<String, String> = ["Prop", "Texture"]
212            .iter()
213            .map(|n| (format!("{n}.md"), render_page(n, "A body.")))
214            .collect();
215        assert_eq!(write_pages(dir, &pages).expect("first run"), (2, 0));
216        for (file, content) in &pages {
217            assert_eq!(
218                &fs::read_to_string(dir.join(file)).expect("written"),
219                content
220            );
221        }
222
223        // A second run over an unchanged tree touches nothing.
224        assert_eq!(write_pages(dir, &pages).expect("second run"), (0, 0));
225
226        fs::write(dir.join("Gone.md"), format!("{AUTOGEN_MARKER}\n\n# Gone\n")).unwrap();
227        fs::write(dir.join("notes.md"), "hand written\n").unwrap();
228        // Non-Markdown neighbours are skipped outright, marker or not.
229        fs::write(dir.join("diagram.png"), format!("{AUTOGEN_MARKER}\n")).unwrap();
230        assert_eq!(write_pages(dir, &pages).expect("third run"), (0, 1));
231        assert!(!dir.join("Gone.md").exists(), "stale page should be pruned");
232        assert!(
233            dir.join("notes.md").exists(),
234            "hand-authored page should stay"
235        );
236        assert!(dir.join("diagram.png").exists(), "non-page should stay");
237    }
238
239    fn describe<'a>(docs: &'a [AssetDoc], type_name: &str) -> Option<&'a AssetDoc> {
240        docs.iter()
241            .find(|d| d.type_name.eq_ignore_ascii_case(type_name))
242    }
243
244    // An asset is found by name whatever its casing, a type a field embeds is
245    // documented in its own right rather than only inlined, and a RuntimeOnly
246    // component -- one no world declares -- gets no page at all.
247    #[test]
248    fn every_documented_type_is_found_by_name() {
249        let (_tree, docs) = synthetic_reference();
250
251        let d = describe(&docs, "Widget").expect("Widget should be documented");
252        assert_eq!(d.type_name, "Widget");
253        assert!(d.full_doc.contains(&d.summary));
254        assert!(describe(&docs, "widget").is_some());
255        assert!(describe(&docs, "WIDGET").is_some());
256        assert!(describe(&docs, "NotARealAsset").is_none());
257
258        let embedded = describe(&docs, "WidgetCollider").expect("the embedded type is documented");
259        assert!(embedded.is_reference_type);
260        assert!(!d.is_reference_type, "an asset is not a reference type");
261
262        assert!(
263            describe(&docs, "Internal").is_none(),
264            "a RuntimeOnly component is engine-internal and gets no page"
265        );
266    }
267
268    // Every entry resolved real prose. An empty summary means the sources went
269    // unread, which would otherwise surface as a page set of bare titles.
270    #[test]
271    fn every_type_resolved_documentation() {
272        let (_tree, docs) = synthetic_reference();
273        assert_eq!(docs.len(), 3, "two assets and the type they embed");
274
275        for d in &docs {
276            assert!(!d.summary.is_empty(), "{} has no summary", d.type_name);
277            assert!(
278                !d.summary.contains('\n'),
279                "{}'s summary spans multiple lines: {:?}",
280                d.type_name,
281                d.summary
282            );
283        }
284
285        // The summary is the first paragraph, not the whole body.
286        let widget = describe(&docs, "Widget").expect("Widget");
287        assert_eq!(widget.summary, "A widget in the world.");
288
289        // A field's own prose and its default both reach the parameter table.
290        assert!(
291            widget.full_doc.contains("The mesh to draw."),
292            "{:?}",
293            widget.full_doc
294        );
295        assert!(widget.full_doc.contains("cube"), "{:?}", widget.full_doc);
296    }
297
298    #[test]
299    fn pages_cover_every_type_plus_the_index() {
300        let (_tree, docs) = synthetic_reference();
301        let pages = pages(&docs);
302
303        assert_eq!(pages.len(), docs.len() + 1);
304        assert!(pages.contains_key("index.md"));
305        for d in &docs {
306            let page = &pages[&format!("{}.md", d.type_name)];
307            assert!(page.starts_with(AUTOGEN_MARKER));
308            assert!(page.contains(&format!("# {}", d.type_name)));
309        }
310    }
311
312    // No `](#anchor)` cross-reference survives into a page's prose: every one is
313    // rewritten to a relative `Name.md` link. Code spans and fenced blocks are
314    // exempt, since they never render as links and a doc may legitimately show
315    // anchor syntax verbatim (StoryImport documents its own Markdown dialect).
316    //
317    // `SOURCES` contains such a link, so this fails if the rewriting stops
318    // happening -- not only if some asset's prose happens to carry one.
319    #[test]
320    fn no_in_page_anchor_links_remain() {
321        let (_tree, docs) = synthetic_reference();
322        let widget = describe(&docs, "Widget").expect("Widget");
323
324        assert!(
325            widget.full_doc.contains("](WidgetCollider.md)"),
326            "the anchor was rewritten to a relative page link: {:?}",
327            widget.full_doc
328        );
329        for d in &docs {
330            assert!(
331                !prose_only(&d.full_doc).contains("](#"),
332                "{} still has an in-page anchor link outside code: {:?}",
333                d.type_name,
334                d.full_doc
335            );
336        }
337    }
338
339    // Strip fenced code blocks and inline code spans, leaving the prose that
340    // renders as markdown.
341    fn prose_only(doc: &str) -> String {
342        let mut out = String::new();
343        let mut in_fence = false;
344        for line in doc.lines() {
345            if line.trim_start().starts_with("```") {
346                in_fence = !in_fence;
347                continue;
348            }
349            if in_fence {
350                continue;
351            }
352            // Drop the content of `...` spans; an unpaired backtick keeps the
353            // rest of the line, which errs toward checking more, not less.
354            let mut parts = line.split('`');
355            out.push_str(parts.next().unwrap_or(""));
356            while let (Some(_code), Some(prose)) = (parts.next(), parts.next()) {
357                out.push_str(prose);
358            }
359            out.push('\n');
360        }
361        out
362    }
363}