1type Span = std::ops::Range<usize>;
2
3#[derive(Debug)]
4pub struct ScriptSource<'s> {
5 raw: &'s str,
7 shebang: Option<Span>,
9 open: Option<Span>,
11 info: Option<Span>,
14 frontmatter: Option<Span>,
16 close: Option<Span>,
18 content: Span,
20}
21
22impl<'s> ScriptSource<'s> {
23 pub fn parse(raw: &'s str) -> Result<Self, FrontmatterError> {
24 use winnow::stream::FindSlice as _;
25 use winnow::stream::Location as _;
26 use winnow::stream::Offset as _;
27 use winnow::stream::Stream as _;
28
29 let content_end = raw.len();
30 let mut source = Self {
31 raw,
32 shebang: None,
33 open: None,
34 info: None,
35 frontmatter: None,
36 close: None,
37 content: 0..content_end,
38 };
39
40 let mut input = winnow::stream::LocatingSlice::new(raw);
41
42 if let Some(shebang_end) = strip_shebang(input.as_ref()) {
43 let shebang_start = input.current_token_start();
44 let _ = input.next_slice(shebang_end);
45 let shebang_end = input.current_token_start();
46 source.shebang = Some(shebang_start..shebang_end);
47 source.content = shebang_end..content_end;
48 }
49
50 if let Some(nl_end) = strip_ws_lines(input.as_ref()) {
52 let _ = input.next_slice(nl_end);
53 }
54
55 const FENCE_CHAR: char = '-';
57 let fence_length = input
58 .as_ref()
59 .char_indices()
60 .find_map(|(i, c)| (c != FENCE_CHAR).then_some(i))
61 .unwrap_or_else(|| input.eof_offset());
62 let open_start = input.current_token_start();
63 let fence_pattern = input.next_slice(fence_length);
64 let open_end = input.current_token_start();
65 match fence_length {
66 0 => {
67 return Ok(source);
68 }
69 1 | 2 => {
70 return Err(FrontmatterError::new(
72 format!(
73 "found {fence_length} `{FENCE_CHAR}` in rust frontmatter, expected at least 3"
74 ),
75 raw.len()..raw.len(),
76 ).push_visible_span(open_start..open_end));
77 }
78 _ if u8::try_from(fence_length).is_err() => {
79 return Err(FrontmatterError::new(
80 format!(
81 "too many `-` symbols: frontmatter openings may be delimited by up to 255 `-` symbols, but found {fence_length}"
82 ),
83 open_start..open_end,
84 ));
85 }
86 _ => {}
87 }
88 source.open = Some(open_start..open_end);
89 let Some(info_nl) = input.find_slice("\n") else {
90 return Err(FrontmatterError::new(
91 format!("unclosed frontmatter; expected `{fence_pattern}`"),
92 raw.len()..raw.len(),
93 )
94 .push_visible_span(open_start..open_end));
95 };
96 let info = input.next_slice(info_nl.start);
97 let info = info.strip_suffix('\r').unwrap_or(info); let info = info.trim_matches(is_horizontal_whitespace);
99 if !info.is_empty() {
100 let info_start = info.offset_from(&raw);
101 let info_end = info_start + info.len();
102 source.info = Some(info_start..info_end);
103 }
104
105 let nl_fence_pattern = format!("\n{fence_pattern}");
107 let Some(frontmatter_nl) = input.find_slice(nl_fence_pattern.as_str()) else {
108 for prefix_len in (2..=(nl_fence_pattern.len() - 1)).rev() {
109 let Some(frontmatter_nl) = input.find_slice(&nl_fence_pattern[0..prefix_len])
110 else {
111 continue;
112 };
113 let nl_len = "\n".len();
114 let close_len = prefix_len - nl_len;
115
116 let _ = input.next_slice(frontmatter_nl.start + nl_len);
117 let close_start = input.current_token_start();
118 let _ = input.next_slice(close_len);
119 let close_end = input.current_token_start();
120 let fewer_dashes = fence_length - close_len;
121 return Err(FrontmatterError::new(
122 format!(
123 "closing code fence has {fewer_dashes} less `-` than the opening fence"
124 ),
125 close_start..close_end,
126 )
127 .push_visible_span(open_start..open_end));
128 }
129 return Err(FrontmatterError::new(
130 format!("unclosed frontmatter; expected `{fence_pattern}`"),
131 raw.len()..raw.len(),
132 )
133 .push_visible_span(open_start..open_end));
134 };
135 let frontmatter_start = input.current_token_start() + 1; let _ = input.next_slice(frontmatter_nl.start + 1);
137 let frontmatter_end = input.current_token_start();
138 source.frontmatter = Some(frontmatter_start..frontmatter_end);
139 let close_start = input.current_token_start();
140 let _ = input.next_slice(fence_length);
141 let close_end = input.current_token_start();
142 source.close = Some(close_start..close_end);
143
144 let nl = input.find_slice("\n");
145 let after_closing_fence = input.next_slice(
146 nl.map(|span| span.end)
147 .unwrap_or_else(|| input.eof_offset()),
148 );
149 let content_start = input.current_token_start();
150 let extra_dashes = after_closing_fence
151 .chars()
152 .take_while(|b| *b == FENCE_CHAR)
153 .count();
154 if 0 < extra_dashes {
155 let extra_start = close_end;
156 let extra_end = extra_start + extra_dashes;
157 return Err(FrontmatterError::new(
158 format!("closing code fence has {extra_dashes} more `-` than the opening fence"),
159 extra_start..extra_end,
160 )
161 .push_visible_span(open_start..open_end));
162 } else {
163 let after_closing_fence = strip_newline(after_closing_fence);
164 let after_closing_fence = after_closing_fence.trim_matches(is_horizontal_whitespace);
165 if !after_closing_fence.is_empty() {
166 let after_start = after_closing_fence.offset_from(&raw);
168 let after_end = after_start + after_closing_fence.len();
169 return Err(FrontmatterError::new(
170 format!("unexpected characters after frontmatter close"),
171 after_start..after_end,
172 )
173 .push_visible_span(open_start..open_end));
174 }
175 }
176
177 source.content = content_start..content_end;
178
179 if let Some(nl_end) = strip_ws_lines(input.as_ref()) {
180 let _ = input.next_slice(nl_end);
181 }
182 let fence_length = input
183 .as_ref()
184 .char_indices()
185 .find_map(|(i, c)| (c != FENCE_CHAR).then_some(i))
186 .unwrap_or_else(|| input.eof_offset());
187 if 0 < fence_length {
188 let fence_start = input.current_token_start();
189 let fence_end = fence_start + fence_length;
190 return Err(FrontmatterError::new(
191 format!("only one frontmatter is supported"),
192 fence_start..fence_end,
193 )
194 .push_visible_span(open_start..open_end)
195 .push_visible_span(close_start..close_end));
196 }
197
198 Ok(source)
199 }
200
201 pub fn shebang(&self) -> Option<&'s str> {
202 self.shebang.clone().map(|span| &self.raw[span])
203 }
204
205 pub fn shebang_span(&self) -> Option<Span> {
206 self.shebang.clone()
207 }
208
209 pub fn open_span(&self) -> Option<Span> {
210 self.open.clone()
211 }
212
213 pub fn info(&self) -> Option<&'s str> {
214 self.info.clone().map(|span| &self.raw[span])
215 }
216
217 pub fn info_span(&self) -> Option<Span> {
218 self.info.clone()
219 }
220
221 pub fn frontmatter(&self) -> Option<&'s str> {
222 self.frontmatter.clone().map(|span| &self.raw[span])
223 }
224
225 pub fn frontmatter_span(&self) -> Option<Span> {
226 self.frontmatter.clone()
227 }
228
229 pub fn close_span(&self) -> Option<Span> {
230 self.close.clone()
231 }
232
233 pub fn content(&self) -> &'s str {
234 &self.raw[self.content.clone()]
235 }
236
237 pub fn content_span(&self) -> Span {
238 self.content.clone()
239 }
240}
241
242pub fn strip_shebang(input: &str) -> Option<usize> {
244 if let Some(rest) = input.strip_prefix("#!") {
249 if !rest.trim_start().starts_with('[') {
255 let newline_end = input.find('\n').map(|pos| pos + 1).unwrap_or(input.len());
257 return Some(newline_end);
258 }
259 }
260 None
261}
262
263pub fn strip_ws_lines(input: &str) -> Option<usize> {
265 let ws_end = input.find(|c| !is_whitespace(c)).unwrap_or(input.len());
266 if ws_end == 0 {
267 return None;
268 }
269
270 let nl_start = input[0..ws_end].rfind('\n')?;
271 let nl_end = nl_start + 1;
272 Some(nl_end)
273}
274
275fn is_whitespace(c: char) -> bool {
279 matches!(
285 c,
286 | '\u{000A}' | '\u{000B}' | '\u{000C}' | '\u{000D}' | '\u{0085}' | '\u{2028}' | '\u{2029}' | '\u{200E}' | '\u{200F}' | '\u{0009}' | '\u{0020}' )
303}
304
305fn is_horizontal_whitespace(c: char) -> bool {
307 matches!(
313 c,
314 '\u{0009}' | '\u{0020}' )
318}
319
320fn strip_newline(text: &str) -> &str {
321 text.strip_suffix("\r\n")
322 .or_else(|| text.strip_suffix('\n'))
323 .unwrap_or(text)
324}
325
326#[derive(Debug)]
327pub struct FrontmatterError {
328 message: String,
329 primary_span: Span,
330 visible_spans: Vec<Span>,
331}
332
333impl FrontmatterError {
334 pub fn new(message: impl Into<String>, span: Span) -> Self {
335 Self {
336 message: message.into(),
337 primary_span: span,
338 visible_spans: Vec::new(),
339 }
340 }
341
342 pub fn push_visible_span(mut self, span: Span) -> Self {
343 self.visible_spans.push(span);
344 self
345 }
346
347 pub fn message(&self) -> &str {
348 self.message.as_str()
349 }
350
351 pub fn primary_span(&self) -> Span {
352 self.primary_span.clone()
353 }
354
355 pub fn visible_spans(&self) -> &[Span] {
356 &self.visible_spans
357 }
358}
359
360impl std::fmt::Display for FrontmatterError {
361 fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 self.message.fmt(fmt)
363 }
364}
365
366impl std::error::Error for FrontmatterError {}
367
368#[cfg(test)]
369mod test {
370 use snapbox::assert_data_eq;
371 use snapbox::prelude::*;
372 use snapbox::str;
373
374 use super::*;
375
376 #[track_caller]
377 fn assert_source(source: &str, expected: impl IntoData) {
378 use std::fmt::Write as _;
379
380 let actual = match ScriptSource::parse(source) {
381 Ok(actual) => actual,
382 Err(err) => panic!("unexpected err: {err}"),
383 };
384
385 let mut rendered = String::new();
386 write_optional_field(&mut rendered, "shebang", actual.shebang());
387 write_optional_field(&mut rendered, "info", actual.info());
388 write_optional_field(&mut rendered, "frontmatter", actual.frontmatter());
389 writeln!(&mut rendered, "content: {:?}", actual.content()).unwrap();
390 assert_data_eq!(rendered, expected.raw());
391 }
392
393 fn write_optional_field(writer: &mut dyn std::fmt::Write, field: &str, value: Option<&str>) {
394 if let Some(value) = value {
395 writeln!(writer, "{field}: {value:?}").unwrap();
396 } else {
397 writeln!(writer, "{field}: None").unwrap();
398 }
399 }
400
401 #[track_caller]
402 fn assert_source_err(source: &str, expected: impl IntoData) {
403 match ScriptSource::parse(source) {
404 Ok(d) => panic!("unexpected Ok({d:#?})"),
405 Err(err) => {
406 let report = &[annotate_snippets::Level::ERROR
407 .primary_title(err.to_string())
408 .element(
409 annotate_snippets::Snippet::source(source)
410 .annotation(
411 annotate_snippets::AnnotationKind::Primary.span(err.primary_span()),
412 )
413 .annotations(err.visible_spans().iter().map(|s| {
414 annotate_snippets::AnnotationKind::Context.span(s.clone())
415 })),
416 )];
417 let renderer = annotate_snippets::Renderer::plain();
418 let actual = renderer.render(report);
419 snapbox::assert_data_eq!(actual, expected.raw())
420 }
421 }
422 }
423
424 #[test]
425 fn split_default() {
426 assert_source(
427 r#"fn main() {}
428"#,
429 str![[r#"
430shebang: None
431info: None
432frontmatter: None
433content: "fn main() {}\n"
434
435"#]],
436 );
437 }
438
439 #[test]
440 fn split_dependencies() {
441 assert_source(
442 r#"---
443[dependencies]
444time="0.1.25"
445---
446fn main() {}
447"#,
448 str![[r#"
449shebang: None
450info: None
451frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
452content: "fn main() {}\n"
453
454"#]],
455 );
456 }
457
458 #[test]
459 fn split_infostring() {
460 assert_source(
461 r#"---cargo
462[dependencies]
463time="0.1.25"
464---
465fn main() {}
466"#,
467 str![[r#"
468shebang: None
469info: "cargo"
470frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
471content: "fn main() {}\n"
472
473"#]],
474 );
475 }
476
477 #[test]
478 fn split_infostring_whitespace() {
479 assert_source(
480 "--- cargo \n\
481[dependencies]\n\
482time=\"0.1.25\"\n\
483---\n\
484fn main() {}\n\
485",
486 str![[r#"
487shebang: None
488info: "cargo"
489frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
490content: "fn main() {}\n"
491
492"#]],
493 );
494 }
495
496 #[test]
497 fn split_shebang() {
498 assert_source(
499 r#"#!/usr/bin/env cargo
500---
501[dependencies]
502time="0.1.25"
503---
504fn main() {}
505"#,
506 str![[r##"
507shebang: "#!/usr/bin/env cargo\n"
508info: None
509frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
510content: "fn main() {}\n"
511
512"##]],
513 );
514 }
515
516 #[test]
517 fn split_crlf() {
518 assert_source(
519 "#!/usr/bin/env cargo\r\n---\r\n[dependencies]\r\ntime=\"0.1.25\"\r\n---\r\nfn main() {}",
520 str![[r##"
521shebang: "#!/usr/bin/env cargo\r\n"
522info: None
523frontmatter: "[dependencies]\r\ntime=\"0.1.25\"\r\n"
524content: "fn main() {}"
525
526"##]],
527 );
528 }
529
530 #[test]
531 fn split_leading_newlines() {
532 assert_source(
533 "#!/usr/bin/env cargo\n\
534 \n\
535\n\
536\n\
537---\n\
538[dependencies]\n\
539time=\"0.1.25\"\n\
540---\n\
541\n\
542\n\
543fn main() {}\n\
544",
545 str![[r##"
546shebang: "#!/usr/bin/env cargo\n"
547info: None
548frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
549content: "\n\nfn main() {}\n"
550
551"##]],
552 );
553 }
554
555 #[test]
556 fn split_attribute() {
557 assert_source(
558 r#"#[allow(dead_code)]
559---
560[dependencies]
561time="0.1.25"
562---
563fn main() {}
564"#,
565 str![[r##"
566shebang: None
567info: None
568frontmatter: None
569content: "#[allow(dead_code)]\n---\n[dependencies]\ntime=\"0.1.25\"\n---\nfn main() {}\n"
570
571"##]],
572 );
573 }
574
575 #[test]
576 fn split_extra_dash() {
577 assert_source(
578 r#"#!/usr/bin/env cargo
579----------
580[dependencies]
581time="0.1.25"
582----------
583
584fn main() {}"#,
585 str![[r##"
586shebang: "#!/usr/bin/env cargo\n"
587info: None
588frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
589content: "\nfn main() {}"
590
591"##]],
592 );
593 }
594
595 #[test]
596 fn split_too_few_dashes() {
597 assert_source_err(
598 r#"#!/usr/bin/env cargo
599--
600[dependencies]
601time="0.1.25"
602--
603fn main() {}
604"#,
605 str![[r#"
606error: found 2 `-` in rust frontmatter, expected at least 3
607 |
6082 | --
609 | --
610...
6116 | fn main() {}
612 | ^
613"#]],
614 );
615 }
616
617 #[test]
618 fn split_indent() {
619 assert_source(
620 r#"#!/usr/bin/env cargo
621 ---
622 [dependencies]
623 time="0.1.25"
624 ----
625
626fn main() {}
627"#,
628 str![[r##"
629shebang: "#!/usr/bin/env cargo\n"
630info: None
631frontmatter: None
632content: " ---\n [dependencies]\n time=\"0.1.25\"\n ----\n\nfn main() {}\n"
633
634"##]],
635 );
636 }
637
638 #[test]
639 fn split_escaped() {
640 assert_source(
641 r#"#!/usr/bin/env cargo
642-----
643---
644---
645-----
646
647fn main() {}
648"#,
649 str![[r##"
650shebang: "#!/usr/bin/env cargo\n"
651info: None
652frontmatter: "---\n---\n"
653content: "\nfn main() {}\n"
654
655"##]],
656 );
657 }
658
659 #[test]
660 fn split_invalid_escaped() {
661 assert_source_err(
662 r#"#!/usr/bin/env cargo
663---
664-----
665-----
666---
667
668fn main() {}
669"#,
670 str![[r#"
671error: closing code fence has 2 more `-` than the opening fence
672 |
6732 | ---
674 | ---
6753 | -----
676 | ^^
677"#]],
678 );
679 }
680
681 #[test]
682 fn split_dashes_in_body() {
683 assert_source(
684 r#"#!/usr/bin/env cargo
685---
686Hello---
687World
688---
689
690fn main() {}
691"#,
692 str![[r##"
693shebang: "#!/usr/bin/env cargo\n"
694info: None
695frontmatter: "Hello---\nWorld\n"
696content: "\nfn main() {}\n"
697
698"##]],
699 );
700 }
701
702 #[test]
703 fn split_mismatched_dashes() {
704 assert_source_err(
705 r#"#!/usr/bin/env cargo
706---
707[dependencies]
708time="0.1.25"
709----
710fn main() {}
711"#,
712 str![[r#"
713error: closing code fence has 1 more `-` than the opening fence
714 |
7152 | ---
716 | ---
717...
7185 | ----
719 | ^
720"#]],
721 );
722 }
723
724 #[test]
725 fn split_missing_close() {
726 assert_source_err(
727 r#"#!/usr/bin/env cargo
728---
729[dependencies]
730time="0.1.25"
731fn main() {}
732"#,
733 str![[r#"
734error: unclosed frontmatter; expected `---`
735 |
7362 | ---
737 | ---
738...
7395 | fn main() {}
740 | ^
741"#]],
742 );
743 }
744
745 #[test]
746 fn split_fewer_dashes() {
747 assert_source_err(
748 r#"----
749[dependencies]
750--
751fn main() {}
752"#,
753 str![[r#"
754error: closing code fence has 2 less `-` than the opening fence
755 |
7561 | ----
757 | ----
7582 | [dependencies]
7593 | --
760 | ^^
761"#]],
762 );
763 }
764
765 #[test]
766 fn split_fewer_dashes_by_one() {
767 assert_source_err(
768 r#"----
769[dependencies]
770---
771fn main() {}
772"#,
773 str![[r#"
774error: closing code fence has 1 less `-` than the opening fence
775 |
7761 | ----
777 | ----
7782 | [dependencies]
7793 | ---
780 | ^^^
781"#]],
782 );
783 }
784
785 #[test]
786 fn split_fewer_dashes_before_non_ascii() {
787 assert_source_err(
789 "---
790-\u{2502}
791",
792 str![[r#"
793error: closing code fence has 2 less `-` than the opening fence
794 |
7951 | ---
796 | ---
7972 | -│
798 | ^
799"#]],
800 );
801 }
802}