Skip to main content

harn_parser/
stdlib_metadata.rs

1//! Structured metadata declared in stdlib HarnDoc blocks.
2//!
3//! Every public stdlib function is expected to carry five fields above its
4//! `pub fn` declaration:
5//!
6//! ```text
7//! /**
8//!  * Returns the contents of `path`.
9//!  *
10//!  * @effects: [fs.read]
11//!  * @allocation: heap
12//!  * @errors: [FileNotFound, PermissionDenied]
13//!  * @api_stability: stable
14//!  * @example: let s = fs::read_to_string(harness.fs, "/x")
15//!  */
16//! pub fn read_to_string(...) -> ... { ... }
17//! ```
18//!
19//! These fields drive `harn graph --json`, LSP hover, and the
20//! `HARN-STD-101` lint that enforces coverage on stdlib sources.
21
22use harn_lexer::Span;
23
24/// One declared metadata field on a stdlib function. Empty lists and
25/// missing fields are distinct: `effects: Some(vec![])` records an
26/// explicit `[]` declaration ("statically certified pure"), while
27/// `effects: None` means the author has not annotated the function yet.
28///
29/// `serde::Serialize` is derived so the same struct can ride through
30/// `harn graph --json` and other JSON wire formats without a parallel
31/// type definition.
32#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
33#[serde(rename_all = "snake_case")]
34pub struct StdlibMetadata {
35    /// Declared effect classes (e.g. `fs.read`, `stdio.write`,
36    /// `llm.call`). Comparable to dependency types in `harn graph --json`.
37    pub effects: Option<Vec<String>>,
38    /// Allocation behavior. The free-form string is intentionally not
39    /// constrained here so authors can use whichever vocabulary fits the
40    /// function (e.g. `stack-only`, `heap`, `caller-owned`).
41    pub allocation: Option<String>,
42    /// Declared error variants the function may return or raise.
43    pub errors: Option<Vec<String>>,
44    /// API stability promise (e.g. `stable`, `experimental`, `deprecated`).
45    pub api_stability: Option<String>,
46    /// Verbatim usage example. Can span multiple lines.
47    pub example: Option<String>,
48}
49
50impl StdlibMetadata {
51    /// True when every required field has been populated.
52    pub fn is_complete(&self) -> bool {
53        self.effects.is_some()
54            && self.allocation.is_some()
55            && self.errors.is_some()
56            && self.api_stability.is_some()
57            && self.example.is_some()
58    }
59
60    /// True when *no* field has been declared. Used by lints and `harn
61    /// graph --json` to distinguish "absent" from "partial".
62    pub fn is_empty(&self) -> bool {
63        self.effects.is_none()
64            && self.allocation.is_none()
65            && self.errors.is_none()
66            && self.api_stability.is_none()
67            && self.example.is_none()
68    }
69
70    /// Names of every metadata field that has not been declared.
71    pub fn missing_fields(&self) -> Vec<&'static str> {
72        let mut out: Vec<&'static str> = Vec::new();
73        if self.effects.is_none() {
74            out.push("effects");
75        }
76        if self.allocation.is_none() {
77            out.push("allocation");
78        }
79        if self.errors.is_none() {
80            out.push("errors");
81        }
82        if self.api_stability.is_none() {
83            out.push("api_stability");
84        }
85        if self.example.is_none() {
86            out.push("example");
87        }
88        out
89    }
90
91    /// Render the metadata as a markdown block for LSP hover and docs.
92    /// Only declared fields are emitted; an unannotated function returns
93    /// an empty string.
94    pub fn to_markdown(&self) -> String {
95        if self.is_empty() {
96            return String::new();
97        }
98        let mut lines: Vec<String> = Vec::new();
99        if let Some(effects) = &self.effects {
100            lines.push(format!(
101                "- **effects:** {}",
102                if effects.is_empty() {
103                    "_none_".to_string()
104                } else {
105                    effects
106                        .iter()
107                        .map(|e| format!("`{e}`"))
108                        .collect::<Vec<_>>()
109                        .join(", ")
110                }
111            ));
112        }
113        if let Some(allocation) = &self.allocation {
114            lines.push(format!("- **allocation:** `{allocation}`"));
115        }
116        if let Some(errors) = &self.errors {
117            lines.push(format!(
118                "- **errors:** {}",
119                if errors.is_empty() {
120                    "_none_".to_string()
121                } else {
122                    errors
123                        .iter()
124                        .map(|e| format!("`{e}`"))
125                        .collect::<Vec<_>>()
126                        .join(", ")
127                }
128            ));
129        }
130        if let Some(stability) = &self.api_stability {
131            lines.push(format!("- **api_stability:** `{stability}`"));
132        }
133        if let Some(example) = &self.example {
134            lines.push(format!("- **example:**\n\n```harn\n{example}\n```"));
135        }
136        format!("**Stdlib metadata**\n\n{}", lines.join("\n"))
137    }
138}
139
140/// Parse all `@key: value` fields from the body of a canonical
141/// `/** ... */` HarnDoc block. The body should be the inner text with
142/// `/**`, leading `*`, and `*/` markers already stripped, one line per
143/// element. Multi-line `@example:` continuations are joined while
144/// preserving trailing newlines.
145pub fn parse_from_doc_body(body: &str) -> StdlibMetadata {
146    parse_from_doc_lines(&body.lines().collect::<Vec<_>>())
147}
148
149fn parse_from_doc_lines(lines: &[&str]) -> StdlibMetadata {
150    let mut meta = StdlibMetadata::default();
151    let mut current_key: Option<&'static str> = None;
152    let mut current_value: String = String::new();
153
154    let flush = |key: Option<&'static str>, value: String, meta: &mut StdlibMetadata| {
155        let Some(key) = key else { return };
156        let trimmed = value.trim_end_matches('\n').to_string();
157        assign_field(meta, key, &trimmed);
158    };
159
160    for raw in lines {
161        let line = raw.trim();
162        if let Some((key, rest)) = parse_key_line(line) {
163            // Flush the previous field before starting a new one.
164            flush(current_key, std::mem::take(&mut current_value), &mut meta);
165            current_key = Some(key);
166            current_value.clear();
167            current_value.push_str(rest.trim());
168        } else if current_key.is_some() {
169            // Lines that are part of an `@example:` continuation keep
170            // their leading indentation relative to the doc block. Blank
171            // lines terminate the current value.
172            if line.is_empty() {
173                flush(current_key, std::mem::take(&mut current_value), &mut meta);
174                current_key = None;
175            } else if current_key == Some("example") {
176                current_value.push('\n');
177                current_value.push_str(line);
178            }
179        }
180    }
181    flush(current_key, current_value, &mut meta);
182    meta
183}
184
185fn parse_key_line(line: &str) -> Option<(&'static str, &str)> {
186    let rest = line.strip_prefix('@')?;
187    let colon = rest.find(':')?;
188    let (key, after) = rest.split_at(colon);
189    let key = match key.trim() {
190        "effects" => "effects",
191        "allocation" => "allocation",
192        "errors" => "errors",
193        "api_stability" => "api_stability",
194        "example" => "example",
195        _ => return None,
196    };
197    Some((key, &after[1..]))
198}
199
200fn assign_field(meta: &mut StdlibMetadata, key: &str, value: &str) {
201    match key {
202        "effects" => meta.effects = Some(parse_list(value)),
203        "errors" => meta.errors = Some(parse_list(value)),
204        "allocation" => meta.allocation = Some(value.trim().to_string()),
205        "api_stability" => meta.api_stability = Some(value.trim().to_string()),
206        "example" => meta.example = Some(value.trim().to_string()),
207        _ => {}
208    }
209}
210
211fn parse_list(raw: &str) -> Vec<String> {
212    let trimmed = raw.trim();
213    let stripped = trimmed
214        .strip_prefix('[')
215        .and_then(|s| s.strip_suffix(']'))
216        .unwrap_or(trimmed);
217    stripped
218        .split(',')
219        .map(|part| part.trim().to_string())
220        .filter(|part| !part.is_empty())
221        .collect()
222}
223
224/// Extract a canonical `/** ... */` block immediately above the given
225/// span and parse its metadata fields. Returns the parsed metadata even
226/// if no fields are declared so callers can detect "doc present, fields
227/// missing".
228pub fn parse_for_span(source: &str, span: &Span) -> Option<StdlibMetadata> {
229    let body = extract_doc_body(source, span)?;
230    Some(parse_from_doc_body(&body))
231}
232
233fn extract_doc_body(source: &str, span: &Span) -> Option<String> {
234    let lines: Vec<&str> = source.lines().collect();
235    let def_line_idx = span.line.checked_sub(1)?;
236    if def_line_idx == 0 {
237        return None;
238    }
239    let above_idx = def_line_idx - 1;
240    let above = lines.get(above_idx)?.trim_end();
241    if !above.trim_end().ends_with("*/") {
242        return None;
243    }
244
245    // Single-line `/** ... */` form.
246    let above_trim = above.trim_start();
247    if above_trim.starts_with("/**") && above_trim.ends_with("*/") && above_trim.len() >= 5 {
248        let inner = &above_trim[3..above_trim.len() - 2];
249        return Some(inner.trim().to_string());
250    }
251
252    // Multi-line block — walk upward to the matching `/**`.
253    let mut start_idx = above_idx;
254    loop {
255        let line = lines.get(start_idx)?.trim_start();
256        if line.starts_with("/**") {
257            break;
258        }
259        if start_idx == 0 {
260            return None;
261        }
262        start_idx -= 1;
263    }
264    let mut body = String::new();
265    for (i, line) in lines.iter().enumerate().take(above_idx + 1).skip(start_idx) {
266        let trimmed = line.trim();
267        let stripped = if i == start_idx {
268            trimmed.strip_prefix("/**").unwrap_or(trimmed).trim_start()
269        } else if i == above_idx {
270            let without_tail = trimmed.strip_suffix("*/").unwrap_or(trimmed).trim_end();
271            without_tail
272                .strip_prefix('*')
273                .map(|s| s.strip_prefix(' ').unwrap_or(s))
274                .unwrap_or(without_tail)
275        } else {
276            trimmed
277                .strip_prefix('*')
278                .map(|s| s.strip_prefix(' ').unwrap_or(s))
279                .unwrap_or(trimmed)
280        };
281        if !body.is_empty() {
282            body.push('\n');
283        }
284        body.push_str(stripped);
285    }
286    Some(body)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn parses_all_five_fields_inline() {
295        let body = "Reads a file.\n\n@effects: [fs.read]\n@allocation: heap\n@errors: [FileNotFound, PermissionDenied]\n@api_stability: stable\n@example: let s = fs::read_to_string(harness.fs, \"/x\")";
296        let meta = parse_from_doc_body(body);
297        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
298        assert_eq!(meta.effects.as_deref(), Some(&["fs.read".to_string()][..]));
299        assert_eq!(meta.allocation.as_deref(), Some("heap"));
300        assert_eq!(
301            meta.errors.as_deref(),
302            Some(&["FileNotFound".to_string(), "PermissionDenied".to_string()][..]),
303        );
304        assert_eq!(meta.api_stability.as_deref(), Some("stable"));
305        assert_eq!(
306            meta.example.as_deref(),
307            Some("let s = fs::read_to_string(harness.fs, \"/x\")"),
308        );
309    }
310
311    #[test]
312    fn partial_metadata_lists_missing_fields() {
313        let body = "@effects: []\n@api_stability: experimental";
314        let meta = parse_from_doc_body(body);
315        assert!(!meta.is_complete());
316        assert!(!meta.is_empty());
317        assert_eq!(
318            meta.missing_fields(),
319            vec!["allocation", "errors", "example"],
320        );
321    }
322
323    #[test]
324    fn empty_effect_and_error_lists_are_explicit() {
325        let body = "@effects: []\n@errors: []";
326        let meta = parse_from_doc_body(body);
327        assert_eq!(meta.effects.as_deref(), Some(&[][..]));
328        assert_eq!(meta.errors.as_deref(), Some(&[][..]));
329    }
330
331    #[test]
332    fn unknown_keys_do_not_pollute_storage() {
333        let body = "@deprecated: yes\n@allocation: stack-only";
334        let meta = parse_from_doc_body(body);
335        assert_eq!(meta.allocation.as_deref(), Some("stack-only"));
336        // No fictitious field — `deprecated` is not in the contract.
337        assert!(meta.effects.is_none());
338    }
339
340    #[test]
341    fn example_continuation_lines_are_joined() {
342        let body = "@example: let s = fs::open(p)\n  let b = fs::read(s)\n  fs::close(s)";
343        let meta = parse_from_doc_body(body);
344        assert_eq!(
345            meta.example.as_deref(),
346            Some("let s = fs::open(p)\nlet b = fs::read(s)\nfs::close(s)"),
347        );
348    }
349
350    #[test]
351    fn parse_for_span_extracts_multi_line_block() {
352        let source = "\
353/**
354 * Read the file.
355 *
356 * @effects: [fs.read]
357 * @allocation: heap
358 * @errors: [FileNotFound]
359 * @api_stability: stable
360 * @example: fs::read(\"/x\")
361 */
362pub fn read_file(path) {
363  __fs_read_to_string(path)
364}
365";
366        let span = Span::with_offsets(0, 0, 10, 1);
367        let meta = parse_for_span(source, &span).expect("metadata present");
368        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
369    }
370
371    #[test]
372    fn parse_for_span_handles_single_line_block() {
373        let source = "/** @effects: [] @allocation: stack-only @errors: [] @api_stability: stable @example: noop() */\npub fn noop() { }\n";
374        let span = Span::with_offsets(0, 0, 2, 1);
375        let meta = parse_for_span(source, &span).expect("metadata present");
376        // Single-line form only fits one tag — accept whichever last wins.
377        assert!(!meta.is_empty());
378    }
379
380    #[test]
381    fn markdown_omits_unset_fields() {
382        let meta = StdlibMetadata {
383            effects: Some(vec!["fs.read".to_string()]),
384            allocation: Some("heap".to_string()),
385            errors: None,
386            api_stability: Some("stable".to_string()),
387            example: None,
388        };
389        let md = meta.to_markdown();
390        assert!(md.contains("**effects:**"));
391        assert!(md.contains("**allocation:**"));
392        assert!(md.contains("**api_stability:**"));
393        assert!(!md.contains("**errors:**"));
394        assert!(!md.contains("**example:**"));
395    }
396}