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}