cargo/util/toml/
embedded.rs

1use anyhow::Context as _;
2
3use cargo_util_schemas::manifest::PackageName;
4
5use crate::util::restricted_names;
6use crate::CargoResult;
7use crate::GlobalContext;
8
9pub(super) fn expand_manifest(
10    content: &str,
11    path: &std::path::Path,
12    gctx: &GlobalContext,
13) -> CargoResult<String> {
14    let source = ScriptSource::parse(content)?;
15    if let Some(frontmatter) = source.frontmatter() {
16        match source.info() {
17            Some("cargo") | None => {}
18            Some(other) => {
19                if let Some(remainder) = other.strip_prefix("cargo,") {
20                    anyhow::bail!("cargo does not support frontmatter infostring attributes like `{remainder}` at this time")
21                } else {
22                    anyhow::bail!("frontmatter infostring `{other}` is unsupported by cargo; specify `cargo` for embedding a manifest")
23                }
24            }
25        }
26
27        // HACK: until rustc has native support for this syntax, we have to remove it from the
28        // source file
29        use std::fmt::Write as _;
30        let hash = crate::util::hex::short_hash(&path.to_string_lossy());
31        let mut rel_path = std::path::PathBuf::new();
32        rel_path.push("target");
33        rel_path.push(&hash[0..2]);
34        rel_path.push(&hash[2..]);
35        let target_dir = gctx.home().join(rel_path);
36        let hacked_path = target_dir
37            .join(
38                path.file_name()
39                    .expect("always a name for embedded manifests"),
40            )
41            .into_path_unlocked();
42        let mut hacked_source = String::new();
43        if let Some(shebang) = source.shebang() {
44            writeln!(hacked_source, "{shebang}")?;
45        }
46        writeln!(hacked_source)?; // open
47        for _ in 0..frontmatter.lines().count() {
48            writeln!(hacked_source)?;
49        }
50        writeln!(hacked_source)?; // close
51        writeln!(hacked_source, "{}", source.content())?;
52        if let Some(parent) = hacked_path.parent() {
53            cargo_util::paths::create_dir_all(parent)?;
54        }
55        cargo_util::paths::write_if_changed(&hacked_path, hacked_source)?;
56
57        let manifest = inject_bin_path(&frontmatter, &hacked_path)
58            .with_context(|| format!("failed to parse manifest at `{}`", path.display()))?;
59        let manifest = toml::to_string_pretty(&manifest)?;
60        Ok(manifest)
61    } else {
62        let frontmatter = "";
63        let manifest = inject_bin_path(frontmatter, path)
64            .with_context(|| format!("failed to parse manifest at `{}`", path.display()))?;
65        let manifest = toml::to_string_pretty(&manifest)?;
66        Ok(manifest)
67    }
68}
69
70/// HACK: Add a `[[bin]]` table to the `original_toml`
71fn inject_bin_path(manifest: &str, path: &std::path::Path) -> CargoResult<toml::Table> {
72    let mut manifest: toml::Table = toml::from_str(&manifest)?;
73
74    let bin_path = path.to_string_lossy().into_owned();
75    let file_stem = path
76        .file_stem()
77        .ok_or_else(|| anyhow::format_err!("no file name"))?
78        .to_string_lossy();
79    let name = sanitize_name(file_stem.as_ref());
80    let bin_name = name.clone();
81
82    let mut bin = toml::Table::new();
83    bin.insert("name".to_owned(), toml::Value::String(bin_name));
84    bin.insert("path".to_owned(), toml::Value::String(bin_path));
85    manifest
86        .entry("bin")
87        .or_insert_with(|| Vec::<toml::Value>::new().into())
88        .as_array_mut()
89        .ok_or_else(|| anyhow::format_err!("`bin` must be an array"))?
90        .push(toml::Value::Table(bin));
91
92    Ok(manifest)
93}
94
95/// Ensure the package name matches the validation from `ops::cargo_new::check_name`
96pub fn sanitize_name(name: &str) -> String {
97    let placeholder = if name.contains('_') {
98        '_'
99    } else {
100        // Since embedded manifests only support `[[bin]]`s, prefer arrow-case as that is the
101        // more common convention for CLIs
102        '-'
103    };
104
105    let mut name = PackageName::sanitize(name, placeholder).into_inner();
106
107    loop {
108        if restricted_names::is_keyword(&name) {
109            name.push(placeholder);
110        } else if restricted_names::is_conflicting_artifact_name(&name) {
111            // Being an embedded manifest, we always assume it is a `[[bin]]`
112            name.push(placeholder);
113        } else if name == "test" {
114            name.push(placeholder);
115        } else if restricted_names::is_windows_reserved(&name) {
116            // Go ahead and be consistent across platforms
117            name.push(placeholder);
118        } else {
119            break;
120        }
121    }
122
123    name
124}
125
126#[derive(Debug)]
127pub struct ScriptSource<'s> {
128    shebang: Option<&'s str>,
129    info: Option<&'s str>,
130    frontmatter: Option<&'s str>,
131    content: &'s str,
132}
133
134impl<'s> ScriptSource<'s> {
135    pub fn parse(input: &'s str) -> CargoResult<Self> {
136        let mut source = Self {
137            shebang: None,
138            info: None,
139            frontmatter: None,
140            content: input,
141        };
142
143        if let Some(shebang_end) = strip_shebang(source.content) {
144            let (shebang, content) = source.content.split_at(shebang_end);
145            source.shebang = Some(shebang);
146            source.content = content;
147        }
148
149        const FENCE_CHAR: char = '-';
150
151        let mut rest = source.content;
152        while !rest.is_empty() {
153            let without_spaces = rest.trim_start_matches([' ', '\t']);
154            let without_nl = without_spaces.trim_start_matches(['\r', '\n']);
155            if without_nl == rest {
156                // nothing trimmed
157                break;
158            } else if without_nl == without_spaces {
159                // frontmatter must come after a newline
160                return Ok(source);
161            }
162            rest = without_nl;
163        }
164        let fence_end = rest
165            .char_indices()
166            .find_map(|(i, c)| (c != FENCE_CHAR).then_some(i))
167            .unwrap_or(source.content.len());
168        let (fence_pattern, rest) = match fence_end {
169            0 => {
170                return Ok(source);
171            }
172            1 | 2 => {
173                anyhow::bail!(
174                    "found {fence_end} `{FENCE_CHAR}` in rust frontmatter, expected at least 3"
175                )
176            }
177            _ => rest.split_at(fence_end),
178        };
179        let nl_fence_pattern = format!("\n{fence_pattern}");
180        let (info, content) = rest.split_once("\n").unwrap_or((rest, ""));
181        let info = info.trim();
182        if !info.is_empty() {
183            source.info = Some(info);
184        }
185        source.content = content;
186
187        let Some(frontmatter_nl) = source.content.find(&nl_fence_pattern) else {
188            anyhow::bail!("no closing `{fence_pattern}` found for frontmatter");
189        };
190        source.frontmatter = Some(&source.content[..frontmatter_nl + 1]);
191        source.content = &source.content[frontmatter_nl + nl_fence_pattern.len()..];
192
193        let (line, content) = source
194            .content
195            .split_once("\n")
196            .unwrap_or((source.content, ""));
197        let line = line.trim();
198        if !line.is_empty() {
199            anyhow::bail!("unexpected trailing content on closing fence: `{line}`");
200        }
201        source.content = content;
202
203        Ok(source)
204    }
205
206    pub fn shebang(&self) -> Option<&'s str> {
207        self.shebang
208    }
209
210    pub fn info(&self) -> Option<&'s str> {
211        self.info
212    }
213
214    pub fn frontmatter(&self) -> Option<&'s str> {
215        self.frontmatter
216    }
217
218    pub fn content(&self) -> &'s str {
219        self.content
220    }
221}
222
223fn strip_shebang(input: &str) -> Option<usize> {
224    // See rust-lang/rust's compiler/rustc_lexer/src/lib.rs's `strip_shebang`
225    // Shebang must start with `#!` literally, without any preceding whitespace.
226    // For simplicity we consider any line starting with `#!` a shebang,
227    // regardless of restrictions put on shebangs by specific platforms.
228    if let Some(rest) = input.strip_prefix("#!") {
229        // Ok, this is a shebang but if the next non-whitespace token is `[`,
230        // then it may be valid Rust code, so consider it Rust code.
231        //
232        // NOTE: rustc considers line and block comments to be whitespace but to avoid
233        // any more awareness of Rust grammar, we are excluding it.
234        if !rest.trim_start().starts_with('[') {
235            // No other choice than to consider this a shebang.
236            let newline_end = input.find('\n').map(|pos| pos + 1).unwrap_or(input.len());
237            return Some(newline_end);
238        }
239    }
240    None
241}
242
243#[cfg(test)]
244mod test_expand {
245    use snapbox::assert_data_eq;
246    use snapbox::prelude::*;
247    use snapbox::str;
248
249    use super::*;
250
251    #[track_caller]
252    fn assert_source(source: &str, expected: impl IntoData) {
253        use std::fmt::Write as _;
254
255        let actual = match ScriptSource::parse(source) {
256            Ok(actual) => actual,
257            Err(err) => panic!("unexpected err: {err}"),
258        };
259
260        let mut rendered = String::new();
261        write_optional_field(&mut rendered, "shebang", actual.shebang());
262        write_optional_field(&mut rendered, "info", actual.info());
263        write_optional_field(&mut rendered, "frontmatter", actual.frontmatter());
264        writeln!(&mut rendered, "content: {:?}", actual.content()).unwrap();
265        assert_data_eq!(rendered, expected.raw());
266    }
267
268    fn write_optional_field(writer: &mut dyn std::fmt::Write, field: &str, value: Option<&str>) {
269        if let Some(value) = value {
270            writeln!(writer, "{field}: {value:?}").unwrap();
271        } else {
272            writeln!(writer, "{field}: None").unwrap();
273        }
274    }
275
276    #[track_caller]
277    fn assert_err(
278        result: Result<impl std::fmt::Debug, impl std::fmt::Display>,
279        err: impl IntoData,
280    ) {
281        match result {
282            Ok(d) => panic!("unexpected Ok({d:#?})"),
283            Err(actual) => snapbox::assert_data_eq!(actual.to_string(), err.raw()),
284        }
285    }
286
287    #[test]
288    fn split_default() {
289        assert_source(
290            r#"fn main() {}
291"#,
292            str![[r#"
293shebang: None
294info: None
295frontmatter: None
296content: "fn main() {}\n"
297
298"#]],
299        );
300    }
301
302    #[test]
303    fn split_dependencies() {
304        assert_source(
305            r#"---
306[dependencies]
307time="0.1.25"
308---
309fn main() {}
310"#,
311            str![[r#"
312shebang: None
313info: None
314frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
315content: "fn main() {}\n"
316
317"#]],
318        );
319    }
320
321    #[test]
322    fn split_infostring() {
323        assert_source(
324            r#"---cargo
325[dependencies]
326time="0.1.25"
327---
328fn main() {}
329"#,
330            str![[r#"
331shebang: None
332info: "cargo"
333frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
334content: "fn main() {}\n"
335
336"#]],
337        );
338    }
339
340    #[test]
341    fn split_infostring_whitespace() {
342        assert_source(
343            r#"--- cargo 
344[dependencies]
345time="0.1.25"
346---
347fn main() {}
348"#,
349            str![[r#"
350shebang: None
351info: "cargo"
352frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
353content: "fn main() {}\n"
354
355"#]],
356        );
357    }
358
359    #[test]
360    fn split_shebang() {
361        assert_source(
362            r#"#!/usr/bin/env cargo
363---
364[dependencies]
365time="0.1.25"
366---
367fn main() {}
368"#,
369            str![[r##"
370shebang: "#!/usr/bin/env cargo\n"
371info: None
372frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
373content: "fn main() {}\n"
374
375"##]],
376        );
377    }
378
379    #[test]
380    fn split_crlf() {
381        assert_source(
382                "#!/usr/bin/env cargo\r\n---\r\n[dependencies]\r\ntime=\"0.1.25\"\r\n---\r\nfn main() {}",
383            str![[r##"
384shebang: "#!/usr/bin/env cargo\r\n"
385info: None
386frontmatter: "[dependencies]\r\ntime=\"0.1.25\"\r\n"
387content: "fn main() {}"
388
389"##]]
390        );
391    }
392
393    #[test]
394    fn split_leading_newlines() {
395        assert_source(
396            r#"#!/usr/bin/env cargo
397    
398
399
400---
401[dependencies]
402time="0.1.25"
403---
404
405
406fn main() {}
407"#,
408            str![[r##"
409shebang: "#!/usr/bin/env cargo\n"
410info: None
411frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
412content: "\n\nfn main() {}\n"
413
414"##]],
415        );
416    }
417
418    #[test]
419    fn split_attribute() {
420        assert_source(
421            r#"#[allow(dead_code)]
422---
423[dependencies]
424time="0.1.25"
425---
426fn main() {}
427"#,
428            str![[r##"
429shebang: None
430info: None
431frontmatter: None
432content: "#[allow(dead_code)]\n---\n[dependencies]\ntime=\"0.1.25\"\n---\nfn main() {}\n"
433
434"##]],
435        );
436    }
437
438    #[test]
439    fn split_extra_dash() {
440        assert_source(
441            r#"#!/usr/bin/env cargo
442----------
443[dependencies]
444time="0.1.25"
445----------
446
447fn main() {}"#,
448            str![[r##"
449shebang: "#!/usr/bin/env cargo\n"
450info: None
451frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
452content: "\nfn main() {}"
453
454"##]],
455        );
456    }
457
458    #[test]
459    fn split_too_few_dashes() {
460        assert_err(
461            ScriptSource::parse(
462                r#"#!/usr/bin/env cargo
463--
464[dependencies]
465time="0.1.25"
466--
467fn main() {}
468"#,
469            ),
470            str!["found 2 `-` in rust frontmatter, expected at least 3"],
471        );
472    }
473
474    #[test]
475    fn split_indent() {
476        assert_source(
477            r#"#!/usr/bin/env cargo
478    ---
479    [dependencies]
480    time="0.1.25"
481    ----
482
483fn main() {}
484"#,
485            str![[r##"
486shebang: "#!/usr/bin/env cargo\n"
487info: None
488frontmatter: None
489content: "    ---\n    [dependencies]\n    time=\"0.1.25\"\n    ----\n\nfn main() {}\n"
490
491"##]],
492        );
493    }
494
495    #[test]
496    fn split_escaped() {
497        assert_source(
498            r#"#!/usr/bin/env cargo
499-----
500---
501---
502-----
503
504fn main() {}
505"#,
506            str![[r##"
507shebang: "#!/usr/bin/env cargo\n"
508info: None
509frontmatter: "---\n---\n"
510content: "\nfn main() {}\n"
511
512"##]],
513        );
514    }
515
516    #[test]
517    fn split_invalid_escaped() {
518        assert_err(
519            ScriptSource::parse(
520                r#"#!/usr/bin/env cargo
521---
522-----
523-----
524---
525
526fn main() {}
527"#,
528            ),
529            str!["unexpected trailing content on closing fence: `--`"],
530        );
531    }
532
533    #[test]
534    fn split_dashes_in_body() {
535        assert_source(
536            r#"#!/usr/bin/env cargo
537---
538Hello---
539World
540---
541
542fn main() {}
543"#,
544            str![[r##"
545shebang: "#!/usr/bin/env cargo\n"
546info: None
547frontmatter: "Hello---\nWorld\n"
548content: "\nfn main() {}\n"
549
550"##]],
551        );
552    }
553
554    #[test]
555    fn split_mismatched_dashes() {
556        assert_err(
557            ScriptSource::parse(
558                r#"#!/usr/bin/env cargo
559---
560[dependencies]
561time="0.1.25"
562----
563fn main() {}
564"#,
565            ),
566            str!["unexpected trailing content on closing fence: `-`"],
567        );
568    }
569
570    #[test]
571    fn split_missing_close() {
572        assert_err(
573            ScriptSource::parse(
574                r#"#!/usr/bin/env cargo
575---
576[dependencies]
577time="0.1.25"
578fn main() {}
579"#,
580            ),
581            str!["no closing `---` found for frontmatter"],
582        );
583    }
584
585    #[track_caller]
586    fn expand(source: &str) -> String {
587        let shell = crate::Shell::from_write(Box::new(Vec::new()));
588        let cwd = std::env::current_dir().unwrap();
589        let home = home::cargo_home_with_cwd(&cwd).unwrap();
590        let gctx = GlobalContext::new(shell, cwd, home);
591        expand_manifest(source, std::path::Path::new("/home/me/test.rs"), &gctx)
592            .unwrap_or_else(|err| panic!("{}", err))
593    }
594
595    #[test]
596    fn expand_default() {
597        assert_data_eq!(
598            expand(r#"fn main() {}"#),
599            str![[r#"
600[[bin]]
601name = "test-"
602path = "/home/me/test.rs"
603
604"#]]
605        );
606    }
607
608    #[test]
609    fn expand_dependencies() {
610        assert_data_eq!(
611            expand(
612                r#"---cargo
613[dependencies]
614time="0.1.25"
615---
616fn main() {}
617"#
618            ),
619            str![[r#"
620[[bin]]
621name = "test-"
622path = [..]
623
624[dependencies]
625time = "0.1.25"
626
627"#]]
628        );
629    }
630}