cargo/util/
frontmatter.rs1use crate::CargoResult;
2
3type Span = std::ops::Range<usize>;
4
5#[derive(Debug)]
6pub struct ScriptSource<'s> {
7 raw: &'s str,
9 shebang: Option<Span>,
11 open: Option<Span>,
13 info: Option<Span>,
16 frontmatter: Option<Span>,
18 close: Option<Span>,
20 content: Span,
22}
23
24impl<'s> ScriptSource<'s> {
25 pub fn parse(raw: &'s str) -> CargoResult<Self> {
26 use winnow::stream::FindSlice as _;
27 use winnow::stream::Location as _;
28 use winnow::stream::Offset as _;
29 use winnow::stream::Stream as _;
30
31 let content_end = raw.len();
32 let mut source = Self {
33 raw,
34 shebang: None,
35 open: None,
36 info: None,
37 frontmatter: None,
38 close: None,
39 content: 0..content_end,
40 };
41
42 let mut input = winnow::stream::LocatingSlice::new(raw);
43
44 if let Some(shebang_end) = strip_shebang(input.as_ref()) {
45 let shebang_start = input.current_token_start();
46 let _ = input.next_slice(shebang_end);
47 let shebang_end = input.current_token_start();
48 source.shebang = Some(shebang_start..shebang_end);
49 source.content = shebang_end..content_end;
50 }
51
52 if let Some(nl_end) = strip_ws_lines(input.as_ref()) {
54 let _ = input.next_slice(nl_end);
55 }
56
57 const FENCE_CHAR: char = '-';
59 let fence_length = input
60 .as_ref()
61 .char_indices()
62 .find_map(|(i, c)| (c != FENCE_CHAR).then_some(i))
63 .unwrap_or_else(|| input.eof_offset());
64 match fence_length {
65 0 => {
66 return Ok(source);
67 }
68 1 | 2 => {
69 anyhow::bail!(
71 "found {fence_length} `{FENCE_CHAR}` in rust frontmatter, expected at least 3"
72 )
73 }
74 _ => {}
75 }
76 let open_start = input.current_token_start();
77 let fence_pattern = input.next_slice(fence_length);
78 let open_end = input.current_token_start();
79 source.open = Some(open_start..open_end);
80 let Some(info_nl) = input.find_slice("\n") else {
81 anyhow::bail!("no closing `{fence_pattern}` found for frontmatter");
82 };
83 let info = input.next_slice(info_nl.start);
84 let info = info.trim_matches(is_whitespace);
85 if !info.is_empty() {
86 let info_start = info.offset_from(&raw);
87 let info_end = info_start + info.len();
88 source.info = Some(info_start..info_end);
89 }
90
91 let nl_fence_pattern = format!("\n{fence_pattern}");
93 let Some(frontmatter_nl) = input.find_slice(nl_fence_pattern.as_str()) else {
94 anyhow::bail!("no closing `{fence_pattern}` found for frontmatter");
95 };
96 let frontmatter_start = input.current_token_start() + 1; let _ = input.next_slice(frontmatter_nl.start + 1);
98 let frontmatter_end = input.current_token_start();
99 source.frontmatter = Some(frontmatter_start..frontmatter_end);
100 let close_start = input.current_token_start();
101 let _ = input.next_slice(fence_length);
102 let close_end = input.current_token_start();
103 source.close = Some(close_start..close_end);
104
105 let nl = input.find_slice("\n");
106 let after_closing_fence = input.next_slice(
107 nl.map(|span| span.end)
108 .unwrap_or_else(|| input.eof_offset()),
109 );
110 let content_start = input.current_token_start();
111 let after_closing_fence = after_closing_fence.trim_matches(is_whitespace);
112 if !after_closing_fence.is_empty() {
113 anyhow::bail!("trailing characters found after frontmatter close");
115 }
116
117 source.content = content_start..content_end;
118
119 let repeat = Self::parse(source.content())?;
120 if repeat.frontmatter.is_some() {
121 anyhow::bail!("only one frontmatter is supported");
122 }
123
124 Ok(source)
125 }
126
127 pub fn shebang(&self) -> Option<&'s str> {
128 self.shebang.clone().map(|span| &self.raw[span])
129 }
130
131 pub fn shebang_span(&self) -> Option<Span> {
132 self.shebang.clone()
133 }
134
135 pub fn open_span(&self) -> Option<Span> {
136 self.open.clone()
137 }
138
139 pub fn info(&self) -> Option<&'s str> {
140 self.info.clone().map(|span| &self.raw[span])
141 }
142
143 pub fn info_span(&self) -> Option<Span> {
144 self.info.clone()
145 }
146
147 pub fn frontmatter(&self) -> Option<&'s str> {
148 self.frontmatter.clone().map(|span| &self.raw[span])
149 }
150
151 pub fn frontmatter_span(&self) -> Option<Span> {
152 self.frontmatter.clone()
153 }
154
155 pub fn close_span(&self) -> Option<Span> {
156 self.close.clone()
157 }
158
159 pub fn content(&self) -> &'s str {
160 &self.raw[self.content.clone()]
161 }
162
163 pub fn content_span(&self) -> Span {
164 self.content.clone()
165 }
166}
167
168pub fn strip_shebang(input: &str) -> Option<usize> {
170 if let Some(rest) = input.strip_prefix("#!") {
175 if !rest.trim_start().starts_with('[') {
181 let newline_end = input.find('\n').map(|pos| pos + 1).unwrap_or(input.len());
183 return Some(newline_end);
184 }
185 }
186 None
187}
188
189pub fn strip_ws_lines(input: &str) -> Option<usize> {
191 let ws_end = input.find(|c| !is_whitespace(c)).unwrap_or(input.len());
192 if ws_end == 0 {
193 return None;
194 }
195
196 let nl_start = input[0..ws_end].rfind('\n')?;
197 let nl_end = nl_start + 1;
198 Some(nl_end)
199}
200
201fn is_whitespace(c: char) -> bool {
207 matches!(
213 c,
214 '\u{0009}' | '\u{000A}' | '\u{000B}' | '\u{000C}' | '\u{000D}' | '\u{0020}' | '\u{0085}'
224
225 | '\u{200E}' | '\u{200F}' | '\u{2028}' | '\u{2029}' )
233}
234
235#[cfg(test)]
236mod test {
237 use snapbox::assert_data_eq;
238 use snapbox::prelude::*;
239 use snapbox::str;
240
241 use super::*;
242
243 #[track_caller]
244 fn assert_source(source: &str, expected: impl IntoData) {
245 use std::fmt::Write as _;
246
247 let actual = match ScriptSource::parse(source) {
248 Ok(actual) => actual,
249 Err(err) => panic!("unexpected err: {err}"),
250 };
251
252 let mut rendered = String::new();
253 write_optional_field(&mut rendered, "shebang", actual.shebang());
254 write_optional_field(&mut rendered, "info", actual.info());
255 write_optional_field(&mut rendered, "frontmatter", actual.frontmatter());
256 writeln!(&mut rendered, "content: {:?}", actual.content()).unwrap();
257 assert_data_eq!(rendered, expected.raw());
258 }
259
260 fn write_optional_field(writer: &mut dyn std::fmt::Write, field: &str, value: Option<&str>) {
261 if let Some(value) = value {
262 writeln!(writer, "{field}: {value:?}").unwrap();
263 } else {
264 writeln!(writer, "{field}: None").unwrap();
265 }
266 }
267
268 #[track_caller]
269 fn assert_err(
270 result: Result<impl std::fmt::Debug, impl std::fmt::Display>,
271 err: impl IntoData,
272 ) {
273 match result {
274 Ok(d) => panic!("unexpected Ok({d:#?})"),
275 Err(actual) => snapbox::assert_data_eq!(actual.to_string(), err.raw()),
276 }
277 }
278
279 #[test]
280 fn split_default() {
281 assert_source(
282 r#"fn main() {}
283"#,
284 str![[r#"
285shebang: None
286info: None
287frontmatter: None
288content: "fn main() {}\n"
289
290"#]],
291 );
292 }
293
294 #[test]
295 fn split_dependencies() {
296 assert_source(
297 r#"---
298[dependencies]
299time="0.1.25"
300---
301fn main() {}
302"#,
303 str![[r#"
304shebang: None
305info: None
306frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
307content: "fn main() {}\n"
308
309"#]],
310 );
311 }
312
313 #[test]
314 fn split_infostring() {
315 assert_source(
316 r#"---cargo
317[dependencies]
318time="0.1.25"
319---
320fn main() {}
321"#,
322 str![[r#"
323shebang: None
324info: "cargo"
325frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
326content: "fn main() {}\n"
327
328"#]],
329 );
330 }
331
332 #[test]
333 fn split_infostring_whitespace() {
334 assert_source(
335 r#"--- cargo
336[dependencies]
337time="0.1.25"
338---
339fn main() {}
340"#,
341 str![[r#"
342shebang: None
343info: "cargo"
344frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
345content: "fn main() {}\n"
346
347"#]],
348 );
349 }
350
351 #[test]
352 fn split_shebang() {
353 assert_source(
354 r#"#!/usr/bin/env cargo
355---
356[dependencies]
357time="0.1.25"
358---
359fn main() {}
360"#,
361 str![[r##"
362shebang: "#!/usr/bin/env cargo\n"
363info: None
364frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
365content: "fn main() {}\n"
366
367"##]],
368 );
369 }
370
371 #[test]
372 fn split_crlf() {
373 assert_source(
374 "#!/usr/bin/env cargo\r\n---\r\n[dependencies]\r\ntime=\"0.1.25\"\r\n---\r\nfn main() {}",
375 str![[r##"
376shebang: "#!/usr/bin/env cargo\r\n"
377info: None
378frontmatter: "[dependencies]\r\ntime=\"0.1.25\"\r\n"
379content: "fn main() {}"
380
381"##]],
382 );
383 }
384
385 #[test]
386 fn split_leading_newlines() {
387 assert_source(
388 r#"#!/usr/bin/env cargo
389
390
391
392---
393[dependencies]
394time="0.1.25"
395---
396
397
398fn main() {}
399"#,
400 str![[r##"
401shebang: "#!/usr/bin/env cargo\n"
402info: None
403frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
404content: "\n\nfn main() {}\n"
405
406"##]],
407 );
408 }
409
410 #[test]
411 fn split_attribute() {
412 assert_source(
413 r#"#[allow(dead_code)]
414---
415[dependencies]
416time="0.1.25"
417---
418fn main() {}
419"#,
420 str![[r##"
421shebang: None
422info: None
423frontmatter: None
424content: "#[allow(dead_code)]\n---\n[dependencies]\ntime=\"0.1.25\"\n---\nfn main() {}\n"
425
426"##]],
427 );
428 }
429
430 #[test]
431 fn split_extra_dash() {
432 assert_source(
433 r#"#!/usr/bin/env cargo
434----------
435[dependencies]
436time="0.1.25"
437----------
438
439fn main() {}"#,
440 str![[r##"
441shebang: "#!/usr/bin/env cargo\n"
442info: None
443frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
444content: "\nfn main() {}"
445
446"##]],
447 );
448 }
449
450 #[test]
451 fn split_too_few_dashes() {
452 assert_err(
453 ScriptSource::parse(
454 r#"#!/usr/bin/env cargo
455--
456[dependencies]
457time="0.1.25"
458--
459fn main() {}
460"#,
461 ),
462 str!["found 2 `-` in rust frontmatter, expected at least 3"],
463 );
464 }
465
466 #[test]
467 fn split_indent() {
468 assert_source(
469 r#"#!/usr/bin/env cargo
470 ---
471 [dependencies]
472 time="0.1.25"
473 ----
474
475fn main() {}
476"#,
477 str![[r##"
478shebang: "#!/usr/bin/env cargo\n"
479info: None
480frontmatter: None
481content: " ---\n [dependencies]\n time=\"0.1.25\"\n ----\n\nfn main() {}\n"
482
483"##]],
484 );
485 }
486
487 #[test]
488 fn split_escaped() {
489 assert_source(
490 r#"#!/usr/bin/env cargo
491-----
492---
493---
494-----
495
496fn main() {}
497"#,
498 str![[r##"
499shebang: "#!/usr/bin/env cargo\n"
500info: None
501frontmatter: "---\n---\n"
502content: "\nfn main() {}\n"
503
504"##]],
505 );
506 }
507
508 #[test]
509 fn split_invalid_escaped() {
510 assert_err(
511 ScriptSource::parse(
512 r#"#!/usr/bin/env cargo
513---
514-----
515-----
516---
517
518fn main() {}
519"#,
520 ),
521 str!["trailing characters found after frontmatter close"],
522 );
523 }
524
525 #[test]
526 fn split_dashes_in_body() {
527 assert_source(
528 r#"#!/usr/bin/env cargo
529---
530Hello---
531World
532---
533
534fn main() {}
535"#,
536 str![[r##"
537shebang: "#!/usr/bin/env cargo\n"
538info: None
539frontmatter: "Hello---\nWorld\n"
540content: "\nfn main() {}\n"
541
542"##]],
543 );
544 }
545
546 #[test]
547 fn split_mismatched_dashes() {
548 assert_err(
549 ScriptSource::parse(
550 r#"#!/usr/bin/env cargo
551---
552[dependencies]
553time="0.1.25"
554----
555fn main() {}
556"#,
557 ),
558 str!["trailing characters found after frontmatter close"],
559 );
560 }
561
562 #[test]
563 fn split_missing_close() {
564 assert_err(
565 ScriptSource::parse(
566 r#"#!/usr/bin/env cargo
567---
568[dependencies]
569time="0.1.25"
570fn main() {}
571"#,
572 ),
573 str!["no closing `---` found for frontmatter"],
574 );
575 }
576}