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(rest) = source.content.strip_prefix("#!") {
148 if rest.trim_start().starts_with('[') {
154 return Ok(source);
155 }
156
157 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}