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        // See rust-lang/rust's compiler/rustc_lexer/src/lib.rs's `strip_shebang`
144        // Shebang must start with `#!` literally, without any preceding whitespace.
145        // For simplicity we consider any line starting with `#!` a shebang,
146        // regardless of restrictions put on shebangs by specific platforms.
147        if let Some(rest) = source.content.strip_prefix("#!") {
148            // Ok, this is a shebang but if the next non-whitespace token is `[`,
149            // then it may be valid Rust code, so consider it Rust code.
150            //
151            // NOTE: rustc considers line and block comments to be whitespace but to avoid
152            // any more awareness of Rust grammar, we are excluding it.
153            if rest.trim_start().starts_with('[') {
154                return Ok(source);
155            }
156
157            // No other choice than to consider this a shebang.
158            let newline_end = source
159                .content
160                .find('\n')
161                .map(|pos| pos + 1)
162                .unwrap_or(source.content.len());
163            let (shebang, content) = source.content.split_at(newline_end);
164            source.shebang = Some(shebang);
165            source.content = content;
166        }
167
168        const FENCE_CHAR: char = '-';
169
170        let mut trimmed_content = source.content;
171        while !trimmed_content.is_empty() {
172            let c = trimmed_content;
173            let c = c.trim_start_matches([' ', '\t']);
174            let c = c.trim_start_matches(['\r', '\n']);
175            if c == trimmed_content {
176                break;
177            }
178            trimmed_content = c;
179        }
180        let fence_end = trimmed_content
181            .char_indices()
182            .find_map(|(i, c)| (c != FENCE_CHAR).then_some(i))
183            .unwrap_or(source.content.len());
184        let (fence_pattern, rest) = match fence_end {
185            0 => {
186                return Ok(source);
187            }
188            1 | 2 => {
189                anyhow::bail!(
190                    "found {fence_end} `{FENCE_CHAR}` in rust frontmatter, expected at least 3"
191                )
192            }
193            _ => trimmed_content.split_at(fence_end),
194        };
195        let (info, content) = rest.split_once("\n").unwrap_or((rest, ""));
196        let info = info.trim();
197        if !info.is_empty() {
198            source.info = Some(info);
199        }
200        source.content = content;
201
202        let Some((frontmatter, content)) = source.content.split_once(fence_pattern) else {
203            anyhow::bail!("no closing `{fence_pattern}` found for frontmatter");
204        };
205        source.frontmatter = Some(frontmatter);
206        source.content = content;
207
208        let (line, content) = source
209            .content
210            .split_once("\n")
211            .unwrap_or((source.content, ""));
212        let line = line.trim();
213        if !line.is_empty() {
214            anyhow::bail!("unexpected trailing content on closing fence: `{line}`");
215        }
216        source.content = content;
217
218        Ok(source)
219    }
220
221    pub fn shebang(&self) -> Option<&'s str> {
222        self.shebang
223    }
224
225    pub fn info(&self) -> Option<&'s str> {
226        self.info
227    }
228
229    pub fn frontmatter(&self) -> Option<&'s str> {
230        self.frontmatter
231    }
232
233    pub fn content(&self) -> &'s str {
234        self.content
235    }
236}
237
238#[cfg(test)]
239mod test_expand {
240    use snapbox::assert_data_eq;
241    use snapbox::prelude::*;
242    use snapbox::str;
243
244    use super::*;
245
246    #[track_caller]
247    fn assert_source(source: &str, expected: impl IntoData) {
248        use std::fmt::Write as _;
249
250        let actual = match ScriptSource::parse(source) {
251            Ok(actual) => actual,
252            Err(err) => panic!("unexpected err: {err}"),
253        };
254
255        let mut rendered = String::new();
256        write_optional_field(&mut rendered, "shebang", actual.shebang());
257        write_optional_field(&mut rendered, "info", actual.info());
258        write_optional_field(&mut rendered, "frontmatter", actual.frontmatter());
259        writeln!(&mut rendered, "content: {:?}", actual.content()).unwrap();
260        assert_data_eq!(rendered, expected.raw());
261    }
262
263    fn write_optional_field(writer: &mut dyn std::fmt::Write, field: &str, value: Option<&str>) {
264        if let Some(value) = value {
265            writeln!(writer, "{field}: {value:?}").unwrap();
266        } else {
267            writeln!(writer, "{field}: None").unwrap();
268        }
269    }
270
271    #[track_caller]
272    fn assert_err(
273        result: Result<impl std::fmt::Debug, impl std::fmt::Display>,
274        err: impl IntoData,
275    ) {
276        match result {
277            Ok(d) => panic!("unexpected Ok({d:#?})"),
278            Err(actual) => snapbox::assert_data_eq!(actual.to_string(), err.raw()),
279        }
280    }
281
282    #[test]
283    fn split_default() {
284        assert_source(
285            r#"fn main() {}
286"#,
287            str![[r#"
288shebang: None
289info: None
290frontmatter: None
291content: "fn main() {}\n"
292
293"#]],
294        );
295    }
296
297    #[test]
298    fn split_dependencies() {
299        assert_source(
300            r#"---
301[dependencies]
302time="0.1.25"
303---
304fn main() {}
305"#,
306            str![[r#"
307shebang: None
308info: None
309frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
310content: "fn main() {}\n"
311
312"#]],
313        );
314    }
315
316    #[test]
317    fn split_infostring() {
318        assert_source(
319            r#"---cargo
320[dependencies]
321time="0.1.25"
322---
323fn main() {}
324"#,
325            str![[r#"
326shebang: None
327info: "cargo"
328frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
329content: "fn main() {}\n"
330
331"#]],
332        );
333    }
334
335    #[test]
336    fn split_infostring_whitespace() {
337        assert_source(
338            r#"--- cargo 
339[dependencies]
340time="0.1.25"
341---
342fn main() {}
343"#,
344            str![[r#"
345shebang: None
346info: "cargo"
347frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
348content: "fn main() {}\n"
349
350"#]],
351        );
352    }
353
354    #[test]
355    fn split_shebang() {
356        assert_source(
357            r#"#!/usr/bin/env cargo
358---
359[dependencies]
360time="0.1.25"
361---
362fn main() {}
363"#,
364            str![[r##"
365shebang: "#!/usr/bin/env cargo\n"
366info: None
367frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
368content: "fn main() {}\n"
369
370"##]],
371        );
372    }
373
374    #[test]
375    fn split_crlf() {
376        assert_source(
377                "#!/usr/bin/env cargo\r\n---\r\n[dependencies]\r\ntime=\"0.1.25\"\r\n---\r\nfn main() {}",
378            str![[r##"
379shebang: "#!/usr/bin/env cargo\r\n"
380info: None
381frontmatter: "[dependencies]\r\ntime=\"0.1.25\"\r\n"
382content: "fn main() {}"
383
384"##]]
385        );
386    }
387
388    #[test]
389    fn split_leading_newlines() {
390        assert_source(
391            r#"#!/usr/bin/env cargo
392    
393
394
395---
396[dependencies]
397time="0.1.25"
398---
399
400
401fn main() {}
402"#,
403            str![[r##"
404shebang: "#!/usr/bin/env cargo\n"
405info: None
406frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
407content: "\n\nfn main() {}\n"
408
409"##]],
410        );
411    }
412
413    #[test]
414    fn split_attribute() {
415        assert_source(
416            r#"#[allow(dead_code)]
417---
418[dependencies]
419time="0.1.25"
420---
421fn main() {}
422"#,
423            str![[r##"
424shebang: None
425info: None
426frontmatter: None
427content: "#[allow(dead_code)]\n---\n[dependencies]\ntime=\"0.1.25\"\n---\nfn main() {}\n"
428
429"##]],
430        );
431    }
432
433    #[test]
434    fn split_extra_dash() {
435        assert_source(
436            r#"#!/usr/bin/env cargo
437----------
438[dependencies]
439time="0.1.25"
440----------
441
442fn main() {}"#,
443            str![[r##"
444shebang: "#!/usr/bin/env cargo\n"
445info: None
446frontmatter: "[dependencies]\ntime=\"0.1.25\"\n"
447content: "\nfn main() {}"
448
449"##]],
450        );
451    }
452
453    #[test]
454    fn split_too_few_dashes() {
455        assert_err(
456            ScriptSource::parse(
457                r#"#!/usr/bin/env cargo
458--
459[dependencies]
460time="0.1.25"
461--
462fn main() {}
463"#,
464            ),
465            str!["found 2 `-` in rust frontmatter, expected at least 3"],
466        );
467    }
468
469    #[test]
470    fn split_mismatched_dashes() {
471        assert_err(
472            ScriptSource::parse(
473                r#"#!/usr/bin/env cargo
474---
475[dependencies]
476time="0.1.25"
477----
478fn main() {}
479"#,
480            ),
481            str!["unexpected trailing content on closing fence: `-`"],
482        );
483    }
484
485    #[test]
486    fn split_missing_close() {
487        assert_err(
488            ScriptSource::parse(
489                r#"#!/usr/bin/env cargo
490---
491[dependencies]
492time="0.1.25"
493fn main() {}
494"#,
495            ),
496            str!["no closing `---` found for frontmatter"],
497        );
498    }
499
500    #[track_caller]
501    fn expand(source: &str) -> String {
502        let shell = crate::Shell::from_write(Box::new(Vec::new()));
503        let cwd = std::env::current_dir().unwrap();
504        let home = home::cargo_home_with_cwd(&cwd).unwrap();
505        let gctx = GlobalContext::new(shell, cwd, home);
506        expand_manifest(source, std::path::Path::new("/home/me/test.rs"), &gctx)
507            .unwrap_or_else(|err| panic!("{}", err))
508    }
509
510    #[test]
511    fn expand_default() {
512        assert_data_eq!(
513            expand(r#"fn main() {}"#),
514            str![[r#"
515[[bin]]
516name = "test-"
517path = "/home/me/test.rs"
518
519"#]]
520        );
521    }
522
523    #[test]
524    fn expand_dependencies() {
525        assert_data_eq!(
526            expand(
527                r#"---cargo
528[dependencies]
529time="0.1.25"
530---
531fn main() {}
532"#
533            ),
534            str![[r#"
535[[bin]]
536name = "test-"
537path = [..]
538
539[dependencies]
540time = "0.1.25"
541
542"#]]
543        );
544    }
545}