1use std::collections::BTreeMap;
20use std::fmt::{self, Write as _};
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use toml_edit::{DocumentMut, Item, Table, Value};
25
26use super::{Answers, Part, Template, folder_of, ron_merge};
27use crate::catalog::{self, Catalog};
28
29pub const GENERATED_STAGES: &str = "crates/game/src/stages.rs";
32
33type Written = BTreeMap<String, Vec<(String, Vec<u8>)>>;
36
37const COMPOSER: &str = "shuyi new";
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
42#[non_exhaustive]
45pub struct Fill {
46 pub name: String,
48 pub crate_name: String,
50 pub engine: BTreeMap<String, String>,
53 pub engine_ref: String,
56 pub engine_docs: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Plan {
65 pub files: BTreeMap<PathBuf, Vec<u8>>,
68 pub parts: Vec<String>,
70 pub modules: Vec<String>,
72 pub targets: Vec<String>,
74 pub stages: Vec<String>,
76 pub skills: Vec<String>,
79 pub answers: Answers,
82 pub limits: Vec<String>,
84}
85
86impl Plan {
87 pub fn paths(&self) -> Vec<String> {
89 self.files.keys().map(|p| shown(p)).collect()
90 }
91}
92
93fn shown(path: &Path) -> String {
95 path.to_string_lossy().replace('\\', "/")
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ComposeError {
102 Answer {
104 message: String,
106 },
107 Part {
109 part: String,
111 message: String,
113 },
114 Requires {
116 part: String,
118 requires: String,
120 because: String,
122 },
123 Conflict {
125 parts: [String; 2],
127 because: String,
129 },
130 SameFile {
132 path: String,
134 parts: Vec<String>,
136 },
137 Merge {
139 path: String,
141 parts: Vec<String>,
143 at: String,
145 message: String,
147 },
148 Placeholder {
150 file: String,
152 line: usize,
154 placeholder: String,
156 },
157 Module {
159 part: String,
161 module: String,
163 message: String,
165 },
166 Stage {
168 parts: Vec<String>,
170 message: String,
172 },
173}
174
175impl fmt::Display for ComposeError {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 let names = |parts: &[String]| {
178 let named: Vec<String> = parts.iter().map(|p| format!("`{p}`")).collect();
179 named.join(" and ")
180 };
181 match self {
182 ComposeError::Answer { message } => write!(f, "{message}"),
183 ComposeError::Part { part, message } => write!(f, "part `{part}`: {message}"),
184 ComposeError::Requires {
185 part,
186 requires,
187 because,
188 } => write!(
189 f,
190 "part `{part}` (picked because {because}) needs part `{requires}`, which no \
191 answer picks"
192 ),
193 ComposeError::Conflict { parts, because } => write!(
194 f,
195 "parts `{}` and `{}` can't be in one game: {because}",
196 parts[0], parts[1]
197 ),
198 ComposeError::SameFile { path, parts } => write!(
199 f,
200 "{path} is written by the parts {}, and only a Cargo.toml or a .ron file is \
201 merged",
202 names(parts)
203 ),
204 ComposeError::Merge {
205 path,
206 parts,
207 at,
208 message,
209 } => {
210 let at = if at.is_empty() {
211 String::new()
212 } else {
213 format!(" at `{at}`")
214 };
215 write!(
216 f,
217 "{path}, written by the parts {}, doesn't merge{at}: {message}",
218 names(parts)
219 )
220 }
221 ComposeError::Placeholder {
222 file,
223 line,
224 placeholder,
225 } => write!(
226 f,
227 "the template's {file}:{line} has an unknown placeholder {placeholder}"
228 ),
229 ComposeError::Module {
230 part,
231 module,
232 message,
233 } => write!(f, "part `{part}` turns on `{module}`: {message}"),
234 ComposeError::Stage { parts, message } => {
235 write!(f, "the stages of {}: {message}", names(parts))
236 }
237 }
238 }
239}
240
241impl std::error::Error for ComposeError {}
242
243pub fn fill(
249 file: &str,
250 text: &str,
251 with: &Fill,
252 answers: &Answers,
253) -> Result<String, ComposeError> {
254 let mut out = String::with_capacity(text.len());
255 let mut at = 0;
256 while let Some(found) = text[at..].find("{{") {
257 let open = at + found;
258 let before = &text[at..open];
259 let unknown = |shown: &str| ComposeError::Placeholder {
260 file: file.to_owned(),
261 line: text[..open].matches('\n').count() + 1,
262 placeholder: shown.to_owned(),
263 };
264 let Some(length) = text[open..].find("}}") else {
265 return Err(unknown(&text[open..]));
266 };
267 let close = open + length + 2;
268 let token = &text[open..close];
269 let inner = &text[open + 2..close - 2];
270 out.push_str(before);
271 at = close;
272 if before.ends_with('$') {
273 out.push_str(token);
274 continue;
275 }
276 if let Some(krate) = inner.strip_prefix("engine.")
277 && let Some(dependency) = with.engine.get(krate)
278 && out.ends_with('"')
279 && text[close..].starts_with('"')
280 {
281 out.pop();
282 out.push_str(dependency);
283 at += 1;
284 continue;
285 }
286 let value = match inner {
287 "name" => with.name.clone(),
288 "crate_name" => with.crate_name.clone(),
289 "engine_ref" => with.engine_ref.clone(),
290 "engine.docs" => with.engine_docs.clone(),
291 _ => match inner
292 .strip_prefix("answer.")
293 .and_then(|question| answers.0.get(question))
294 {
295 Some(picked) => picked.join(","),
296 None => return Err(unknown(token)),
297 },
298 };
299 out.push_str(&value);
300 }
301 out.push_str(&text[at..]);
302 Ok(out)
303}
304
305fn module_of(stage: &str) -> String {
307 stage.replace('-', "_")
308}
309
310pub fn stages_rs(stages: &[String]) -> String {
316 let head = "//! The game's stages, in the order its template's parts add them. `shuyi\n\
317 //! new` wrote this file from the parts it was made with: the title\n\
318 //! ([`crate::title`]) starts the first, and a stage's own part of the\n\
319 //! game's data is [`Own`].\n\n";
320 let mut out = head.to_owned();
321 match stages {
322 [] => out.push_str(
323 "use shuyi::prelude::*;\n\n\
324 use crate::Data;\n\n\
325 /// The stages' ids, in order: none yet.\n\
326 pub fn names() -> &'static [&'static str] {\n &[]\n}\n\n\
327 /// The stages' own part of the game's data: none.\n\
328 pub type Own = ();\n\n\
329 /// Reads the stages' own part from the game's files.\n\
330 pub fn own(_: &Bundle) -> Result<Own, Vec<ContentError>> {\n Ok(())\n}\n\n\
331 /// The first stage, which the title starts: none.\n\
332 pub fn first(_: &mut Ctx<Data>) -> Option<Box<dyn Screen<Data>>> {\n None\n}\n\n\
333 /// Every screen the stages can show, for the check that the game's\n\
334 /// controls fit each target: none.\n\
335 pub fn screens(_: &mut Ctx<Data>) -> Vec<Box<dyn Screen<Data>>> {\n Vec::new()\n}\n\n\
336 /// The game's record of its play for a crash report: none.\n\
337 pub fn crash_record() -> Option<shuyi::platform::CrashRecord<Data>> {\n None\n}\n\n\
338 /// The game's tools: the engine's, for a game on the short path.\n\
339 #[cfg(feature = \"tools\")]\n\
340 pub fn tools() -> shuyi_cli::BasicTools<UiAction, Own> {\n \
341 shuyi_cli::BasicTools::new(crate::game())\n}\n",
342 ),
343 [stage, ..] => {
344 let module = module_of(stage);
345 for stage in stages {
347 let _ = writeln!(out, "pub mod {};", module_of(stage));
348 }
349 let names: Vec<String> = stages.iter().map(|s| format!("\"{s}\"")).collect();
350 let _ = write!(
351 out,
352 "\nuse shuyi::prelude::*;\n\n\
353 use crate::Data;\n\n\
354 /// The stages' ids, in order.\n\
355 pub fn names() -> &'static [&'static str] {{\n &[{names}]\n}}\n\n\
356 /// The stages' own part of the game's data.\n\
357 pub type Own = {module}::Own;\n\n\
358 /// Reads the stages' own part from the game's files.\n\
359 pub fn own(bundle: &Bundle) -> Result<Own, Vec<ContentError>> {{\n \
360 {module}::own(bundle)\n}}\n\n\
361 /// The first stage, which the title starts.\n\
362 pub fn first(ctx: &mut Ctx<Data>) -> Option<Box<dyn Screen<Data>>> {{\n \
363 Some({module}::first(ctx))\n}}\n\n\
364 /// Every screen the stages can show, for the check that the game's\n\
365 /// controls fit each target.\n\
366 pub fn screens(ctx: &mut Ctx<Data>) -> Vec<Box<dyn Screen<Data>>> {{\n \
367 {module}::screens(ctx)\n}}\n\n\
368 /// The game's record of its play for a crash report: its stage's.\n\
369 pub fn crash_record() -> Option<shuyi::platform::CrashRecord<Data>> {{\n \
370 Some({module}::crash_record)\n}}\n\n\
371 /// The game's tools: its stage's, which give the engine's for the rest.\n\
372 #[cfg(feature = \"tools\")]\n\
373 pub fn tools() -> {module}::Tools {{\n \
374 {module}::Tools::new(crate::game())\n}}\n",
375 names = names.join(", "),
376 );
377 }
378 }
379 out
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum Merged {
385 Toml,
386 Ron,
387}
388
389impl Merged {
390 fn of(path: &str) -> Option<Self> {
391 if path == "Cargo.toml" || path.ends_with("/Cargo.toml") {
392 Some(Merged::Toml)
393 } else if Path::new(path)
394 .extension()
395 .is_some_and(|e| e.eq_ignore_ascii_case("ron"))
396 {
397 Some(Merged::Ron)
398 } else {
399 None
400 }
401 }
402}
403
404fn merged(path: &str, writers: Vec<(String, Vec<u8>)>) -> Result<Vec<u8>, ComposeError> {
407 let parts: Vec<String> = writers.iter().map(|(part, _)| part.clone()).collect();
408 if writers.len() == 1 {
409 return Ok(writers
410 .into_iter()
411 .next()
412 .map(|(_, b)| b)
413 .unwrap_or_default());
414 }
415 let Some(kind) = Merged::of(path) else {
416 return Err(ComposeError::SameFile {
417 path: path.to_owned(),
418 parts,
419 });
420 };
421 let failed = |at: String, message: String| ComposeError::Merge {
422 path: path.to_owned(),
423 parts: parts.clone(),
424 at,
425 message,
426 };
427 let mut texts = Vec::new();
428 for (part, bytes) in &writers {
429 let text = std::str::from_utf8(bytes)
430 .map_err(|e| failed(String::new(), format!("part `{part}`'s is not UTF-8: {e}")))?;
431 texts.push(text);
432 }
433 let mut out = texts[0].to_owned();
434 for text in &texts[1..] {
435 out = match kind {
436 Merged::Toml => merge_toml(&out, text),
437 Merged::Ron => ron_merge::merge(&out, text),
438 }
439 .map_err(|(at, message)| failed(at, message))?;
440 }
441 Ok(out.into_bytes())
442}
443
444pub(super) fn merge_toml(base: &str, other: &str) -> Result<String, (String, String)> {
448 let mut doc: DocumentMut = base.parse().map_err(|e| (String::new(), format!("{e}")))?;
449 let other: DocumentMut = other.parse().map_err(|e| (String::new(), format!("{e}")))?;
450 merge_tables(doc.as_table_mut(), other.as_table(), "")?;
451 Ok(doc.to_string())
452}
453
454fn merge_tables(into: &mut Table, from: &Table, at: &str) -> Result<(), (String, String)> {
456 for (key, item) in from {
457 let here = if at.is_empty() {
458 key.to_owned()
459 } else {
460 format!("{at}.{key}")
461 };
462 if let Some(existing) = into.get_mut(key) {
463 merge_items(existing, item, &here)?;
464 } else {
465 let formatted = from.key(key).cloned().unwrap_or_else(|| key.into());
466 into.insert_formatted(&formatted, item.clone());
467 }
468 }
469 Ok(())
470}
471
472fn bare(value: &Value) -> String {
474 let mut value = value.clone();
475 value.decor_mut().clear();
476 value.to_string()
477}
478
479fn merge_items(into: &mut Item, from: &Item, at: &str) -> Result<(), (String, String)> {
481 match (into, from) {
482 (Item::Table(into), Item::Table(from)) => merge_tables(into, from, at),
483 (Item::Value(Value::Array(into)), Item::Value(Value::Array(from))) => {
484 for value in from {
485 if !into.iter().any(|v| bare(v) == bare(value)) {
486 into.push_formatted(value.clone());
487 }
488 }
489 Ok(())
490 }
491 (Item::ArrayOfTables(into), Item::ArrayOfTables(from)) => {
492 for table in from {
493 if !into.iter().any(|t| t.to_string() == table.to_string()) {
494 into.push(table.clone());
495 }
496 }
497 Ok(())
498 }
499 (into, from) if into.is_table_like() && from.is_table_like() => {
500 let (Some(into), Some(from)) = (into.as_table_like_mut(), from.as_table_like()) else {
501 return Ok(());
502 };
503 for (key, item) in from.iter() {
504 let here = format!("{at}.{key}");
505 match into.get_mut(key) {
506 Some(existing) => merge_items(existing, item, &here)?,
507 None => {
508 into.insert(key, item.clone());
509 }
510 }
511 }
512 Ok(())
513 }
514 (into, from) => {
515 let shown = |item: &Item| match item.as_value() {
516 Some(value) => bare(value),
517 None => item.to_string().trim().to_owned(),
518 };
519 let (a, b) = (shown(into), shown(from));
520 if a == b {
521 Ok(())
522 } else {
523 Err((at.to_owned(), format!("`{a}` in one, `{b}` in another")))
524 }
525 }
526 }
527}
528
529fn picked<'a>(template: &'a Template, answers: &Answers) -> Result<Vec<&'a Part>, ComposeError> {
532 let parts: Vec<&Part> = template
533 .parts
534 .iter()
535 .filter(|part| part.when.holds(answers))
536 .collect();
537 for part in &parts {
538 if let Some(missing) = part
539 .requires
540 .iter()
541 .find(|id| !parts.iter().any(|p| &p.id == *id))
542 {
543 return Err(ComposeError::Requires {
544 part: part.id.clone(),
545 requires: missing.clone(),
546 because: part.when.because(answers),
547 });
548 }
549 if let Some(other) = parts.iter().find(|p| part.conflicts.contains(&p.id)) {
550 return Err(ComposeError::Conflict {
551 parts: [part.id.clone(), other.id.clone()],
552 because: format!(
553 "`{}` is picked because {}, and `{}` because {}",
554 part.id,
555 part.when.because(answers),
556 other.id,
557 other.when.because(answers)
558 ),
559 });
560 }
561 }
562 Ok(parts)
563}
564
565fn written(
568 template: &Template,
569 parts: &[&Part],
570 with: &Fill,
571 answers: &Answers,
572) -> Result<Written, ComposeError> {
573 let mut written = Written::new();
574 for part in parts {
575 let folder = folder_of(&part.files).map_err(|message| ComposeError::Part {
576 part: part.id.clone(),
577 message,
578 })?;
579 let files = template
580 .files
581 .range(folder.clone()..)
582 .take_while(|(path, _)| path.starts_with(&folder));
583 for (path, bytes) in files {
584 let source = format!("{}{}", folder, &path[folder.len()..]);
585 let to = fill(&source, &path[folder.len()..], with, answers)?;
586 let bytes = match std::str::from_utf8(bytes) {
587 Ok(text) => fill(&source, text, with, answers)?.into_bytes(),
588 Err(_) => bytes.clone(),
589 };
590 written
591 .entry(to)
592 .or_default()
593 .push((part.id.clone(), bytes));
594 }
595 }
596 Ok(written)
597}
598
599fn stages(
602 template: &Template,
603 parts: &[&Part],
604 written: &mut Written,
605) -> Result<Vec<String>, ComposeError> {
606 let with_part: Vec<(&str, &str)> = parts
607 .iter()
608 .filter_map(|p| p.stage.as_deref().map(|stage| (p.id.as_str(), stage)))
609 .collect();
610 if !template.parts.iter().any(|p| p.stage.is_some()) {
611 return Ok(Vec::new());
612 }
613 if let Some(writers) = written.get(GENERATED_STAGES) {
614 return Err(ComposeError::Stage {
615 parts: writers.iter().map(|(p, _)| p.clone()).collect(),
616 message: format!(
617 "{GENERATED_STAGES} is written by {COMPOSER} from the parts' stages, not by a part"
618 ),
619 });
620 }
621 if with_part.len() > 1 {
622 return Err(ComposeError::Stage {
623 parts: with_part.iter().map(|(p, _)| (*p).to_owned()).collect(),
624 message: "each adds a stage, and a game has one stage for now: pick one".to_owned(),
625 });
626 }
627 for (part, stage) in &with_part {
628 let module = format!("crates/game/src/stages/{}.rs", module_of(stage));
629 if !written.contains_key(&module) {
630 return Err(ComposeError::Stage {
631 parts: vec![(*part).to_owned()],
632 message: format!("the part adds the stage `{stage}` but writes no {module}"),
633 });
634 }
635 }
636 let stages: Vec<String> = with_part.iter().map(|(_, s)| (*s).to_owned()).collect();
637 written.insert(
638 GENERATED_STAGES.to_owned(),
639 vec![(COMPOSER.to_owned(), stages_rs(&stages).into_bytes())],
640 );
641 Ok(stages)
642}
643
644pub fn compose(
649 template: &Template,
650 answers: &Answers,
651 catalog: &Catalog,
652 with: &Fill,
653) -> Result<Plan, ComposeError> {
654 template
655 .check_answers(answers)
656 .map_err(|message| ComposeError::Answer { message })?;
657 let answers = template.with_defaults(answers);
658 let parts = picked(template, &answers)?;
659 let mut written = written(template, &parts, with, &answers)?;
660 let stages = stages(template, &parts, &mut written)?;
661
662 let mut files = BTreeMap::new();
663 for (path, writers) in written {
664 let bytes = merged(&path, writers)?;
665 files.insert(PathBuf::from(path), bytes);
666 }
667 let skills = with_skills(catalog, &mut files);
668
669 let mut modules = Vec::new();
670 let mut features = Vec::new();
671 for part in &parts {
672 for name in &part.modules {
673 let module = catalog.module(name).ok_or_else(|| ComposeError::Module {
674 part: part.id.clone(),
675 module: name.clone(),
676 message: crate::contract::add::unknown(catalog, name),
677 })?;
678 if !modules.contains(&module.name) {
679 modules.push(module.name.clone());
680 features.push((part.id.clone(), module.feature().to_owned()));
681 }
682 }
683 }
684 let root = PathBuf::from("Cargo.toml");
685 for (part, feature) in &features {
686 let failed = |message: String| ComposeError::Module {
687 part: part.clone(),
688 module: catalog::module_of(feature),
689 message,
690 };
691 let manifest = files
692 .get(&root)
693 .ok_or_else(|| failed("the game has no Cargo.toml to turn it on in".to_owned()))?;
694 let text = String::from_utf8_lossy(manifest).into_owned();
695 if let Some(edited) = catalog::turn_on(&text, feature).map_err(failed)? {
696 files.insert(root.clone(), edited.into_bytes());
697 }
698 }
699
700 let mut targets: Vec<String> = Vec::new();
701 for target in parts.iter().flat_map(|p| &p.targets) {
702 if !targets.contains(target) {
703 targets.push(target.clone());
704 }
705 }
706 let mut limits = Vec::new();
707 for (question, picked) in &answers.0 {
708 for answer in picked {
709 let served = template.parts.iter().any(|p| {
710 p.when
711 .picks()
712 .contains(&(question.as_str(), answer.as_str()))
713 });
714 if !served {
715 limits.push(format!(
716 "no part of the template for `{question}` = `{answer}`: nothing added"
717 ));
718 }
719 }
720 }
721 Ok(Plan {
722 files,
723 parts: parts.iter().map(|p| p.id.clone()).collect(),
724 modules,
725 targets,
726 stages,
727 skills,
728 answers,
729 limits,
730 })
731}
732
733fn with_skills(catalog: &Catalog, files: &mut BTreeMap<PathBuf, Vec<u8>>) -> Vec<String> {
737 let mut added = Vec::new();
738 for skill in &catalog.skills.skills {
739 let folder = format!("{}/{}/", catalog::SKILLS, skill.name);
740 let written = files.keys().any(|p| shown(p).starts_with(&folder));
741 if catalog.is_module(&skill.name) || written {
742 continue;
743 }
744 for (path, bytes) in &skill.files {
745 files.insert(PathBuf::from(format!("{folder}{path}")), bytes.clone());
746 }
747 added.push(skill.name.clone());
748 }
749 added
750}
751
752#[derive(Debug, Clone, Default, PartialEq, Eq)]
754pub struct Applied {
755 pub written: Vec<String>,
757 pub overwritten: Vec<String>,
759}
760
761pub fn apply(plan: &Plan, dir: &Path, force: bool) -> Result<Applied, String> {
765 let overwritten: Vec<String> = plan
766 .files
767 .keys()
768 .filter(|path| dir.join(path).exists())
769 .map(|p| shown(p))
770 .collect();
771 let empty = fs::read_dir(dir).map_or(true, |mut entries| entries.next().is_none());
772 if !empty && !force {
773 let over = if overwritten.is_empty() {
774 String::new()
775 } else {
776 format!(" (it would write over {})", overwritten.join(", "))
777 };
778 return Err(format!("is not empty{over}: pass --force to write into it"));
779 }
780 for (path, bytes) in &plan.files {
781 let to = dir.join(path);
782 if let Some(parent) = to.parent() {
783 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
784 }
785 fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
786 }
787 Ok(Applied {
788 written: plan.paths(),
789 overwritten,
790 })
791}