Skip to main content

assay/
metadata.rs

1use serde::Serialize;
2
3/// LDoc-style metadata parsed from `--- @tag value` lines at the top of a Lua module.
4///
5/// Only assay constructs one; `#[non_exhaustive]` keeps a new `@tag` a patch release
6/// instead of forcing a minor on every consumer.
7#[derive(Debug, Clone, Default)]
8#[non_exhaustive]
9pub struct ModuleMetadata {
10    /// From `@module` tag
11    pub module_name: String,
12    /// From `@description` tag
13    pub description: String,
14    /// From `@keywords` tag, split by comma and trimmed
15    pub keywords: Vec<String>,
16    /// From `@env` tag, split by comma and trimmed
17    pub env_vars: Vec<String>,
18    /// From `@icon` tag — a simple-icons slug, absent when no brand fits
19    pub icon: Option<String>,
20    /// From `@category` tag
21    pub category: Option<String>,
22    /// From `@quickref` tags (one per tag line)
23    pub quickrefs: Vec<QuickRef>,
24    /// Auto-extracted function names from `function c:method(` and `function M.method(` patterns
25    pub auto_functions: Vec<String>,
26}
27
28/// A quick-reference entry parsed from `@quickref signature -> return_hint | description`.
29#[derive(Debug, Clone, Default, Serialize)]
30#[non_exhaustive]
31pub struct QuickRef {
32    /// e.g. `c:health()`
33    pub signature: String,
34    /// e.g. `{database, version, commit}`
35    pub return_hint: String,
36    /// e.g. `Check Grafana health`
37    pub description: String,
38}
39
40/// Parse LDoc-style metadata from a Lua source string.
41///
42/// 1. Parses `--- @tag value` lines at the TOP of the file (stops at first non-`---` line).
43/// 2. Auto-extracts function names from `function c:method_name(` and `function M.method_name(`
44///    patterns across the entire file.
45///
46/// Never panics — returns a valid [`ModuleMetadata`] even on empty or malformed input.
47pub fn parse_metadata(source: &str) -> ModuleMetadata {
48    let mut meta = ModuleMetadata::default();
49
50    parse_header_tags(source, &mut meta);
51    extract_auto_functions(source, &mut meta);
52
53    meta
54}
55
56/// Parse `--- @tag value` lines from the top of the file, stopping at the first non-`---` line.
57fn parse_header_tags(source: &str, meta: &mut ModuleMetadata) {
58    for line in source.lines() {
59        let trimmed = line.trim();
60
61        if !trimmed.starts_with("---") {
62            break;
63        }
64
65        // Strip the `--- ` prefix and look for `@tag`
66        let after_dashes = trimmed.trim_start_matches('-').trim();
67        if let Some(rest) = after_dashes.strip_prefix('@')
68            && let Some((tag, value)) = rest.split_once(char::is_whitespace)
69        {
70            let value = value.trim();
71            match tag {
72                "module" => meta.module_name = value.to_string(),
73                "description" => meta.description = value.to_string(),
74                "keywords" => {
75                    meta.keywords = split_comma_list(value);
76                }
77                "env" => {
78                    meta.env_vars = split_comma_list(value);
79                }
80                "icon" => meta.icon = Some(value.to_string()),
81                "category" => meta.category = Some(value.to_string()),
82                "quickref" => {
83                    if let Some(qr) = parse_quickref(value) {
84                        meta.quickrefs.push(qr);
85                    }
86                }
87                _ => {} // Unknown tags silently ignored
88            }
89        }
90    }
91}
92
93/// Split a comma-separated string into trimmed, non-empty items.
94fn split_comma_list(value: &str) -> Vec<String> {
95    value
96        .split(',')
97        .map(|s| s.trim().to_string())
98        .filter(|s| !s.is_empty())
99        .collect()
100}
101
102/// Parse a quickref value: `signature -> return_hint | description`
103fn parse_quickref(value: &str) -> Option<QuickRef> {
104    // Split on ` -> ` first to get signature and the rest
105    let (signature, rest) = value.split_once(" -> ")?;
106    // Split the rest on ` | ` to get return_hint and description
107    let (return_hint, description) = rest.split_once(" | ")?;
108
109    Some(QuickRef {
110        signature: signature.trim().to_string(),
111        return_hint: return_hint.trim().to_string(),
112        description: description.trim().to_string(),
113    })
114}
115
116/// Scan the entire source for `function c:method_name(` and `function M.method_name(` patterns,
117/// extracting the method/function name.
118fn extract_auto_functions(source: &str, meta: &mut ModuleMetadata) {
119    for line in source.lines() {
120        let trimmed = line.trim();
121
122        // Match `function <ident>:<name>(` or `function <ident>.<name>(`
123        if let Some(rest) = trimmed.strip_prefix("function ") {
124            // Find the separator (`:` or `.`) after the identifier
125            if let Some(name) = extract_function_name(rest)
126                && !name.is_empty()
127            {
128                meta.auto_functions.push(name);
129            }
130        }
131    }
132}
133
134/// Extract function name from patterns like `c:health()` or `M.client(url, opts)`.
135/// Returns the part after `:` or `.` and before `(`.
136fn extract_function_name(rest: &str) -> Option<String> {
137    // Find the separator position (first `:` or `.`)
138    let sep_pos = rest.find([':', '.'])?;
139    let after_sep = &rest[sep_pos + 1..];
140    // Take everything up to `(`
141    let name = after_sep.split('(').next()?;
142    let name = name.trim();
143    if name.is_empty() {
144        return None;
145    }
146    Some(name.to_string())
147}