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!("const 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 (key, after) = rest.split_once(':')?;
215    let key = match key.trim() {
216        "effects" => "effects",
217        "errors" => "errors",
218        "api_stability" => "api_stability",
219        "example" => "example",
220        _ => return None,
221    };
222    Some((key, after))
223}
224
225fn assign_field(meta: &mut StdlibMetadata, key: &str, value: &str) {
226    match key {
227        "effects" => meta.effects = Some(parse_list(value)),
228        "errors" => meta.errors = Some(parse_list(value)),
229        "api_stability" => meta.api_stability = Some(value.trim().to_string()),
230        "example" => meta.example = Some(value.trim().to_string()),
231        _ => {}
232    }
233}
234
235fn parse_list(raw: &str) -> Vec<String> {
236    let trimmed = raw.trim();
237    let stripped = trimmed
238        .strip_prefix('[')
239        .and_then(|s| s.strip_suffix(']'))
240        .unwrap_or(trimmed);
241    stripped
242        .split(',')
243        .map(|part| part.trim().to_string())
244        .filter(|part| !part.is_empty())
245        .collect()
246}
247
248/// Extract a canonical `/** ... */` block immediately above the given
249/// span and parse its metadata fields. Returns the parsed metadata even
250/// if no fields are declared so callers can detect "doc present, fields
251/// missing".
252pub fn parse_for_span(source: &str, span: &Span) -> Option<StdlibMetadata> {
253    let body = extract_doc_body(source, span)?;
254    Some(parse_from_doc_body(&body))
255}
256
257fn extract_doc_body(source: &str, span: &Span) -> Option<String> {
258    let lines: Vec<&str> = source.lines().collect();
259    let def_line_idx = span.line.checked_sub(1)?;
260    if def_line_idx == 0 {
261        return None;
262    }
263    let above_idx = def_line_idx - 1;
264    let above = lines.get(above_idx)?.trim_end();
265    if !above.trim_end().ends_with("*/") {
266        return None;
267    }
268
269    // Single-line `/** ... */` form.
270    let above_trim = above.trim_start();
271    if let Some(inner) = above_trim
272        .strip_prefix("/**")
273        .and_then(|s| s.strip_suffix("*/"))
274    {
275        return Some(inner.trim().to_string());
276    }
277
278    // Multi-line block — walk upward to the matching `/**`.
279    let mut start_idx = above_idx;
280    loop {
281        let line = lines.get(start_idx)?.trim_start();
282        if line.starts_with("/**") {
283            break;
284        }
285        if start_idx == 0 {
286            return None;
287        }
288        start_idx -= 1;
289    }
290    let mut body = String::new();
291    for (i, line) in lines.iter().enumerate().take(above_idx + 1).skip(start_idx) {
292        let trimmed = line.trim();
293        let stripped = if i == start_idx {
294            trimmed.strip_prefix("/**").unwrap_or(trimmed).trim_start()
295        } else if i == above_idx {
296            let without_tail = trimmed.strip_suffix("*/").unwrap_or(trimmed).trim_end();
297            without_tail
298                .strip_prefix('*')
299                .map(|s| s.strip_prefix(' ').unwrap_or(s))
300                .unwrap_or(without_tail)
301        } else {
302            trimmed
303                .strip_prefix('*')
304                .map(|s| s.strip_prefix(' ').unwrap_or(s))
305                .unwrap_or(trimmed)
306        };
307        if !body.is_empty() {
308            body.push('\n');
309        }
310        body.push_str(stripped);
311    }
312    Some(body)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn parses_all_fields_inline() {
321        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\")";
322        let meta = parse_from_doc_body(body);
323        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
324        assert_eq!(meta.effects.as_deref(), Some(&["fs.read".to_string()][..]));
325        assert_eq!(
326            meta.errors.as_deref(),
327            Some(&["FileNotFound".to_string(), "PermissionDenied".to_string()][..]),
328        );
329        assert_eq!(meta.api_stability.as_deref(), Some("experimental"));
330        assert_eq!(
331            meta.example.as_deref(),
332            Some("let s = fs::read_to_string(harness.fs, \"/x\")"),
333        );
334    }
335
336    #[test]
337    fn required_set_is_effects_and_errors_only() {
338        let body = "@effects: []\n@errors: []";
339        let meta = parse_from_doc_body(body);
340        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
341        assert!(meta.api_stability.is_none());
342        assert!(meta.example.is_none());
343    }
344
345    #[test]
346    fn partial_metadata_lists_missing_fields() {
347        let body = "@api_stability: experimental";
348        let meta = parse_from_doc_body(body);
349        assert!(!meta.is_complete());
350        assert!(!meta.is_empty());
351        assert_eq!(meta.missing_fields(), vec!["effects", "errors"]);
352    }
353
354    #[test]
355    fn empty_effect_and_error_lists_are_explicit() {
356        let body = "@effects: []\n@errors: []";
357        let meta = parse_from_doc_body(body);
358        assert_eq!(meta.effects.as_deref(), Some(&[][..]));
359        assert_eq!(meta.errors.as_deref(), Some(&[][..]));
360    }
361
362    #[test]
363    fn unknown_keys_do_not_pollute_storage() {
364        // `deprecated` was never in the contract; `allocation` was removed
365        // from it. Both parse as ignored unknown keys.
366        let body = "@deprecated: yes\n@allocation: stack-only\n@errors: []";
367        let meta = parse_from_doc_body(body);
368        assert_eq!(meta.errors.as_deref(), Some(&[][..]));
369        assert!(meta.effects.is_none());
370    }
371
372    #[test]
373    fn example_continuation_lines_are_joined() {
374        let body = "@example: let s = fs::open(p)\n  let b = fs::read(s)\n  fs::close(s)";
375        let meta = parse_from_doc_body(body);
376        assert_eq!(
377            meta.example.as_deref(),
378            Some("let s = fs::open(p)\nlet b = fs::read(s)\nfs::close(s)"),
379        );
380    }
381
382    #[test]
383    fn parse_for_span_extracts_multi_line_block() {
384        let source = "\
385/**
386 * Read the file.
387 *
388 * @effects: [fs.read]
389 * @errors: [FileNotFound]
390 */
391pub fn read_file(path) {
392  __fs_read_to_string(path)
393}
394";
395        let span = Span::with_offsets(0, 0, 7, 1);
396        let meta = parse_for_span(source, &span).expect("metadata present");
397        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
398    }
399
400    #[test]
401    fn parse_for_span_handles_single_line_block() {
402        let source = "/** @effects: [] @errors: [] @example: noop() */\npub fn noop() { }\n";
403        let span = Span::with_offsets(0, 0, 2, 1);
404        let meta = parse_for_span(source, &span).expect("metadata present");
405        // Single-line form only fits one tag — accept whichever last wins.
406        assert!(!meta.is_empty());
407    }
408
409    #[test]
410    fn markdown_omits_unset_fields() {
411        let meta = StdlibMetadata {
412            effects: Some(vec!["fs.read".to_string()]),
413            errors: None,
414            api_stability: Some("experimental".to_string()),
415            example: None,
416        };
417        let md = meta.to_markdown();
418        assert!(md.contains("**effects:**"));
419        assert!(md.contains("**api_stability:**"));
420        assert!(!md.contains("**errors:**"));
421        assert!(!md.contains("**example"));
422    }
423
424    #[test]
425    fn markdown_prefers_authored_example_over_derived() {
426        let meta = StdlibMetadata {
427            effects: Some(vec![]),
428            errors: Some(vec![]),
429            api_stability: None,
430            example: Some("read_file(\"/etc/hosts\")".to_string()),
431        };
432        let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
433        assert!(md.contains("read_file(\"/etc/hosts\")"));
434        assert!(!md.contains("derived from signature"));
435    }
436
437    #[test]
438    fn markdown_falls_back_to_derived_example() {
439        let meta = StdlibMetadata {
440            effects: Some(vec![]),
441            errors: Some(vec![]),
442            api_stability: None,
443            example: None,
444        };
445        let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
446        assert!(md.contains("derived from signature"));
447        assert!(md.contains("let out = read_file(path)"));
448    }
449
450    #[test]
451    fn derived_example_renders_even_without_declared_fields() {
452        let meta = StdlibMetadata::default();
453        assert!(meta.to_markdown().is_empty());
454        let md = meta.to_markdown_with_derived_example(Some("greet(name)"));
455        assert!(md.contains("greet(name)"));
456    }
457
458    #[test]
459    fn synthesize_example_binds_non_nil_returns() {
460        use crate::ast::{TypeExpr, TypedParam};
461        let params = vec![TypedParam::untyped("path"), TypedParam::untyped("limit")];
462        let ret = TypeExpr::Named("string".to_string());
463        assert_eq!(
464            synthesize_example("read_file", &params, Some(&ret)),
465            "const out = read_file(path, limit)",
466        );
467    }
468
469    #[test]
470    fn synthesize_example_skips_binding_for_nil_and_untyped_returns() {
471        use crate::ast::{TypeExpr, TypedParam};
472        let params = vec![TypedParam::untyped("event")];
473        let nil = TypeExpr::Named("nil".to_string());
474        assert_eq!(
475            synthesize_example("notify", &params, Some(&nil)),
476            "notify(event)",
477        );
478        assert_eq!(synthesize_example("notify", &params, None), "notify(event)");
479    }
480
481    #[test]
482    fn synthesize_example_spreads_rest_params() {
483        use crate::ast::TypedParam;
484        let mut rest = TypedParam::untyped("parts");
485        rest.rest = true;
486        assert_eq!(synthesize_example("join", &[rest], None), "join(...parts)");
487    }
488}