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