houdinimd-docs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: houdinimd-docs
3
+ Version: 0.1.0
4
+ Classifier: Programming Language :: Rust
5
+ Classifier: Programming Language :: Python :: Implementation :: CPython
6
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
7
+ Summary: Houdini's own documentation, read out of the install on this machine.
8
+ Keywords: houdini,documentation,sidefx
9
+ Author: JTCHE
10
+ License-Expression: MIT
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
13
+ Project-URL: Homepage, https://houdinimd.com
14
+
15
+ # houdinimd-docs
16
+
17
+ Houdini's own documentation, read out of the Houdini install on this machine.
18
+ It is the documentation engine of the [HoudiniMD](https://houdinimd.com)
19
+ desktop app, compiled from Rust into a Python module. The
20
+ [HoudiniMCP](https://github.com/JTCHE/houdini-mcp) bridge uses it.
21
+
22
+ No documentation ships in this package. Every page comes from the help files
23
+ of an installed Houdini, so the text matches that build exactly.
24
+
25
+ ```python
26
+ import houdinimd_docs
27
+
28
+ docs = houdinimd_docs.Docs("/path/to/a/folder/for/the/index")
29
+ docs.installs() # the Houdini builds found, newest first
30
+ docs.page("nodes/sop/copytopoints") # one page, as Markdown
31
+ docs.search("copy to points", limit=5) # ranked full-text search
32
+ docs.page("hom/hou/Node", build="21.0.829")
33
+ ```
34
+
35
+ Every call returns a JSON string. `page` raises `ValueError` when the build
36
+ has no such page.
37
+
38
+ The first search on a build indexes it into a SQLite file in the folder you
39
+ name, which takes a few seconds. The index stays there, so this happens once
40
+ per build. A page read never waits for it.
41
+
42
+ Without `build`, the reader uses the Houdini in `$HFS`, then the newest build
43
+ it finds. The build comes from the install's own `SYS_Version.h`, never from
44
+ its folder name.
45
+
@@ -0,0 +1,30 @@
1
+ # houdinimd-docs
2
+
3
+ Houdini's own documentation, read out of the Houdini install on this machine.
4
+ It is the documentation engine of the [HoudiniMD](https://houdinimd.com)
5
+ desktop app, compiled from Rust into a Python module. The
6
+ [HoudiniMCP](https://github.com/JTCHE/houdini-mcp) bridge uses it.
7
+
8
+ No documentation ships in this package. Every page comes from the help files
9
+ of an installed Houdini, so the text matches that build exactly.
10
+
11
+ ```python
12
+ import houdinimd_docs
13
+
14
+ docs = houdinimd_docs.Docs("/path/to/a/folder/for/the/index")
15
+ docs.installs() # the Houdini builds found, newest first
16
+ docs.page("nodes/sop/copytopoints") # one page, as Markdown
17
+ docs.search("copy to points", limit=5) # ranked full-text search
18
+ docs.page("hom/hou/Node", build="21.0.829")
19
+ ```
20
+
21
+ Every call returns a JSON string. `page` raises `ValueError` when the build
22
+ has no such page.
23
+
24
+ The first search on a build indexes it into a SQLite file in the folder you
25
+ name, which takes a few seconds. The index stays there, so this happens once
26
+ per build. A page read never waits for it.
27
+
28
+ Without `build`, the reader uses the Houdini in `$HFS`, then the newest build
29
+ it finds. The build comes from the install's own `SYS_Version.h`, never from
30
+ its folder name.
@@ -0,0 +1,18 @@
1
+ [package]
2
+ name = "engine"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ rust-version = "1.85"
6
+ description = "Reads Houdini help out of an install: pages, index and search."
7
+
8
+ [dependencies]
9
+ serde = { version = "1", features = ["derive"] }
10
+ serde_json = "1"
11
+ zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
12
+ wiki = { version = "0.1.0", path = "../wiki" }
13
+ rusqlite = { version = "0.40.2", features = ["bundled"] }
14
+ rayon = "1.12.0"
15
+
16
+ [target."cfg(windows)".dependencies]
17
+ windows-sys = { version = "0.61.2", features = ["Win32_System_Threading"] }
18
+ winreg = "0.56.0"
@@ -0,0 +1,276 @@
1
+ //! Turns a reference written beside a help page into the path the app reads:
2
+ //! a picture or video for the `himage` protocol, and a link for the router.
3
+ //!
4
+ //! A page writes its assets the way the SideFX help server serves them, which
5
+ //! is not the way the install stores them:
6
+ //!
7
+ //! - `/images/playbar/timeline.png` is `playbar/timeline.png` in `images.zip`.
8
+ //! - `../images/BasisSOP.jpg` on `nodes/sop/basis` is `nodes/BasisSOP.jpg` in
9
+ //! the same zip. The `images` segment is a serving path, not a folder.
10
+ //! - `/videos/tween.webm` is a loose file under `$HFS/houdini/help/videos`.
11
+ //! - `/movies/rotate.gif` is the same shape as `videos`, under the name a
12
+ //! package's own help folder uses for it — SideFX Labs ships no `videos/`.
13
+ //!
14
+ //! All three come back as one shape, `images/…`, `videos/…` or `movies/…`, so
15
+ //! the protocol handler has one thing to read and the front-end has nothing
16
+ //! to know.
17
+
18
+ use wiki::{Block, Inline, LinkTarget};
19
+
20
+ /// The asset path for `src` as written on the page at `page`, or `None` when
21
+ /// the reference names nothing this app can read.
22
+ pub fn resolve(page: &str, src: &str) -> Option<String> {
23
+ // `opdef:` and `./MainImage.jpg` name a picture inside an HDA, which is
24
+ // not a file in the install. Only the pages about writing help use them.
25
+ if src.starts_with("opdef:") || src.contains('?') || src.is_empty() {
26
+ return None;
27
+ }
28
+
29
+ let mut parts: Vec<&str> = Vec::new();
30
+ if !src.starts_with('/') {
31
+ // A relative reference stands beside the page, so the page's own name
32
+ // is not part of the base.
33
+ let dir = page
34
+ .trim_matches('/')
35
+ .rsplit_once('/')
36
+ .map_or("", |(d, _)| d);
37
+ parts.extend(dir.split('/'));
38
+ }
39
+ parts.extend(src.split('/'));
40
+
41
+ let mut path: Vec<&str> = Vec::new();
42
+ for part in parts {
43
+ match part {
44
+ "" | "." => {}
45
+ ".." => {
46
+ path.pop()?;
47
+ }
48
+ part => path.push(part),
49
+ }
50
+ }
51
+
52
+ // The first `images`, `videos` or `movies` segment says which store holds
53
+ // the file. Everything before it is the section the page lives in, which
54
+ // the store keeps as its own top folder.
55
+ let at = path.iter().position(|p| *p == "images" || *p == "videos" || *p == "movies")?;
56
+ let store = path.remove(at);
57
+ if path.len() <= at {
58
+ return None;
59
+ }
60
+ Some(format!("{store}/{}", path.join("/")))
61
+ }
62
+
63
+ /// The app path for a wiki link written beside the page rather than from the
64
+ /// help root. `news/22/index` writes `[Solaris|solaris]`, and that means
65
+ /// `/news/22/solaris`, not a page under the index.
66
+ ///
67
+ /// ponytail: the base is the folder the page name sits in, so a page read at
68
+ /// its folder form (`news/22` instead of `news/22/index`) bases one level too
69
+ /// high. The index writes the `/index` form for every folder page, so only a
70
+ /// hand-written address reaches the other one.
71
+ pub fn link(page: &str, target: &str) -> Option<String> {
72
+ if target.is_empty() || target.starts_with('/') {
73
+ return None;
74
+ }
75
+ let dir = page
76
+ .trim_matches('/')
77
+ .rsplit_once('/')
78
+ .map_or("", |(dir, _)| dir);
79
+
80
+ let mut path: Vec<&str> = Vec::new();
81
+ for part in dir.split('/').chain(target.split('/')) {
82
+ match part {
83
+ "" | "." => {}
84
+ ".." => {
85
+ path.pop()?;
86
+ }
87
+ part => path.push(part),
88
+ }
89
+ }
90
+ // A single segment is a section, not a page, and nothing links to one.
91
+ if path.len() < 2 {
92
+ return None;
93
+ }
94
+ Some(format!("/{}", path.join("/")))
95
+ }
96
+
97
+ /// Rewrites every asset reference in a page to the path the `himage` protocol
98
+ /// reads. A reference that names nothing readable is dropped, so the reader
99
+ /// gets the text without a broken frame in the middle of it.
100
+ pub fn rewrite(page: &str, blocks: &mut [Block], name_of: &dyn Fn(&str) -> Option<String>) {
101
+ for block in blocks {
102
+ match block {
103
+ Block::Item {
104
+ name,
105
+ label,
106
+ props,
107
+ children,
108
+ } => {
109
+ if name == "video" {
110
+ for (key, value) in props.iter_mut() {
111
+ if key == "src" {
112
+ *value = resolve(page, value).unwrap_or_default();
113
+ }
114
+ }
115
+ }
116
+ inlines(page, label, name_of);
117
+ rewrite(page, children, name_of);
118
+ }
119
+ Block::Heading {
120
+ title, children, ..
121
+ } => {
122
+ inlines(page, &mut title.main, name_of);
123
+ rewrite(page, children, name_of);
124
+ }
125
+ Block::Section { children, .. } => rewrite(page, children, name_of),
126
+ Block::Definition { term, children, .. } => {
127
+ inlines(page, term, name_of);
128
+ rewrite(page, children, name_of);
129
+ }
130
+ Block::Usage { children, .. } => rewrite(page, children, name_of),
131
+ Block::Paragraph { text } | Block::Summary { text } => inlines(page, text, name_of),
132
+ Block::Subtopic { link, children } => {
133
+ inlines(page, link, name_of);
134
+ rewrite(page, children, name_of);
135
+ }
136
+ Block::Bullets { items } | Block::Numbers { items } => {
137
+ for item in items {
138
+ rewrite(page, &mut item.blocks, name_of);
139
+ }
140
+ }
141
+ Block::Table { rows } => {
142
+ for row in rows {
143
+ for cell in row {
144
+ rewrite(page, &mut cell.blocks, name_of);
145
+ }
146
+ }
147
+ }
148
+ Block::Html { children, .. } => rewrite(page, children, name_of),
149
+ Block::Divider { children, .. } => rewrite(page, children, name_of),
150
+ Block::Code { .. }
151
+ | Block::Include { .. }
152
+ | Block::RawHtml { .. } => {}
153
+ }
154
+ }
155
+ }
156
+
157
+ fn inlines(page: &str, inlines: &mut Vec<Inline>, name_of: &dyn Fn(&str) -> Option<String>) {
158
+ inlines.retain_mut(|inline| match inline {
159
+ Inline::Image { src } => match resolve(page, src) {
160
+ Some(path) => {
161
+ *src = path;
162
+ true
163
+ }
164
+ None => false,
165
+ },
166
+ Inline::Bold { body } | Inline::Italic { body } | Inline::Ui { body } => {
167
+ self::inlines(page, body, name_of);
168
+ true
169
+ }
170
+ Inline::Link { text, target } => {
171
+ if let LinkTarget::Wiki { path, .. } = target {
172
+ // `[intro]` names a page and shows the address; SideFX shows
173
+ // the page's title there instead.
174
+ let bare = matches!(text.as_slice(), [Inline::Text { text }] if text == path);
175
+ if let Some(resolved) = link(page, path) {
176
+ *path = resolved;
177
+ }
178
+ if bare && let Some(found) = name_of(path) {
179
+ *text = vec![Inline::Text { text: found }];
180
+ }
181
+ }
182
+ self::inlines(page, text, name_of);
183
+ true
184
+ }
185
+ _ => true,
186
+ });
187
+ }
188
+
189
+ #[cfg(test)]
190
+ mod tests {
191
+ use super::{link, resolve};
192
+
193
+ #[test]
194
+ fn a_relative_link_stands_beside_its_page() {
195
+ assert_eq!(
196
+ link("news/22/index", "solaris").as_deref(),
197
+ Some("/news/22/solaris")
198
+ );
199
+ assert_eq!(
200
+ link("news/22/karma", "solaris").as_deref(),
201
+ Some("/news/22/solaris")
202
+ );
203
+ assert_eq!(
204
+ link("nodes/sop/box", "../../vex/functions/lerp").as_deref(),
205
+ Some("/vex/functions/lerp")
206
+ );
207
+ }
208
+
209
+ #[test]
210
+ fn an_absolute_link_is_left_alone() {
211
+ assert_eq!(link("news/22/index", "/nodes/sop/box"), None);
212
+ assert_eq!(link("news/22/index", ""), None);
213
+ }
214
+
215
+ #[test]
216
+ fn a_link_that_leaves_the_help_root_is_refused() {
217
+ assert_eq!(link("news/22/index", "../../../elsewhere"), None);
218
+ assert_eq!(link("news/index", "solaris").as_deref(), Some("/news/solaris"));
219
+ }
220
+
221
+ #[test]
222
+ fn absolute_image_drops_the_serving_folder() {
223
+ assert_eq!(
224
+ resolve("basics/playbar", "/images/playbar/timeline.png").as_deref(),
225
+ Some("images/playbar/timeline.png")
226
+ );
227
+ }
228
+
229
+ #[test]
230
+ fn a_relative_image_keeps_the_section_it_came_from() {
231
+ assert_eq!(
232
+ resolve("nodes/sop/basis", "../images/BasisSOP.jpg").as_deref(),
233
+ Some("images/nodes/BasisSOP.jpg")
234
+ );
235
+ assert_eq!(
236
+ resolve("nodes/cop2/rotoshape", "../images/RotoShapeEditMode.jpg").as_deref(),
237
+ Some("images/nodes/RotoShapeEditMode.jpg")
238
+ );
239
+ }
240
+
241
+ #[test]
242
+ fn an_empty_segment_is_not_a_folder() {
243
+ assert_eq!(
244
+ resolve(
245
+ "nodes/lop/rendergeometrysettings",
246
+ "//images/solaris/kug/a.jpg"
247
+ )
248
+ .as_deref(),
249
+ Some("images/solaris/kug/a.jpg")
250
+ );
251
+ }
252
+
253
+ #[test]
254
+ fn a_video_reads_from_the_videos_folder() {
255
+ assert_eq!(
256
+ resolve("anim/animtoolbar", "/videos/animtoolbar_tween.webm").as_deref(),
257
+ Some("videos/animtoolbar_tween.webm")
258
+ );
259
+ }
260
+
261
+ #[test]
262
+ fn a_picture_inside_an_asset_is_not_a_file() {
263
+ assert_eq!(resolve("help/nodes", "opdef:.?test.png"), None);
264
+ assert_eq!(
265
+ resolve("help/nodes", "opdef:matt::Sop/e::1.0?test.png"),
266
+ None
267
+ );
268
+ assert_eq!(resolve("help/nodes", "./MainImage.jpg"), None);
269
+ }
270
+
271
+ #[test]
272
+ fn a_reference_that_leaves_the_help_root_is_refused() {
273
+ assert_eq!(resolve("basics/playbar", "../../../images/x.png"), None);
274
+ assert_eq!(resolve("basics/playbar", "/images"), None);
275
+ }
276
+ }
@@ -0,0 +1,137 @@
1
+ //! `index.db`: what the engine derives from a Houdini install.
2
+ //!
3
+ //! Everything here is derived. Delete the file and nothing of the reader's is
4
+ //! lost — the background pass fills it again in seconds. The reader's own work
5
+ //! lives in `user.db`, which the app owns and this module never touches.
6
+ //!
7
+ //! See spec: Local — SQLite FTS5 Index.
8
+
9
+ use std::path::{Path, PathBuf};
10
+
11
+ use rusqlite::Connection;
12
+
13
+ /// `index.db` sits in the folder the caller names.
14
+ pub fn path(data: &Path) -> PathBuf {
15
+ data.join("index.db")
16
+ }
17
+
18
+ /// Opens `index.db` and makes its schema.
19
+ pub fn open(data: &Path) -> Result<Connection, String> {
20
+ std::fs::create_dir_all(data).map_err(|e| format!("{}: {e}", data.display()))?;
21
+ let index = path(data);
22
+ let db = Connection::open(&index).map_err(|e| format!("{}: {e}", index.display()))?;
23
+
24
+ // WAL lets the background indexer write while the reader reads.
25
+ db.pragma_update(None, "journal_mode", "WAL")
26
+ .map_err(|e| e.to_string())?;
27
+ db.pragma_update(None, "synchronous", "NORMAL")
28
+ .map_err(|e| e.to_string())?;
29
+
30
+ reset_if_stale(&db)?;
31
+ db.execute_batch(SCHEMA).map_err(|e| e.to_string())?;
32
+ Ok(db)
33
+ }
34
+
35
+ /// What `SCHEMA` describes. Raise it whenever the derived tables change shape,
36
+ /// or the parser writes different rows into them.
37
+ const VERSION: u32 = 3;
38
+
39
+ /// Throws away everything derived from the Houdini install when the shape it
40
+ /// was written in is not the shape this build reads. `index.db` is derived, so
41
+ /// there is nothing here to migrate — the background pass fills it again in
42
+ /// seconds.
43
+ fn reset_if_stale(db: &Connection) -> Result<(), String> {
44
+ let found: u32 = db
45
+ .query_row("PRAGMA user_version", [], |row| row.get(0))
46
+ .map_err(|e| e.to_string())?;
47
+ if found != VERSION {
48
+ db.execute_batch(
49
+ "DROP TABLE IF EXISTS pages;
50
+ DROP TABLE IF EXISTS pages_fts;
51
+ DROP TABLE IF EXISTS builds;",
52
+ )
53
+ .map_err(|e| e.to_string())?;
54
+ db.pragma_update(None, "user_version", VERSION)
55
+ .map_err(|e| e.to_string())?;
56
+ }
57
+ Ok(())
58
+ }
59
+
60
+ /// One build is one set of rows, never one file and never one folder. A
61
+ /// Houdini upgrade rewrites its own rows and leaves the others alone.
62
+ const SCHEMA: &str = r#"
63
+ CREATE TABLE IF NOT EXISTS builds (
64
+ build TEXT PRIMARY KEY,
65
+ pages INTEGER NOT NULL DEFAULT 0,
66
+ -- 0 while the background pass is still filling this build in.
67
+ done INTEGER NOT NULL DEFAULT 0
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS pages (
71
+ build TEXT NOT NULL,
72
+ path TEXT NOT NULL,
73
+ title TEXT NOT NULL,
74
+ node_type TEXT,
75
+ icon TEXT,
76
+ summary TEXT,
77
+ PRIMARY KEY (build, path)
78
+ ) WITHOUT ROWID;
79
+
80
+ -- One row per SECTION of a page, not per page: a hit names the heading the
81
+ -- reader should land on, which is what the result list draws under the page.
82
+ -- Cutting the body at its headings stores it once, not twice — see
83
+ -- `sections.rs`.
84
+ --
85
+ -- `heading` is empty and `title` is set on the row for the text above the first
86
+ -- heading, so a page is named exactly once and the title weight cannot multiply
87
+ -- with the number of sections it has.
88
+ CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
89
+ build UNINDEXED,
90
+ path UNINDEXED,
91
+ slug UNINDEXED,
92
+ heading,
93
+ title,
94
+ body,
95
+ tokenize = "unicode61 remove_diacritics 2"
96
+ );
97
+ "#;
98
+
99
+ /// Turns what the reader typed into an FTS5 query.
100
+ ///
101
+ /// Every token is quoted, so a bare `*`, `-` or `NEAR` is text and not syntax.
102
+ /// The last token takes a prefix star, because the reader is still typing it.
103
+ pub fn match_query(text: &str) -> Option<String> {
104
+ let tokens: Vec<String> = text
105
+ .split(|c: char| !c.is_alphanumeric() && c != '_')
106
+ .filter(|t| !t.is_empty())
107
+ .map(|t| format!("\"{t}\""))
108
+ .collect();
109
+ let (last, rest) = tokens.split_last()?;
110
+ let mut query = rest.join(" ");
111
+ if !query.is_empty() {
112
+ query.push(' ');
113
+ }
114
+ query.push_str(last);
115
+ query.push('*');
116
+ Some(query)
117
+ }
118
+
119
+ #[cfg(test)]
120
+ mod tests {
121
+ use super::match_query;
122
+
123
+ #[test]
124
+ fn a_query_quotes_every_token_and_extends_the_last() {
125
+ assert_eq!(match_query("copy to points").unwrap(), "\"copy\" \"to\" \"points\"*");
126
+ }
127
+
128
+ #[test]
129
+ fn syntax_the_reader_types_stays_text() {
130
+ assert_eq!(match_query("a OR b*").unwrap(), "\"a\" \"OR\" \"b\"*");
131
+ }
132
+
133
+ #[test]
134
+ fn nothing_to_match_is_no_query() {
135
+ assert!(match_query(" ").is_none());
136
+ }
137
+ }
@@ -0,0 +1,66 @@
1
+ //! The example files that ship for a node, as a section at the foot of its
2
+ //! page.
3
+ //!
4
+ //! An example is a scene file plus a page beside it, under
5
+ //! `help/examples/<node path>/`. The node page itself never names them —
6
+ //! SideFX's doc build finds them by their place on the disk and writes the
7
+ //! Examples section from what it finds. See spec: Local — Whole sections are
8
+ //! dropped from pages.
9
+ //!
10
+ //! Not in the `wiki` crate: the crate reads no file, and this reads a
11
+ //! directory. Same reason as `family.rs`. See `agents/architecture.md`.
12
+
13
+ use std::path::Path;
14
+
15
+ use wiki::model::ListItem;
16
+ use wiki::{Block, Inline, LinkTarget};
17
+
18
+ /// Appends an Examples section when the install ships examples for this page.
19
+ ///
20
+ /// `path` is the page's own help path, `nodes/sop/file`. The examples for it
21
+ /// sit under `examples/` at the same path.
22
+ pub fn append(help: &Path, path: &str, blocks: &mut Vec<Block>) {
23
+ if path.starts_with("examples/") {
24
+ return;
25
+ }
26
+ let mut dir = help.join("examples");
27
+ for part in path.split('/') {
28
+ dir.push(part);
29
+ }
30
+ let Ok(entries) = std::fs::read_dir(&dir) else {
31
+ return;
32
+ };
33
+ let mut names: Vec<String> = entries
34
+ .flatten()
35
+ .filter_map(|entry| {
36
+ let file = entry.file_name().into_string().ok()?;
37
+ file.strip_suffix(".txt").map(str::to_string)
38
+ })
39
+ .collect();
40
+ if names.is_empty() {
41
+ return;
42
+ }
43
+ names.sort_unstable();
44
+
45
+ let items = names
46
+ .into_iter()
47
+ .map(|name| ListItem {
48
+ blocks: vec![Block::Paragraph {
49
+ text: vec![Inline::Link {
50
+ text: vec![Inline::Text { text: name.clone() }],
51
+ target: LinkTarget::Wiki {
52
+ path: format!("/examples/{path}/{name}"),
53
+ anchor: None,
54
+ },
55
+ }],
56
+ }],
57
+ props: Vec::new(),
58
+ })
59
+ .collect();
60
+ blocks.push(Block::Section {
61
+ name: "examples".to_string(),
62
+ title: None,
63
+ props: Vec::new(),
64
+ children: vec![Block::Bullets { items }],
65
+ });
66
+ }
@@ -0,0 +1,118 @@
1
+ //! The sibling list a VEX function or statement page carries at its foot:
2
+ //! every other page that shares one of its `#tags`, one heading per tag.
3
+ //!
4
+ //! SideFX's own doc build writes this from a tag index that sits outside the
5
+ //! page source, so the raw wiki markup never carries it — the app rebuilds it
6
+ //! from its own reading of `vex.zip` instead. See spec: Local — VEX pages lose
7
+ //! the family index.
8
+ //!
9
+ //! Not in the `wiki` crate: the crate reads no file, and this reads every VEX
10
+ //! page once to answer one page. See `agents/architecture.md`.
11
+
12
+ use std::collections::HashMap;
13
+ use std::path::{Path, PathBuf};
14
+ use std::sync::{Arc, LazyLock, Mutex};
15
+
16
+ use wiki::model::ListItem;
17
+ use wiki::{Block, Inline, LinkTarget};
18
+
19
+ struct Entry {
20
+ name: String,
21
+ tags: Vec<String>,
22
+ }
23
+
24
+ /// `vex.zip` read once per install and reused for every function page after
25
+ /// the first. About 1,100 small pages; parsing them again for every page open
26
+ /// would cost more than the page itself.
27
+ static CACHE: LazyLock<Mutex<HashMap<PathBuf, Arc<Vec<Entry>>>>> =
28
+ LazyLock::new(|| Mutex::new(HashMap::new()));
29
+
30
+ fn entries(help: &Path) -> Arc<Vec<Entry>> {
31
+ let zip = help.join("vex.zip");
32
+ let mut cache = CACHE.lock().expect("the family cache is not poisoned");
33
+ if let Some(found) = cache.get(&zip) {
34
+ return found.clone();
35
+ }
36
+ let built = Arc::new(read(&zip));
37
+ cache.insert(zip, built.clone());
38
+ built
39
+ }
40
+
41
+ fn read(zip: &Path) -> Vec<Entry> {
42
+ let names = crate::help::entries(zip, "functions/")
43
+ .into_iter()
44
+ .chain(crate::help::entries(zip, "statements/"));
45
+ let mut out = Vec::new();
46
+ for name in names {
47
+ let stem = name
48
+ .rsplit('/')
49
+ .next()
50
+ .unwrap_or(&name)
51
+ .trim_end_matches(".txt");
52
+ // A page written to be included by another, not a function of its own.
53
+ if stem.starts_with('_') {
54
+ continue;
55
+ }
56
+ let Some(source) = crate::help::text(zip, &name) else {
57
+ continue;
58
+ };
59
+ let (props, _) = wiki::blocks::parse_source(&source);
60
+ if !matches!(wiki::model::prop(&props, "type"), Some("vex" | "vexstatement")) {
61
+ continue;
62
+ }
63
+ let Some(tags) = wiki::model::prop(&props, "tags") else {
64
+ continue;
65
+ };
66
+ let tags = tags
67
+ .split(',')
68
+ .map(str::trim)
69
+ .filter(|t| !t.is_empty())
70
+ .map(str::to_string)
71
+ .collect();
72
+ out.push(Entry { name: stem.to_string(), tags });
73
+ }
74
+ out
75
+ }
76
+
77
+ /// Appends one `Block::Section` per tag the page carries, each an
78
+ /// alphabetical list of every VEX function or statement with that tag
79
+ /// (this page included), in the order the page's own `#tags` lists them.
80
+ pub fn append(help: &Path, props: &wiki::Props, blocks: &mut Vec<Block>) {
81
+ if !matches!(wiki::model::prop(props, "type"), Some("vex" | "vexstatement")) {
82
+ return;
83
+ }
84
+ let Some(own_tags) = wiki::model::prop(props, "tags") else {
85
+ return;
86
+ };
87
+ let all = entries(help);
88
+ for tag in own_tags.split(',').map(str::trim).filter(|t| !t.is_empty()) {
89
+ let mut names: Vec<&str> = all
90
+ .iter()
91
+ .filter(|entry| entry.tags.iter().any(|t| t == tag))
92
+ .map(|entry| entry.name.as_str())
93
+ .collect();
94
+ names.sort_unstable();
95
+ names.dedup();
96
+ if names.is_empty() {
97
+ continue;
98
+ }
99
+ let items = names
100
+ .into_iter()
101
+ .map(|name| ListItem {
102
+ blocks: vec![Block::Paragraph {
103
+ text: vec![Inline::Link {
104
+ text: vec![Inline::Text { text: name.to_string() }],
105
+ target: LinkTarget::Vex { name: name.to_string() },
106
+ }],
107
+ }],
108
+ props: Vec::new(),
109
+ })
110
+ .collect();
111
+ blocks.push(Block::Section {
112
+ name: tag.to_string(),
113
+ title: None,
114
+ props: Vec::new(),
115
+ children: vec![Block::Bullets { items }],
116
+ });
117
+ }
118
+ }