1use crate::project::{ModulesSection, ShapeProject};
11use serde::Deserialize;
12
13#[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 #[serde(default)]
31 pub modules: Option<ModulesSection>,
32}
33
34#[derive(Debug, Clone)]
36pub struct FrontmatterDiagnostic {
37 pub message: String,
38 pub severity: FrontmatterDiagnosticSeverity,
39 pub location: Option<FrontmatterDiagnosticLocation>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum FrontmatterDiagnosticSeverity {
45 Error,
46 Warning,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct FrontmatterDiagnosticLocation {
52 pub line: u32,
53 pub character: u32,
54 pub length: u32,
55}
56
57const 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
70pub const FRONTMATTER_TOP_LEVEL_KEYS: &[&str] =
72 &["name", "description", "version", "author", "tags"];
73
74pub const FRONTMATTER_SECTION_KEYS: &[&str] =
76 &["modules", "dependencies", "dev-dependencies", "extensions"];
77
78pub const FRONTMATTER_EXTENSION_KEYS: &[&str] = &["name", "path", "config"];
80
81pub 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
98struct FrontmatterBody<'a> {
100 toml_str: &'a str,
101 remaining: &'a str,
102 toml_start_line: u32,
103}
104
105fn 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
150pub 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
182fn 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, };
190
191 for (key, value) in &table {
192 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 if !is_forbidden && !ALLOWED_KEYS.contains(&key.as_str()) {
208 if matches!(value, toml::Value::Table(_)) {
209 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
440pub 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
463fn 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; }
472 None
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[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 #[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 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}