Skip to main content

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