Skip to main content

shape_runtime/
frontmatter.rs

1//! Front-matter parser for Shape scripts
2//!
3//! Parses optional TOML front-matter delimited by `---` at the top of a script.
4//! Also skips shebang lines (`#!/...`).
5//!
6//! Frontmatter is for **standalone script** metadata/dependencies/extensions.
7//! It supports `[dependencies]`, `[dev-dependencies]`, and `[[extensions]]`.
8//! Packaging/build sections like `[project]` and `[build]` still belong in `shape.toml`.
9
10use crate::project::{ModulesSection, ShapeProject};
11use serde::Deserialize;
12
13/// Script-level frontmatter configuration.
14///
15/// This is intentionally focused on script-level metadata fields used by
16/// diagnostics and editor hints.
17#[derive(Debug, Clone, Deserialize, Default)]
18pub struct FrontmatterConfig {
19    #[serde(default)]
20    pub name: Option<String>,
21    #[serde(default)]
22    pub description: Option<String>,
23    #[serde(default)]
24    pub version: Option<String>,
25    #[serde(default)]
26    pub author: Option<String>,
27    #[serde(default)]
28    pub tags: Option<Vec<String>>,
29    /// Module search paths (allowed in frontmatter for scripts)
30    #[serde(default)]
31    pub modules: Option<ModulesSection>,
32}
33
34/// A diagnostic produced during frontmatter validation.
35#[derive(Debug, Clone)]
36pub struct FrontmatterDiagnostic {
37    pub message: String,
38    pub severity: FrontmatterDiagnosticSeverity,
39    pub location: Option<FrontmatterDiagnosticLocation>,
40}
41
42/// Severity level for frontmatter diagnostics.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum FrontmatterDiagnosticSeverity {
45    Error,
46    Warning,
47}
48
49/// Source location for a frontmatter diagnostic.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct FrontmatterDiagnosticLocation {
52    pub line: u32,
53    pub character: u32,
54    pub length: u32,
55}
56
57/// Sections that are forbidden in frontmatter (they belong in `shape.toml`).
58const FORBIDDEN_SECTIONS: &[(&str, &str)] = &[
59    (
60        "project",
61        "The [project] section belongs in shape.toml, not in file frontmatter",
62    ),
63    (
64        "build",
65        "Build configuration must be specified in shape.toml",
66    ),
67    ("plugins", "Use [[extensions]] instead of [plugins]"),
68];
69
70/// Known top-level keys allowed in frontmatter.
71pub const FRONTMATTER_TOP_LEVEL_KEYS: &[&str] =
72    &["name", "description", "version", "author", "tags"];
73
74/// Known table sections allowed in frontmatter.
75pub const FRONTMATTER_SECTION_KEYS: &[&str] =
76    &["modules", "dependencies", "dev-dependencies", "extensions"];
77
78/// Keys allowed inside `[[extensions]]` entries.
79pub const FRONTMATTER_EXTENSION_KEYS: &[&str] = &["name", "path", "config"];
80
81/// Keys allowed inside `[modules]`.
82pub const FRONTMATTER_MODULE_KEYS: &[&str] = &["paths"];
83
84const ALLOWED_KEYS: &[&str] = &[
85    "name",
86    "description",
87    "version",
88    "author",
89    "tags",
90    "modules",
91    "dependencies",
92    "dev-dependencies",
93    "extensions",
94];
95
96const ALLOWED_EXTENSION_KEYS: &[&str] = FRONTMATTER_EXTENSION_KEYS;
97
98/// Result of extracting the TOML body from frontmatter delimiters.
99struct FrontmatterBody<'a> {
100    toml_str: &'a str,
101    remaining: &'a str,
102    toml_start_line: u32,
103}
104
105/// Shared logic for both `parse_frontmatter` and `parse_frontmatter_validated`.
106///
107/// Skips shebang, finds `---` delimiters, and extracts the TOML body and
108/// remaining source. Returns `None` if no valid frontmatter block is found.
109fn extract_frontmatter_body(source: &str) -> Option<FrontmatterBody<'_>> {
110    let has_shebang = source.starts_with("#!");
111    let rest = if has_shebang {
112        match source.find('\n') {
113            Some(pos) => &source[pos + 1..],
114            None => return None,
115        }
116    } else {
117        source
118    };
119
120    let trimmed = rest.trim_start_matches([' ', '\t']);
121    if !trimmed.starts_with("---") {
122        return None;
123    }
124    let after_marker = &trimmed[3..];
125    let first_newline = after_marker.find('\n');
126    match first_newline {
127        Some(pos) if after_marker[..pos].trim().is_empty() => {}
128        None if after_marker.trim().is_empty() => return None,
129        _ => return None,
130    }
131
132    let body_start = &after_marker[first_newline.unwrap() + 1..];
133
134    let end_pos = find_closing_delimiter(body_start)?;
135
136    let toml_str = &body_start[..end_pos];
137    let after_closing_line = &body_start[end_pos..];
138    let remaining = match after_closing_line.find('\n') {
139        Some(pos) => &after_closing_line[pos + 1..],
140        None => "",
141    };
142
143    Some(FrontmatterBody {
144        toml_str,
145        remaining,
146        toml_start_line: if has_shebang { 2 } else { 1 },
147    })
148}
149
150/// Parse optional front-matter from a Shape source string, with validation.
151///
152/// Returns `(config, diagnostics, remaining_source)` where:
153/// - `config` is `Some` if a `---` delimited TOML block was found and parsed
154/// - `diagnostics` contains any validation errors/warnings
155/// - `remaining_source` is the Shape code after the front-matter
156///
157/// Shebang lines (`#!...`) at the very start are skipped before checking
158/// for front-matter.
159pub fn parse_frontmatter_validated(
160    source: &str,
161) -> (Option<FrontmatterConfig>, Vec<FrontmatterDiagnostic>, &str) {
162    let body = match extract_frontmatter_body(source) {
163        Some(b) => b,
164        None => return (None, vec![], source),
165    };
166
167    let mut diagnostics = validate_frontmatter_toml(body.toml_str, body.toml_start_line);
168
169    match toml::from_str::<FrontmatterConfig>(body.toml_str) {
170        Ok(config) => (Some(config), diagnostics, body.remaining),
171        Err(err) => {
172            diagnostics.push(frontmatter_parse_error_diagnostic(
173                body.toml_str,
174                body.toml_start_line,
175                &err,
176            ));
177            (None, diagnostics, body.remaining)
178        }
179    }
180}
181
182/// Validate raw TOML string for forbidden project-level sections and unknown keys.
183fn validate_frontmatter_toml(toml_str: &str, toml_start_line: u32) -> Vec<FrontmatterDiagnostic> {
184    let mut diagnostics = Vec::new();
185
186    let table = match toml_str.parse::<toml::Table>() {
187        Ok(t) => t,
188        Err(_) => return diagnostics, // parse error handled elsewhere
189    };
190
191    for (key, value) in &table {
192        // Check forbidden sections
193        let mut is_forbidden = false;
194        for (section, message) in FORBIDDEN_SECTIONS {
195            if key == section {
196                diagnostics.push(FrontmatterDiagnostic {
197                    message: message.to_string(),
198                    severity: FrontmatterDiagnosticSeverity::Error,
199                    location: find_section_header_location(toml_str, key, toml_start_line),
200                });
201                is_forbidden = true;
202                break;
203            }
204        }
205
206        // Warn about unknown keys (not forbidden, not in allowed list)
207        if !is_forbidden && !ALLOWED_KEYS.contains(&key.as_str()) {
208            if matches!(value, toml::Value::Table(_)) {
209                // Table-valued unknown keys may be extension sections
210                diagnostics.push(FrontmatterDiagnostic {
211                    message: format!(
212                        "Unknown section '[{}]' may be an extension section \
213                         — will be passed to extensions if claimed",
214                        key
215                    ),
216                    severity: FrontmatterDiagnosticSeverity::Warning,
217                    location: find_section_header_location(toml_str, key, toml_start_line),
218                });
219            } else {
220                diagnostics.push(FrontmatterDiagnostic {
221                    message: format!(
222                        "Unknown frontmatter key '{}'. Allowed keys: name, description, \
223                         version, author, tags, modules, dependencies, dev-dependencies, extensions",
224                        key
225                    ),
226                    severity: FrontmatterDiagnosticSeverity::Warning,
227                    location: find_top_level_key_location(toml_str, key, toml_start_line),
228                });
229            }
230        }
231    }
232
233    diagnostics.extend(validate_extension_entries(toml_str, toml_start_line));
234
235    diagnostics
236}
237
238fn validate_extension_entries(toml_str: &str, toml_start_line: u32) -> Vec<FrontmatterDiagnostic> {
239    #[derive(Debug, Clone, Copy)]
240    struct ExtensionEntryState {
241        header_line: u32,
242        has_name: bool,
243        has_path: bool,
244    }
245
246    fn finalize_entry(
247        diagnostics: &mut Vec<FrontmatterDiagnostic>,
248        entry: Option<ExtensionEntryState>,
249    ) {
250        let Some(entry) = entry else {
251            return;
252        };
253
254        if !entry.has_name {
255            diagnostics.push(FrontmatterDiagnostic {
256                message: "Missing required key 'name' in [[extensions]] entry".to_string(),
257                severity: FrontmatterDiagnosticSeverity::Error,
258                location: Some(FrontmatterDiagnosticLocation {
259                    line: entry.header_line,
260                    character: 0,
261                    length: 14,
262                }),
263            });
264        }
265
266        if !entry.has_path {
267            diagnostics.push(FrontmatterDiagnostic {
268                message: "Missing required key 'path' in [[extensions]] entry".to_string(),
269                severity: FrontmatterDiagnosticSeverity::Error,
270                location: Some(FrontmatterDiagnosticLocation {
271                    line: entry.header_line,
272                    character: 0,
273                    length: 14,
274                }),
275            });
276        }
277    }
278
279    let mut diagnostics = Vec::new();
280    let mut in_extensions = false;
281    let mut current_entry: Option<ExtensionEntryState> = None;
282
283    for (idx, raw_line) in toml_str.lines().enumerate() {
284        let trimmed = raw_line.trim();
285        let absolute_line = toml_start_line + idx as u32;
286
287        if trimmed.starts_with("[[extensions]]") {
288            finalize_entry(&mut diagnostics, current_entry.take());
289            in_extensions = true;
290            current_entry = Some(ExtensionEntryState {
291                header_line: absolute_line,
292                has_name: false,
293                has_path: false,
294            });
295            continue;
296        }
297
298        if trimmed.starts_with("[[") || (trimmed.starts_with('[') && trimmed.ends_with(']')) {
299            finalize_entry(&mut diagnostics, current_entry.take());
300            in_extensions = false;
301            continue;
302        }
303
304        if !in_extensions {
305            continue;
306        }
307
308        if trimmed.is_empty() || trimmed.starts_with('#') {
309            continue;
310        }
311
312        let Some(eq_pos) = raw_line.find('=') else {
313            continue;
314        };
315
316        let key = raw_line[..eq_pos].trim();
317        let key_start = raw_line
318            .find(key)
319            .or_else(|| raw_line[..eq_pos].find(|c: char| !c.is_whitespace()))
320            .unwrap_or(0) as u32;
321
322        if let Some(entry) = current_entry.as_mut() {
323            match key {
324                "name" => entry.has_name = true,
325                "path" => entry.has_path = true,
326                "config" => {}
327                _ => {
328                    if !ALLOWED_EXTENSION_KEYS.contains(&key) {
329                        diagnostics.push(FrontmatterDiagnostic {
330                            message: format!(
331                                "Unknown key '{}' in [[extensions]] entry. Allowed keys: name, path, config",
332                                key
333                            ),
334                            severity: FrontmatterDiagnosticSeverity::Error,
335                            location: Some(FrontmatterDiagnosticLocation {
336                                line: absolute_line,
337                                character: key_start,
338                                length: key.len() as u32,
339                            }),
340                        });
341                    }
342                }
343            }
344        }
345    }
346
347    finalize_entry(&mut diagnostics, current_entry);
348    diagnostics
349}
350
351fn frontmatter_parse_error_diagnostic(
352    toml_str: &str,
353    toml_start_line: u32,
354    err: &toml::de::Error,
355) -> FrontmatterDiagnostic {
356    let location = err.span().map(|span| {
357        let (line, character) = offset_to_line_col(toml_str, span.start);
358        FrontmatterDiagnosticLocation {
359            line: toml_start_line + line,
360            character,
361            length: 1,
362        }
363    });
364
365    FrontmatterDiagnostic {
366        message: format!("Frontmatter TOML parse error: {}", err.message()),
367        severity: FrontmatterDiagnosticSeverity::Error,
368        location,
369    }
370}
371
372fn find_section_header_location(
373    toml_str: &str,
374    section: &str,
375    toml_start_line: u32,
376) -> Option<FrontmatterDiagnosticLocation> {
377    let header = format!("[{}]", section);
378    for (idx, raw_line) in toml_str.lines().enumerate() {
379        let trimmed = raw_line.trim();
380        if trimmed == header {
381            let start = raw_line.find('[').unwrap_or(0) as u32;
382            return Some(FrontmatterDiagnosticLocation {
383                line: toml_start_line + idx as u32,
384                character: start,
385                length: header.len() as u32,
386            });
387        }
388    }
389    None
390}
391
392fn find_top_level_key_location(
393    toml_str: &str,
394    key: &str,
395    toml_start_line: u32,
396) -> Option<FrontmatterDiagnosticLocation> {
397    let mut in_section = false;
398    for (idx, raw_line) in toml_str.lines().enumerate() {
399        let trimmed = raw_line.trim();
400        if trimmed.starts_with('[') && trimmed.ends_with(']') {
401            in_section = true;
402            continue;
403        }
404        if in_section || trimmed.is_empty() || trimmed.starts_with('#') {
405            continue;
406        }
407        let Some(eq_pos) = raw_line.find('=') else {
408            continue;
409        };
410        let current_key = raw_line[..eq_pos].trim();
411        if current_key == key {
412            let key_start = raw_line.find(key).unwrap_or(0) as u32;
413            return Some(FrontmatterDiagnosticLocation {
414                line: toml_start_line + idx as u32,
415                character: key_start,
416                length: key.len() as u32,
417            });
418        }
419    }
420    None
421}
422
423fn offset_to_line_col(text: &str, offset: usize) -> (u32, u32) {
424    let mut line = 0u32;
425    let mut col = 0u32;
426    for (i, ch) in text.char_indices() {
427        if i >= offset {
428            break;
429        }
430        if ch == '\n' {
431            line += 1;
432            col = 0;
433        } else {
434            col += 1;
435        }
436    }
437    (line, col)
438}
439
440/// Parse optional front-matter from a Shape source string.
441///
442/// Returns `(config, remaining_source)` where `config` is `Some` if a
443/// `---` delimited TOML block was found, and `remaining_source` is the
444/// Shape code after the front-matter (or the full source if none).
445///
446/// This is the backwards-compatible version. For validation diagnostics,
447/// use [`parse_frontmatter_validated`] instead.
448///
449/// Shebang lines (`#!...`) at the very start are skipped before checking
450/// for front-matter.
451pub fn parse_frontmatter(source: &str) -> (Option<ShapeProject>, &str) {
452    let body = match extract_frontmatter_body(source) {
453        Some(b) => b,
454        None => return (None, source),
455    };
456
457    match crate::project::parse_shape_project_toml(body.toml_str) {
458        Ok(config) => (Some(config), body.remaining),
459        Err(_) => (None, body.remaining),
460    }
461}
462
463/// Find the byte offset of a line that is exactly `---` (with optional whitespace).
464fn find_closing_delimiter(s: &str) -> Option<usize> {
465    let mut offset = 0;
466    for line in s.lines() {
467        if line.trim() == "---" {
468            return Some(offset);
469        }
470        offset += line.len() + 1; // +1 for newline
471    }
472    None
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    // ---- Legacy parse_frontmatter tests ----
480
481    #[test]
482    fn test_no_frontmatter() {
483        let source = "let x = 1;\nprint(x);\n";
484        let (config, rest) = parse_frontmatter(source);
485        assert!(config.is_none());
486        assert_eq!(rest, source);
487    }
488
489    #[test]
490    fn test_with_frontmatter() {
491        let source = r#"---
492[modules]
493paths = ["lib"]
494---
495let x = 1;
496"#;
497        let (config, rest) = parse_frontmatter(source);
498        assert!(config.is_some());
499        let cfg = config.unwrap();
500        assert_eq!(cfg.modules.paths, vec!["lib"]);
501        assert_eq!(rest, "let x = 1;\n");
502    }
503
504    #[test]
505    fn test_with_frontmatter_extensions() {
506        let source = r#"---
507[[extensions]]
508name = "duckdb"
509path = "./extensions/libshape_ext_duckdb.so"
510---
511let x = 1;
512"#;
513        let (config, rest) = parse_frontmatter(source);
514        assert!(config.is_some());
515        let cfg = config.unwrap();
516        assert_eq!(cfg.extensions.len(), 1);
517        assert_eq!(cfg.extensions[0].name, "duckdb");
518        assert_eq!(rest, "let x = 1;\n");
519    }
520
521    #[test]
522    fn test_shebang_with_frontmatter() {
523        let source = r#"#!/usr/bin/env shape
524---
525[project]
526name = "script"
527
528[modules]
529paths = ["lib", "vendor"]
530---
531print("hello");
532"#;
533        let (config, rest) = parse_frontmatter(source);
534        assert!(config.is_some());
535        let cfg = config.unwrap();
536        assert_eq!(cfg.project.name, "script");
537        assert_eq!(cfg.modules.paths, vec!["lib", "vendor"]);
538        assert_eq!(rest, "print(\"hello\");\n");
539    }
540
541    #[test]
542    fn test_shebang_without_frontmatter() {
543        let source = "#!/usr/bin/env shape\nlet x = 1;\n";
544        let (config, rest) = parse_frontmatter(source);
545        assert!(config.is_none());
546        assert_eq!(rest, source);
547    }
548
549    #[test]
550    fn test_malformed_toml() {
551        let source = "---\nthis is not valid toml {{{\n---\nlet x = 1;\n";
552        let (config, rest) = parse_frontmatter(source);
553        assert!(config.is_none());
554        assert_eq!(rest, "let x = 1;\n");
555    }
556
557    #[test]
558    fn test_no_closing_delimiter() {
559        let source = "---\n[modules]\npaths = [\"lib\"]\nlet x = 1;\n";
560        let (config, rest) = parse_frontmatter(source);
561        assert!(config.is_none());
562        assert_eq!(rest, source);
563    }
564
565    // ---- Validated frontmatter tests ----
566
567    #[test]
568    fn test_validated_no_frontmatter() {
569        let source = "let x = 1;\nprint(x);\n";
570        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
571        assert!(config.is_none());
572        assert!(diagnostics.is_empty());
573        assert_eq!(rest, source);
574    }
575
576    #[test]
577    fn test_validated_valid_frontmatter() {
578        let source = r#"---
579name = "my-script"
580description = "A test script"
581version = "1.0.0"
582author = "dev"
583tags = ["analysis", "test"]
584
585[modules]
586paths = ["lib"]
587---
588let x = 1;
589"#;
590        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
591        assert!(config.is_some());
592        assert!(
593            diagnostics.is_empty(),
594            "Expected no diagnostics but got: {:?}",
595            diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
596        );
597        let cfg = config.unwrap();
598        assert_eq!(cfg.name.as_deref(), Some("my-script"));
599        assert_eq!(cfg.description.as_deref(), Some("A test script"));
600        assert_eq!(cfg.version.as_deref(), Some("1.0.0"));
601        assert_eq!(cfg.author.as_deref(), Some("dev"));
602        assert_eq!(
603            cfg.tags.as_deref(),
604            Some(&["analysis".to_string(), "test".to_string()][..])
605        );
606        assert_eq!(cfg.modules.as_ref().unwrap().paths, vec!["lib"]);
607        assert_eq!(rest, "let x = 1;\n");
608    }
609
610    #[test]
611    fn test_validated_empty_frontmatter() {
612        let source = "---\n---\nlet x = 1;\n";
613        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
614        assert!(config.is_some());
615        assert!(diagnostics.is_empty());
616        assert_eq!(rest, "let x = 1;\n");
617    }
618
619    #[test]
620    fn test_validated_project_section_error() {
621        let source = r#"---
622[project]
623name = "bad"
624---
625let x = 1;
626"#;
627        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
628        // Config is still parsed (with unknown fields ignored), but diagnostics are emitted
629        assert!(config.is_some());
630        assert_eq!(rest, "let x = 1;\n");
631        assert_eq!(diagnostics.len(), 1);
632        assert_eq!(
633            diagnostics[0].severity,
634            FrontmatterDiagnosticSeverity::Error
635        );
636        assert!(diagnostics[0].message.contains("[project]"));
637        assert!(diagnostics[0].message.contains("shape.toml"));
638    }
639
640    #[test]
641    fn test_validated_dependencies_allowed() {
642        let source = "---\n[dependencies]\nfoo = \"1.0\"\n---\nlet x = 1;\n";
643        let (_config, diagnostics, rest) = parse_frontmatter_validated(source);
644        assert!(diagnostics.is_empty());
645        assert_eq!(rest, "let x = 1;\n");
646    }
647
648    #[test]
649    fn test_validated_build_section_error() {
650        let source = "---\n[build]\noptimize = true\n---\nlet x = 1;\n";
651        let (_config, diagnostics, _rest) = parse_frontmatter_validated(source);
652        assert_eq!(diagnostics.len(), 1);
653        assert_eq!(
654            diagnostics[0].severity,
655            FrontmatterDiagnosticSeverity::Error
656        );
657        assert!(diagnostics[0].message.contains("Build configuration"));
658    }
659
660    #[test]
661    fn test_validated_extensions_allowed() {
662        let source = r#"---
663[[extensions]]
664name = "csv"
665path = "./libshape_plugin_csv.so"
666---
667let x = 1;
668"#;
669        let (_config, diagnostics, rest) = parse_frontmatter_validated(source);
670        assert!(diagnostics.is_empty());
671        assert_eq!(rest, "let x = 1;\n");
672    }
673
674    #[test]
675    fn test_validated_plugins_error() {
676        let source = "---\n[plugins]\nname = \"plug\"\n---\nlet x = 1;\n";
677        let (_config, diagnostics, _rest) = parse_frontmatter_validated(source);
678        assert_eq!(diagnostics.len(), 1);
679        assert_eq!(
680            diagnostics[0].severity,
681            FrontmatterDiagnosticSeverity::Error
682        );
683        assert!(diagnostics[0].message.contains("[[extensions]]"));
684    }
685
686    #[test]
687    fn test_validated_dev_dependencies_allowed() {
688        let source = "---\n[dev-dependencies]\ntest-lib = \"2.0\"\n---\nlet x = 1;\n";
689        let (_config, diagnostics, rest) = parse_frontmatter_validated(source);
690        assert!(diagnostics.is_empty());
691        assert_eq!(rest, "let x = 1;\n");
692    }
693
694    #[test]
695    fn test_validated_multiple_forbidden_sections() {
696        let source = r#"---
697[project]
698name = "bad"
699
700[dependencies]
701foo = "1.0"
702
703[build]
704optimize = true
705---
706let x = 1;
707"#;
708        let (_config, diagnostics, _rest) = parse_frontmatter_validated(source);
709        assert_eq!(diagnostics.len(), 2);
710        assert!(
711            diagnostics
712                .iter()
713                .all(|d| d.severity == FrontmatterDiagnosticSeverity::Error)
714        );
715    }
716
717    #[test]
718    fn test_validated_unknown_key_warning() {
719        let source = "---\nfoo = \"bar\"\n---\nlet x = 1;\n";
720        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
721        assert!(config.is_some());
722        assert_eq!(rest, "let x = 1;\n");
723        assert_eq!(diagnostics.len(), 1);
724        assert_eq!(
725            diagnostics[0].severity,
726            FrontmatterDiagnosticSeverity::Warning
727        );
728        assert!(
729            diagnostics[0]
730                .message
731                .contains("Unknown frontmatter key 'foo'")
732        );
733        assert_eq!(
734            diagnostics[0].location,
735            Some(FrontmatterDiagnosticLocation {
736                line: 1,
737                character: 0,
738                length: 3,
739            })
740        );
741    }
742
743    #[test]
744    fn test_validated_unknown_extensions_key_error() {
745        let source = r#"---
746[[extensions]]
747nm = "duckdb"
748path = "./extensions/libshape_ext_duckdb.so"
749---
750let x = 1;
751"#;
752        let (_config, diagnostics, rest) = parse_frontmatter_validated(source);
753        assert_eq!(rest, "let x = 1;\n");
754        assert!(diagnostics.iter().any(|d| {
755            d.severity == FrontmatterDiagnosticSeverity::Error
756                && d.message
757                    .contains("Unknown key 'nm' in [[extensions]] entry")
758                && d.location
759                    == Some(FrontmatterDiagnosticLocation {
760                        line: 2,
761                        character: 0,
762                        length: 2,
763                    })
764        }));
765    }
766
767    #[test]
768    fn test_validated_shebang_with_validation() {
769        let source = r#"#!/usr/bin/env shape
770---
771name = "my-script"
772
773[modules]
774paths = ["lib"]
775---
776print("hello");
777"#;
778        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
779        assert!(config.is_some());
780        assert!(diagnostics.is_empty());
781        let cfg = config.unwrap();
782        assert_eq!(cfg.name.as_deref(), Some("my-script"));
783        assert_eq!(cfg.modules.as_ref().unwrap().paths, vec!["lib"]);
784        assert_eq!(rest, "print(\"hello\");\n");
785    }
786
787    #[test]
788    fn test_validated_malformed_toml() {
789        let source = "---\nthis is not valid toml {{{\n---\nlet x = 1;\n";
790        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
791        assert!(config.is_none());
792        assert_eq!(rest, "let x = 1;\n");
793        assert_eq!(diagnostics.len(), 1);
794        assert_eq!(
795            diagnostics[0].severity,
796            FrontmatterDiagnosticSeverity::Error
797        );
798        assert!(
799            diagnostics[0]
800                .message
801                .contains("Frontmatter TOML parse error")
802        );
803    }
804
805    #[test]
806    fn test_validated_no_closing_delimiter() {
807        let source = "---\nname = \"test\"\nlet x = 1;\n";
808        let (config, diagnostics, rest) = parse_frontmatter_validated(source);
809        assert!(config.is_none());
810        assert!(diagnostics.is_empty());
811        assert_eq!(rest, source);
812    }
813
814    #[test]
815    fn test_validated_extension_section_softer_diagnostic() {
816        let source = "---\n[native-dependencies]\nlibm = \"libm.so\"\n---\nlet x = 1;\n";
817        let (_config, diagnostics, rest) = parse_frontmatter_validated(source);
818        assert_eq!(rest, "let x = 1;\n");
819        assert_eq!(diagnostics.len(), 1);
820        assert_eq!(
821            diagnostics[0].severity,
822            FrontmatterDiagnosticSeverity::Warning
823        );
824        assert!(
825            diagnostics[0].message.contains("extension section"),
826            "Table-valued unknown key should get softer message, got: {}",
827            diagnostics[0].message
828        );
829    }
830
831    #[test]
832    fn test_validated_scalar_unknown_key_still_warns() {
833        let source = "---\nfoo = \"bar\"\n---\nlet x = 1;\n";
834        let (_config, diagnostics, _rest) = parse_frontmatter_validated(source);
835        assert_eq!(diagnostics.len(), 1);
836        assert!(
837            diagnostics[0].message.contains("Unknown frontmatter key"),
838            "Scalar unknown key should get existing warning, got: {}",
839            diagnostics[0].message
840        );
841    }
842}