Skip to main content

harn_parser/
stdlib_metadata.rs

1//! Structured metadata declared in stdlib HarnDoc blocks.
2//!
3//! Public stdlib functions carry a prose summary plus two required
4//! machine-meaningful fields above their `pub fn` declaration:
5//!
6//! ```text
7//! /**
8//!  * Returns the contents of `path`.
9//!  *
10//!  * @effects: [fs.read]
11//!  * @errors: [FileNotFound, PermissionDenied]
12//!  */
13//! pub fn read_to_string(...) -> ... { ... }
14//! ```
15//!
16//! Two more fields are optional:
17//!
18//! - `@api_stability:` — stability promise (`experimental`, `internal`,
19//!   ...). Absent means `stable`.
20//! - `@example:` — a hand-written usage example, only worth writing when
21//!   it shows something the signature cannot. When absent, tooling
22//!   synthesizes one from the type signature ([`synthesize_example`]).
23//!
24//! These fields drive `harn graph --json`, LSP hover, and the
25//! `HARN-STD-101` lint that enforces coverage on stdlib sources.
26
27use crate::ast::{TypeExpr, TypedParam};
28use harn_lexer::Span;
29
30/// One declared metadata field on a stdlib function. Empty lists and
31/// missing fields are distinct: `effects: Some(vec![])` records an
32/// explicit `[]` declaration ("statically certified pure"), while
33/// `effects: None` means the author has not annotated the function yet.
34///
35/// `serde::Serialize` is derived so the same struct can ride through
36/// `harn graph --json` and other JSON wire formats without a parallel
37/// type definition.
38#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
39#[serde(rename_all = "snake_case")]
40pub struct StdlibMetadata {
41    /// Declared effect classes (e.g. `fs.read`, `stdio.write`,
42    /// `llm.call`). Comparable to dependency types in `harn graph --json`.
43    pub effects: Option<Vec<String>>,
44    /// Declared error variants the function may return or raise.
45    pub errors: Option<Vec<String>>,
46    /// API stability promise (e.g. `experimental`, `internal`,
47    /// `deprecated`). Absent means `stable`.
48    pub api_stability: Option<String>,
49    /// Verbatim usage example. Can span multiple lines. Optional — when
50    /// absent, tooling derives one from the signature instead.
51    pub example: Option<String>,
52}
53
54impl StdlibMetadata {
55    /// True when every required field has been populated.
56    pub fn is_complete(&self) -> bool {
57        self.effects.is_some() && self.errors.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.errors.is_none()
65            && self.api_stability.is_none()
66            && self.example.is_none()
67    }
68
69    /// Names of every required metadata field that has not been declared.
70    pub fn missing_fields(&self) -> Vec<&'static str> {
71        let mut out: Vec<&'static str> = Vec::new();
72        if self.effects.is_none() {
73            out.push("effects");
74        }
75        if self.errors.is_none() {
76            out.push("errors");
77        }
78        out
79    }
80
81    /// Render the metadata as a markdown block for LSP hover and docs.
82    /// Only declared fields are emitted; an unannotated function returns
83    /// an empty string.
84    pub fn to_markdown(&self) -> String {
85        self.to_markdown_with_derived_example(None)
86    }
87
88    /// Like [`Self::to_markdown`], but falls back to a signature-derived
89    /// example (labeled as such) when the author has not written an
90    /// `@example`.
91    pub fn to_markdown_with_derived_example(&self, derived: Option<&str>) -> String {
92        if self.is_empty() && derived.is_none() {
93            return String::new();
94        }
95        let mut lines: Vec<String> = Vec::new();
96        if let Some(effects) = &self.effects {
97            lines.push(format!(
98                "- **effects:** {}",
99                if effects.is_empty() {
100                    "_none_".to_string()
101                } else {
102                    effects
103                        .iter()
104                        .map(|e| format!("`{e}`"))
105                        .collect::<Vec<_>>()
106                        .join(", ")
107                }
108            ));
109        }
110        if let Some(errors) = &self.errors {
111            lines.push(format!(
112                "- **errors:** {}",
113                if errors.is_empty() {
114                    "_none_".to_string()
115                } else {
116                    errors
117                        .iter()
118                        .map(|e| format!("`{e}`"))
119                        .collect::<Vec<_>>()
120                        .join(", ")
121                }
122            ));
123        }
124        if let Some(stability) = &self.api_stability {
125            lines.push(format!("- **api_stability:** `{stability}`"));
126        }
127        if let Some(example) = &self.example {
128            lines.push(format!("- **example:**\n\n```harn\n{example}\n```"));
129        } else if let Some(derived) = derived {
130            lines.push(format!(
131                "- **example** _(derived from signature)_**:**\n\n```harn\n{derived}\n```"
132            ));
133        }
134        format!("**Stdlib metadata**\n\n{}", lines.join("\n"))
135    }
136}
137
138/// Synthesize a usage example from a function signature, for symbols whose
139/// docs carry no hand-written `@example`. LSP hover and `harn graph
140/// --json` present the result labeled as derived.
141pub fn synthesize_example(
142    name: &str,
143    params: &[TypedParam],
144    return_type: Option<&TypeExpr>,
145) -> String {
146    let args = params
147        .iter()
148        .map(|p| {
149            if p.rest {
150                format!("...{}", p.name)
151            } else {
152                p.name.clone()
153            }
154        })
155        .collect::<Vec<_>>()
156        .join(", ");
157    let call = format!("{name}({args})");
158    match return_type {
159        // Only bind the result when the signature declares a non-nil
160        // return; an untyped fn may well be a statement-style helper.
161        Some(TypeExpr::Named(n)) if n == "nil" => call,
162        Some(_) => format!("let out = {call}"),
163        None => call,
164    }
165}
166
167/// Parse all `@key: value` fields from the body of a canonical
168/// `/** ... */` HarnDoc block. The body should be the inner text with
169/// `/**`, leading `*`, and `*/` markers already stripped, one line per
170/// element. Multi-line `@example:` continuations are joined while
171/// preserving trailing newlines.
172pub fn parse_from_doc_body(body: &str) -> StdlibMetadata {
173    parse_from_doc_lines(&body.lines().collect::<Vec<_>>())
174}
175
176fn parse_from_doc_lines(lines: &[&str]) -> StdlibMetadata {
177    let mut meta = StdlibMetadata::default();
178    let mut current_key: Option<&'static str> = None;
179    let mut current_value: String = String::new();
180
181    let flush = |key: Option<&'static str>, value: String, meta: &mut StdlibMetadata| {
182        let Some(key) = key else { return };
183        let trimmed = value.trim_end_matches('\n').to_string();
184        assign_field(meta, key, &trimmed);
185    };
186
187    for raw in lines {
188        let line = raw.trim();
189        if let Some((key, rest)) = parse_key_line(line) {
190            // Flush the previous field before starting a new one.
191            flush(current_key, std::mem::take(&mut current_value), &mut meta);
192            current_key = Some(key);
193            current_value.clear();
194            current_value.push_str(rest.trim());
195        } else if current_key.is_some() {
196            // Lines that are part of an `@example:` continuation keep
197            // their leading indentation relative to the doc block. Blank
198            // lines terminate the current value.
199            if line.is_empty() {
200                flush(current_key, std::mem::take(&mut current_value), &mut meta);
201                current_key = None;
202            } else if current_key == Some("example") {
203                current_value.push('\n');
204                current_value.push_str(line);
205            }
206        }
207    }
208    flush(current_key, current_value, &mut meta);
209    meta
210}
211
212fn parse_key_line(line: &str) -> Option<(&'static str, &str)> {
213    let rest = line.strip_prefix('@')?;
214    let colon = rest.find(':')?;
215    let (key, after) = rest.split_at(colon);
216    let key = match key.trim() {
217        "effects" => "effects",
218        "errors" => "errors",
219        "api_stability" => "api_stability",
220        "example" => "example",
221        _ => return None,
222    };
223    Some((key, &after[1..]))
224}
225
226fn assign_field(meta: &mut StdlibMetadata, key: &str, value: &str) {
227    match key {
228        "effects" => meta.effects = Some(parse_list(value)),
229        "errors" => meta.errors = Some(parse_list(value)),
230        "api_stability" => meta.api_stability = Some(value.trim().to_string()),
231        "example" => meta.example = Some(value.trim().to_string()),
232        _ => {}
233    }
234}
235
236fn parse_list(raw: &str) -> Vec<String> {
237    let trimmed = raw.trim();
238    let stripped = trimmed
239        .strip_prefix('[')
240        .and_then(|s| s.strip_suffix(']'))
241        .unwrap_or(trimmed);
242    stripped
243        .split(',')
244        .map(|part| part.trim().to_string())
245        .filter(|part| !part.is_empty())
246        .collect()
247}
248
249/// Extract a canonical `/** ... */` block immediately above the given
250/// span and parse its metadata fields. Returns the parsed metadata even
251/// if no fields are declared so callers can detect "doc present, fields
252/// missing".
253pub fn parse_for_span(source: &str, span: &Span) -> Option<StdlibMetadata> {
254    let body = extract_doc_body(source, span)?;
255    Some(parse_from_doc_body(&body))
256}
257
258fn extract_doc_body(source: &str, span: &Span) -> Option<String> {
259    let lines: Vec<&str> = source.lines().collect();
260    let def_line_idx = span.line.checked_sub(1)?;
261    if def_line_idx == 0 {
262        return None;
263    }
264    let above_idx = def_line_idx - 1;
265    let above = lines.get(above_idx)?.trim_end();
266    if !above.trim_end().ends_with("*/") {
267        return None;
268    }
269
270    // Single-line `/** ... */` form.
271    let above_trim = above.trim_start();
272    if above_trim.starts_with("/**") && above_trim.ends_with("*/") && above_trim.len() >= 5 {
273        let inner = &above_trim[3..above_trim.len() - 2];
274        return Some(inner.trim().to_string());
275    }
276
277    // Multi-line block — walk upward to the matching `/**`.
278    let mut start_idx = above_idx;
279    loop {
280        let line = lines.get(start_idx)?.trim_start();
281        if line.starts_with("/**") {
282            break;
283        }
284        if start_idx == 0 {
285            return None;
286        }
287        start_idx -= 1;
288    }
289    let mut body = String::new();
290    for (i, line) in lines.iter().enumerate().take(above_idx + 1).skip(start_idx) {
291        let trimmed = line.trim();
292        let stripped = if i == start_idx {
293            trimmed.strip_prefix("/**").unwrap_or(trimmed).trim_start()
294        } else if i == above_idx {
295            let without_tail = trimmed.strip_suffix("*/").unwrap_or(trimmed).trim_end();
296            without_tail
297                .strip_prefix('*')
298                .map(|s| s.strip_prefix(' ').unwrap_or(s))
299                .unwrap_or(without_tail)
300        } else {
301            trimmed
302                .strip_prefix('*')
303                .map(|s| s.strip_prefix(' ').unwrap_or(s))
304                .unwrap_or(trimmed)
305        };
306        if !body.is_empty() {
307            body.push('\n');
308        }
309        body.push_str(stripped);
310    }
311    Some(body)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn parses_all_fields_inline() {
320        let body = "Reads a file.\n\n@effects: [fs.read]\n@errors: [FileNotFound, PermissionDenied]\n@api_stability: experimental\n@example: let s = fs::read_to_string(harness.fs, \"/x\")";
321        let meta = parse_from_doc_body(body);
322        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
323        assert_eq!(meta.effects.as_deref(), Some(&["fs.read".to_string()][..]));
324        assert_eq!(
325            meta.errors.as_deref(),
326            Some(&["FileNotFound".to_string(), "PermissionDenied".to_string()][..]),
327        );
328        assert_eq!(meta.api_stability.as_deref(), Some("experimental"));
329        assert_eq!(
330            meta.example.as_deref(),
331            Some("let s = fs::read_to_string(harness.fs, \"/x\")"),
332        );
333    }
334
335    #[test]
336    fn required_set_is_effects_and_errors_only() {
337        let body = "@effects: []\n@errors: []";
338        let meta = parse_from_doc_body(body);
339        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
340        assert!(meta.api_stability.is_none());
341        assert!(meta.example.is_none());
342    }
343
344    #[test]
345    fn partial_metadata_lists_missing_fields() {
346        let body = "@api_stability: experimental";
347        let meta = parse_from_doc_body(body);
348        assert!(!meta.is_complete());
349        assert!(!meta.is_empty());
350        assert_eq!(meta.missing_fields(), vec!["effects", "errors"]);
351    }
352
353    #[test]
354    fn empty_effect_and_error_lists_are_explicit() {
355        let body = "@effects: []\n@errors: []";
356        let meta = parse_from_doc_body(body);
357        assert_eq!(meta.effects.as_deref(), Some(&[][..]));
358        assert_eq!(meta.errors.as_deref(), Some(&[][..]));
359    }
360
361    #[test]
362    fn unknown_keys_do_not_pollute_storage() {
363        // `deprecated` was never in the contract; `allocation` was removed
364        // from it. Both parse as ignored unknown keys.
365        let body = "@deprecated: yes\n@allocation: stack-only\n@errors: []";
366        let meta = parse_from_doc_body(body);
367        assert_eq!(meta.errors.as_deref(), Some(&[][..]));
368        assert!(meta.effects.is_none());
369    }
370
371    #[test]
372    fn example_continuation_lines_are_joined() {
373        let body = "@example: let s = fs::open(p)\n  let b = fs::read(s)\n  fs::close(s)";
374        let meta = parse_from_doc_body(body);
375        assert_eq!(
376            meta.example.as_deref(),
377            Some("let s = fs::open(p)\nlet b = fs::read(s)\nfs::close(s)"),
378        );
379    }
380
381    #[test]
382    fn parse_for_span_extracts_multi_line_block() {
383        let source = "\
384/**
385 * Read the file.
386 *
387 * @effects: [fs.read]
388 * @errors: [FileNotFound]
389 */
390pub fn read_file(path) {
391  __fs_read_to_string(path)
392}
393";
394        let span = Span::with_offsets(0, 0, 7, 1);
395        let meta = parse_for_span(source, &span).expect("metadata present");
396        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
397    }
398
399    #[test]
400    fn parse_for_span_handles_single_line_block() {
401        let source = "/** @effects: [] @errors: [] @example: noop() */\npub fn noop() { }\n";
402        let span = Span::with_offsets(0, 0, 2, 1);
403        let meta = parse_for_span(source, &span).expect("metadata present");
404        // Single-line form only fits one tag — accept whichever last wins.
405        assert!(!meta.is_empty());
406    }
407
408    #[test]
409    fn markdown_omits_unset_fields() {
410        let meta = StdlibMetadata {
411            effects: Some(vec!["fs.read".to_string()]),
412            errors: None,
413            api_stability: Some("experimental".to_string()),
414            example: None,
415        };
416        let md = meta.to_markdown();
417        assert!(md.contains("**effects:**"));
418        assert!(md.contains("**api_stability:**"));
419        assert!(!md.contains("**errors:**"));
420        assert!(!md.contains("**example"));
421    }
422
423    #[test]
424    fn markdown_prefers_authored_example_over_derived() {
425        let meta = StdlibMetadata {
426            effects: Some(vec![]),
427            errors: Some(vec![]),
428            api_stability: None,
429            example: Some("read_file(\"/etc/hosts\")".to_string()),
430        };
431        let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
432        assert!(md.contains("read_file(\"/etc/hosts\")"));
433        assert!(!md.contains("derived from signature"));
434    }
435
436    #[test]
437    fn markdown_falls_back_to_derived_example() {
438        let meta = StdlibMetadata {
439            effects: Some(vec![]),
440            errors: Some(vec![]),
441            api_stability: None,
442            example: None,
443        };
444        let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
445        assert!(md.contains("derived from signature"));
446        assert!(md.contains("let out = read_file(path)"));
447    }
448
449    #[test]
450    fn derived_example_renders_even_without_declared_fields() {
451        let meta = StdlibMetadata::default();
452        assert!(meta.to_markdown().is_empty());
453        let md = meta.to_markdown_with_derived_example(Some("greet(name)"));
454        assert!(md.contains("greet(name)"));
455    }
456
457    #[test]
458    fn synthesize_example_binds_non_nil_returns() {
459        use crate::ast::{TypeExpr, TypedParam};
460        let params = vec![TypedParam::untyped("path"), TypedParam::untyped("limit")];
461        let ret = TypeExpr::Named("string".to_string());
462        assert_eq!(
463            synthesize_example("read_file", &params, Some(&ret)),
464            "let out = read_file(path, limit)",
465        );
466    }
467
468    #[test]
469    fn synthesize_example_skips_binding_for_nil_and_untyped_returns() {
470        use crate::ast::{TypeExpr, TypedParam};
471        let params = vec![TypedParam::untyped("event")];
472        let nil = TypeExpr::Named("nil".to_string());
473        assert_eq!(
474            synthesize_example("notify", &params, Some(&nil)),
475            "notify(event)",
476        );
477        assert_eq!(synthesize_example("notify", &params, None), "notify(event)");
478    }
479
480    #[test]
481    fn synthesize_example_spreads_rest_params() {
482        use crate::ast::TypedParam;
483        let mut rest = TypedParam::untyped("parts");
484        rest.rest = true;
485        assert_eq!(synthesize_example("join", &[rest], None), "join(...parts)");
486    }
487}