Skip to main content

linesmith_plugin/
header.rs

1//! Parses the optional `@data_deps = [...]` declaration from the first
2//! contiguous block of `//` line comments at the top of a plugin
3//! script. See `docs/specs/plugin-api.md` §@data_deps header syntax for
4//! the full contract.
5//!
6//! Resolved dep list is always a superset of `["status"]` — every
7//! plugin implicitly has access to the stdin payload — even if the
8//! author lists other deps explicitly or declares no header at all.
9//!
10//! Names are returned as raw lowercase tokens (`Vec<String>`); the
11//! consumer maps them to its own dep enum at registration time. Names
12//! reserved from plugins per spec (`credentials`, `jsonl`) and any
13//! token outside the plugin-accessible set surface as
14//! [`HeaderError::UnknownDep`] so the consumer doesn't have to repeat
15//! the validation.
16
17/// Plugin-accessible dep names per `docs/specs/plugin-api.md`. The
18/// header validator rejects any token outside this list; the
19/// consumer maps these strings back to its own enum at registration
20/// time. Exposed `pub` so consumer-side drift-detection tests can
21/// iterate the same catalog rather than hard-coding a parallel list
22/// (a third copy that could itself fall out of sync).
23pub const KNOWN_DEPS: &[&str] = &[
24    "status",
25    "settings",
26    "claude_json",
27    "usage",
28    "sessions",
29    "git",
30];
31
32/// Error surface for header parsing. The registry layer wraps these
33/// into [`PluginError`](super::error::PluginError) variants with
34/// the plugin id attached.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum HeaderError {
37    /// The `@data_deps = ...` RHS didn't parse as a JSON-style array
38    /// of bare-string dep names.
39    Malformed(String),
40    /// A listed dep name is not in the plugin-accessible set
41    /// defined by [`KNOWN_DEPS`]. `credentials` and `jsonl` are
42    /// reserved per spec and surface here alongside truly unknown
43    /// names.
44    UnknownDep(String),
45}
46
47/// Parse a plugin script's `@data_deps` header. Returns the resolved
48/// dep list as raw lowercase token strings (always including
49/// `"status"`), or [`HeaderError`] on malformed syntax / unknown /
50/// reserved dep name.
51///
52/// Accepts:
53/// - No header at all (defaults to `["status"]`)
54/// - Empty array (`@data_deps = []`) — same as no header
55/// - Single-line (`@data_deps = ["status", "usage"]`)
56/// - Multi-line across multiple `//` comment lines
57/// - Trailing commas
58/// - Single or double quotes around each name
59pub fn parse_data_deps_header(src: &str) -> Result<Vec<String>, HeaderError> {
60    let header_block = collect_header_block(src);
61    let Some(rhs) = find_data_deps_rhs(&header_block)? else {
62        return Ok(vec!["status".to_string()]);
63    };
64    let tokens = split_array_body(rhs)?;
65    let mut deps = vec!["status".to_string()];
66    for token in tokens {
67        if !KNOWN_DEPS.contains(&token.as_str()) {
68            return Err(HeaderError::UnknownDep(token));
69        }
70        if !deps.iter().any(|d| d == &token) {
71            deps.push(token);
72        }
73    }
74    Ok(deps)
75}
76
77/// Concatenate the first contiguous block of `//` comment lines,
78/// stripping the `//` prefix and optional single following space from
79/// each. A blank line or any non-`//` line ends the block (per spec).
80fn collect_header_block(src: &str) -> String {
81    let mut buf = String::new();
82    for line in src.lines() {
83        let trimmed = line.trim_start();
84        if trimmed.is_empty() {
85            break;
86        }
87        let Some(rest) = trimmed.strip_prefix("//") else {
88            break;
89        };
90        // Drop a single leading space after `//` for ergonomic
91        // multi-line indentation; anything else is kept verbatim.
92        let rest = rest.strip_prefix(' ').unwrap_or(rest);
93        buf.push_str(rest);
94        buf.push('\n');
95    }
96    buf
97}
98
99/// Locate `@data_deps = [ ... ]` in the header block and return the
100/// text starting right after the opening `[`. `Ok(None)` when no
101/// `@data_deps` declaration exists at all (a valid "no header" state
102/// resolved to the default `["status"]` by the caller). If the key is
103/// present but the `= [` shape is missing (e.g. `@data_deps ["x"]`
104/// or `@data_deps = "x"`), returns [`HeaderError::Malformed`] rather
105/// than silently degrading to the default — writing `@data_deps`
106/// signals intent, so a malformed RHS should surface as an error.
107/// Missing closing `]` is detected downstream by [`split_array_body`].
108fn find_data_deps_rhs(header: &str) -> Result<Option<&str>, HeaderError> {
109    let Some(start) = header.find("@data_deps") else {
110        return Ok(None);
111    };
112    let after_key = &header[start + "@data_deps".len()..];
113    let Some(eq_pos) = after_key.find('=') else {
114        return Err(HeaderError::Malformed(
115            "@data_deps declaration missing `=`".to_string(),
116        ));
117    };
118    let after_eq = after_key[eq_pos + 1..].trim_start();
119    let Some(open) = after_eq.strip_prefix('[') else {
120        return Err(HeaderError::Malformed(
121            "@data_deps RHS must be an array literal starting with `[`".to_string(),
122        ));
123    };
124    Ok(Some(open))
125}
126
127/// Split the body between `[` and `]` into trimmed, unquoted tokens.
128/// Whitespace, newlines, trailing commas, and inline `//` comments
129/// (per spec §@data_deps header syntax "comments inside the array")
130/// are tolerated. Missing closing `]` or unbalanced quoting surfaces
131/// as [`HeaderError::Malformed`].
132fn split_array_body(body: &str) -> Result<Vec<String>, HeaderError> {
133    let Some(end) = body.find(']') else {
134        return Err(HeaderError::Malformed(
135            "missing closing `]` in @data_deps array".to_string(),
136        ));
137    };
138    let inside = &body[..end];
139    // Strip `//` inline comments line-by-line before comma-splitting.
140    // `//` extends to end-of-line, not end-of-fragment; a fragment
141    // can span multiple lines (a dep on one line, a justification
142    // comment on the next), so we can't just find the first `//`.
143    let stripped: String = inside
144        .lines()
145        .map(|line| match line.find("//") {
146            Some(i) => &line[..i],
147            None => line,
148        })
149        .collect::<Vec<_>>()
150        .join(" ");
151    let mut tokens = Vec::new();
152    for raw in stripped.split(',') {
153        let s = raw.trim();
154        if s.is_empty() {
155            continue;
156        }
157        let unquoted = unquote(s)?;
158        tokens.push(unquoted);
159    }
160    Ok(tokens)
161}
162
163fn unquote(s: &str) -> Result<String, HeaderError> {
164    let bytes = s.as_bytes();
165    if bytes.len() >= 2
166        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
167            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
168    {
169        Ok(s[1..s.len() - 1].to_string())
170    } else {
171        Err(HeaderError::Malformed(format!(
172            "expected quoted string, got `{s}`"
173        )))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn deps(names: &[&str]) -> Vec<String> {
182        names.iter().map(|s| (*s).to_string()).collect()
183    }
184
185    #[test]
186    fn no_header_defaults_to_status_only() {
187        let src = "fn render(ctx) { () }";
188        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
189    }
190
191    #[test]
192    fn empty_array_defaults_to_status_only() {
193        let src = "// @data_deps = []\nfn render(ctx) {}";
194        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
195    }
196
197    #[test]
198    fn single_line_single_entry_unions_with_status() {
199        let src = r#"// @data_deps = ["usage"]
200fn render(ctx) {}"#;
201        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
202    }
203
204    #[test]
205    fn single_line_multi_entry() {
206        let src = r#"// @data_deps = ["settings", "usage", "git"]
207fn render(ctx) {}"#;
208        assert_eq!(
209            parse_data_deps_header(src),
210            Ok(deps(&["status", "settings", "usage", "git"]))
211        );
212    }
213
214    #[test]
215    fn explicit_status_is_accepted_without_duplication() {
216        let src = r#"// @data_deps = ["status", "usage"]
217fn render(ctx) {}"#;
218        let resolved = parse_data_deps_header(src).unwrap();
219        assert_eq!(resolved, deps(&["status", "usage"]));
220        assert_eq!(
221            resolved.iter().filter(|d| *d == "status").count(),
222            1,
223            "status must not be duplicated when listed explicitly"
224        );
225    }
226
227    #[test]
228    fn multi_line_array_accepted() {
229        let src = r#"// @data_deps = [
230//   "settings",
231//   "usage",
232//   "git",
233// ]
234fn render(ctx) {}"#;
235        assert_eq!(
236            parse_data_deps_header(src),
237            Ok(deps(&["status", "settings", "usage", "git"]))
238        );
239    }
240
241    #[test]
242    fn trailing_comma_in_single_line_ok() {
243        let src = r#"// @data_deps = ["usage",]
244fn render(ctx) {}"#;
245        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
246    }
247
248    #[test]
249    fn single_quotes_accepted() {
250        let src = "// @data_deps = ['usage']\nfn render(ctx) {}";
251        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
252    }
253
254    #[test]
255    fn unknown_dep_name_rejected() {
256        let src = r#"// @data_deps = ["usage", "mystery"]
257fn render(ctx) {}"#;
258        assert_eq!(
259            parse_data_deps_header(src),
260            Err(HeaderError::UnknownDep("mystery".to_string()))
261        );
262    }
263
264    #[test]
265    fn reserved_credentials_dep_rejected_as_unknown() {
266        // `credentials` is plugin-reserved per spec §@data_deps
267        // header syntax. Header parser must reject it with UnknownDep,
268        // matching the error surface for truly unknown names.
269        let src = r#"// @data_deps = ["credentials"]
270fn render(ctx) {}"#;
271        assert_eq!(
272            parse_data_deps_header(src),
273            Err(HeaderError::UnknownDep("credentials".to_string()))
274        );
275    }
276
277    #[test]
278    fn reserved_jsonl_dep_rejected_as_unknown() {
279        let src = r#"// @data_deps = ["jsonl"]
280fn render(ctx) {}"#;
281        assert_eq!(
282            parse_data_deps_header(src),
283            Err(HeaderError::UnknownDep("jsonl".to_string()))
284        );
285    }
286
287    #[test]
288    fn blank_line_ends_header_block() {
289        // The header is the first block of `//` lines. A blank line
290        // after it means `@data_deps` below is in a different block
291        // and must not be parsed.
292        let src = r#"// top comment
293
294// @data_deps = ["usage"]
295fn render(ctx) {}"#;
296        // The `@data_deps` line is in a second block, so the first
297        // block's resolution defaults to [status] only.
298        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
299    }
300
301    #[test]
302    fn non_comment_line_ends_header_block() {
303        // Anything that doesn't start with `//` (after trimming
304        // whitespace) ends the block — including rhai statements.
305        let src = r#"// top comment
306fn render(ctx) {}
307// @data_deps = ["usage"]"#;
308        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
309    }
310
311    #[test]
312    fn header_appearing_after_other_comments_still_parses() {
313        // Multi-line `//` comments before `@data_deps` are part of
314        // the same header block; the parser finds the declaration
315        // regardless of its position within the block.
316        let src = r#"// Some plugin description
317// Authored by me
318// @data_deps = ["usage"]
319fn render(ctx) {}"#;
320        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
321    }
322
323    #[test]
324    fn malformed_missing_equals_rejected() {
325        // Spec intent: writing `@data_deps` declares a header, so
326        // malformed RHS must surface as an error — not silently
327        // downgrade to the default `[status]`.
328        let src = r#"// @data_deps ["usage"]
329fn render(ctx) {}"#;
330        assert!(matches!(
331            parse_data_deps_header(src),
332            Err(HeaderError::Malformed(_))
333        ));
334    }
335
336    #[test]
337    fn malformed_scalar_rhs_rejected() {
338        let src = r#"// @data_deps = "usage"
339fn render(ctx) {}"#;
340        assert!(matches!(
341            parse_data_deps_header(src),
342            Err(HeaderError::Malformed(_))
343        ));
344    }
345
346    #[test]
347    fn malformed_missing_closing_bracket() {
348        let src = r#"// @data_deps = ["usage"
349fn render(ctx) {}"#;
350        assert!(matches!(
351            parse_data_deps_header(src),
352            Err(HeaderError::Malformed(_))
353        ));
354    }
355
356    #[test]
357    fn malformed_unquoted_token() {
358        let src = r#"// @data_deps = [usage]
359fn render(ctx) {}"#;
360        assert!(matches!(
361            parse_data_deps_header(src),
362            Err(HeaderError::Malformed(_))
363        ));
364    }
365
366    #[test]
367    fn block_comment_syntax_is_not_scanned() {
368        // Per spec: `/* @data_deps = [...] */` is NOT parsed.
369        let src = r#"/* @data_deps = ["usage"] */
370fn render(ctx) {}"#;
371        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
372    }
373
374    #[test]
375    fn inline_comment_on_array_line_accepted() {
376        // Spec §@data_deps header syntax: "Trailing commas, comments
377        // inside the array, and multi-line forms are all accepted."
378        let src = r#"// @data_deps = [
379//   "usage",       // why we need it
380//   "git",         // trailing comment too
381// ]
382fn render(ctx) {}"#;
383        assert_eq!(
384            parse_data_deps_header(src),
385            Ok(deps(&["status", "usage", "git"]))
386        );
387    }
388
389    #[test]
390    fn inline_comment_after_last_entry_accepted() {
391        // Spec only requires line-comment support inside the array;
392        // block comments are not scanned. Exercise the single-line
393        // `//` case after a quoted entry.
394        let src = r#"// @data_deps = [
395//   "usage", // ok
396//   "git"
397// ]
398fn render(ctx) {}"#;
399        assert_eq!(
400            parse_data_deps_header(src),
401            Ok(deps(&["status", "usage", "git"]))
402        );
403    }
404
405    #[test]
406    fn whitespace_before_double_slash_is_tolerated() {
407        let src = r#"    // @data_deps = ["usage"]
408fn render(ctx) {}"#;
409        assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
410    }
411}