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 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)?; for _ in 0..frontmatter.lines().count() {
48 writeln!(hacked_source)?;
49 }
50 writeln!(hacked_source)?; 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
70fn 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
95pub fn sanitize_name(name: &str) -> String {
97 let placeholder = if name.contains('_') {
98 '_'
99 } else {
100 '-'
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 name.push(placeholder);
113 } else if name == "test" {
114 name.push(placeholder);
115 } else if restricted_names::is_windows_reserved(&name) {
116 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 break;
158 } else if without_nl == without_spaces {
159 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 if let Some(rest) = input.strip_prefix("#!") {
229 if !rest.trim_start().starts_with('[') {
235 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}