Skip to main content

shuyi_cli/
template.rs

1//! Templates as data (ADR-0013 ยง3): a folder with a `template.ron` and
2//! the files of its parts, which `shuyi new` turns into a new game.
3//!
4//! - [`Template`]: the manifest, read and checked by [`Template::load`]
5//!   (a folder) or [`Template::read`] (its files, already in memory);
6//! - [`Part`]: a folder of files, with the modules, targets and stage it
7//!   adds, and [`When`] it applies;
8//! - [`Question`]: a question of the template's own, and its answers;
9//! - [`Answers`]: the answers picked, by question, as `answers.ron` holds
10//!   them;
11//! - [`compose`]: the template and the answers made into a [`Plan`], the
12//!   files and their bytes, with no I/O; [`apply`] writes a plan.
13//!
14//! The engine's own template, `templates/shuyi-default`, is embedded in
15//! the `shuyi` binary ([`built_in`]). `docs/templates.md` describes the
16//! format. No template runs code: a template is data, and `new` writes
17//! files.
18
19mod compose;
20mod ron_merge;
21
22use std::collections::BTreeMap;
23use std::fmt;
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use include_dir::{Dir, DirEntry, include_dir};
28use schemars::JsonSchema;
29use semver::{Version, VersionReq};
30use serde::{Deserialize, Serialize};
31
32pub use compose::{
33    Applied, ComposeError, Fill, GENERATED_STAGES, Plan, apply, compose, fill, stages_rs,
34};
35
36/// The manifest of a template, in its folder.
37pub const MANIFEST: &str = "template.ron";
38
39/// The version of the template format this `shuyi` reads.
40pub const FORMAT_VERSION: u32 = 1;
41
42/// The engine's version, which a template's `engine` must match.
43pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45/// The engine's own template, embedded.
46static BUILT_IN: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../templates/shuyi-default");
47
48/// Where the engine's own template is, in the engine's repository.
49pub const BUILT_IN_ORIGIN: &str = "templates/shuyi-default";
50
51/// A template's files, by their path from its folder (`/`-separated),
52/// with their bytes.
53pub type Files = BTreeMap<String, Vec<u8>>;
54
55/// A template: what `template.ron` says, with the files of its folder.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct Template {
59    /// Its id (`shuyi-default`).
60    pub id: String,
61    /// How people call it.
62    pub name: String,
63    /// Its own version.
64    pub version: Version,
65    /// The engine versions it makes games for (`>=0.1, <0.2`).
66    pub engine: VersionReq,
67    /// Its license, an SPDX expression: the license of the files it
68    /// writes.
69    pub license: String,
70    /// The template format it is written in: [`FORMAT_VERSION`].
71    pub format_version: u32,
72    /// Its own questions, for what a game picks that no concern of the
73    /// engine's asks.
74    #[serde(default)]
75    pub questions: Vec<Question>,
76    /// Short names for sets of answers (`grid`), which `new --template`
77    /// takes.
78    #[serde(default)]
79    pub short_names: BTreeMap<String, Answers>,
80    /// Its parts, in the order their files are written and merged.
81    pub parts: Vec<Part>,
82    /// Every file of its folder but `template.ron`.
83    #[serde(skip)]
84    pub files: Files,
85    /// Where it was read from, as errors name it.
86    #[serde(skip)]
87    pub origin: String,
88}
89
90/// A part of a template: a folder of files, what they turn on, and when
91/// they are written.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct Part {
95    /// Its id, which `requires` and `conflicts` name.
96    pub id: String,
97    /// When it applies.
98    pub when: When,
99    /// Its folder, from the template's: its files are written from there
100    /// into the game's folder.
101    pub files: PathBuf,
102    /// The engine's modules it turns on (`physics`), as `shuyi add` does.
103    #[serde(default)]
104    pub modules: Vec<String>,
105    /// The targets it ships on (`web`).
106    #[serde(default)]
107    pub targets: Vec<String>,
108    /// The stage it adds, if any (`top-down-grid`): its module is
109    /// `crates/game/src/stages/<stage, with `_` for `-`>.rs`.
110    #[serde(default)]
111    pub stage: Option<String>,
112    /// The parts it needs picked too.
113    #[serde(default)]
114    pub requires: Vec<String>,
115    /// The parts it can't be picked with.
116    #[serde(default)]
117    pub conflicts: Vec<String>,
118}
119
120/// When a part applies.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub enum When {
123    /// In every game.
124    Always,
125    /// When the question (its id first) has the answer (its id) picked.
126    Picked(String, String),
127    /// When each of these holds.
128    All(Vec<When>),
129    /// When one of these holds.
130    Any(Vec<When>),
131}
132
133/// A question of a template's own.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct Question {
137    /// Its id, which `Picked` and the answers name.
138    pub id: String,
139    /// The words that ask it.
140    pub text: String,
141    /// How many answers a game picks.
142    pub pick: Pick,
143    /// Its answers.
144    pub answers: Vec<Answer>,
145    /// The answers picked when none is given: none if empty.
146    #[serde(default)]
147    pub default: Vec<String>,
148}
149
150/// How many answers a question takes.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152pub enum Pick {
153    /// One at most.
154    One,
155    /// Any number.
156    Many,
157}
158
159/// One answer to a question.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct Answer {
163    /// Its id.
164    pub id: String,
165    /// The words that say it.
166    pub text: String,
167}
168
169/// The answers a game picked: by question id, the ids of its answers, as
170/// `answers.ron` holds them (`{ "perspective": ["top-down-grid"] }`).
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172#[serde(transparent)]
173pub struct Answers(pub BTreeMap<String, Vec<String>>);
174
175impl Answers {
176    /// `answers.ron`'s text, read (`file` names it in an error).
177    pub fn parse(file: &str, text: &str) -> Result<Self, String> {
178        ron::from_str(text).map_err(|e| format!("{file}: {e}"))
179    }
180
181    /// As `answers.ron` writes them.
182    pub fn to_ron(&self) -> String {
183        let pretty = ron::ser::PrettyConfig::new();
184        ron::ser::to_string_pretty(self, pretty).unwrap_or_default()
185    }
186
187    /// The answers of `--set <question>=<answer[,answer]>`.
188    pub fn parse_set(set: &str) -> Result<(String, Vec<String>), String> {
189        let (question, answers) = set
190            .split_once('=')
191            .ok_or_else(|| format!("--set {set}: not <question>=<answer[,answer]>"))?;
192        let question = question.trim();
193        if question.is_empty() {
194            return Err(format!("--set {set}: no question before `=`"));
195        }
196        let answers = answers
197            .split(',')
198            .map(str::trim)
199            .filter(|a| !a.is_empty())
200            .map(str::to_owned)
201            .collect();
202        Ok((question.to_owned(), answers))
203    }
204
205    /// These answers, with `other`'s in place of theirs for each question
206    /// `other` answers.
207    #[must_use]
208    pub fn and(mut self, other: &Answers) -> Self {
209        for (question, answers) in &other.0 {
210            self.0.insert(question.clone(), answers.clone());
211        }
212        self
213    }
214
215    /// Whether `question` has `answer` picked.
216    pub fn picked(&self, question: &str, answer: &str) -> bool {
217        self.0
218            .get(question)
219            .is_some_and(|answers| answers.iter().any(|a| a == answer))
220    }
221}
222
223impl When {
224    /// Whether it holds for `answers`.
225    pub fn holds(&self, answers: &Answers) -> bool {
226        match self {
227            When::Always => true,
228            When::Picked(question, answer) => answers.picked(question, answer),
229            When::All(all) => all.iter().all(|w| w.holds(answers)),
230            When::Any(any) => any.iter().any(|w| w.holds(answers)),
231        }
232    }
233
234    /// Why it holds for `answers`, in words: "always", or the answers that
235    /// make it hold (`perspective` is `top-down-grid`).
236    pub fn because(&self, answers: &Answers) -> String {
237        match self {
238            When::Always => "always".to_owned(),
239            When::Picked(question, answer) => format!("`{question}` is `{answer}`"),
240            When::All(all) => {
241                let words: Vec<String> = all.iter().map(|w| w.because(answers)).collect();
242                if words.is_empty() {
243                    "always".to_owned()
244                } else {
245                    words.join(" and ")
246                }
247            }
248            When::Any(any) => {
249                let words: Vec<String> = any
250                    .iter()
251                    .filter(|w| w.holds(answers))
252                    .map(|w| w.because(answers))
253                    .collect();
254                words.join(" or ")
255            }
256        }
257    }
258
259    /// Every `(question, answer)` it names, at any depth.
260    pub fn picks(&self) -> Vec<(&str, &str)> {
261        match self {
262            When::Always => Vec::new(),
263            When::Picked(question, answer) => vec![(question.as_str(), answer.as_str())],
264            When::All(whens) | When::Any(whens) => whens.iter().flat_map(When::picks).collect(),
265        }
266    }
267}
268
269/// Why a template can't be read: the file, the field and what is wrong.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct LoadError {
272    /// The file (`templates/shuyi-default/template.ron`).
273    pub file: String,
274    /// The field (`parts[base].files`), or `""` for the whole file.
275    pub field: String,
276    /// What is wrong.
277    pub message: String,
278}
279
280impl fmt::Display for LoadError {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        if self.field.is_empty() {
283            write!(f, "{}: {}", self.file, self.message)
284        } else {
285            write!(f, "{}: {}: {}", self.file, self.field, self.message)
286        }
287    }
288}
289
290impl std::error::Error for LoadError {}
291
292/// A part's folder as a prefix of the template's file paths (`base/`), or
293/// why it is none.
294pub(crate) fn folder_of(files: &Path) -> Result<String, String> {
295    let text = files.to_string_lossy().replace('\\', "/");
296    let trimmed = text.trim_matches('/');
297    let escapes = trimmed
298        .split('/')
299        .any(|segment| segment == ".." || segment == "." || segment.is_empty());
300    // An empty path is one empty segment, and an absolute one starts with
301    // `/` here (a Windows drive's `C:` matches no file of the template).
302    if text.starts_with('/') || escapes {
303        return Err(format!(
304            "`{text}` is no folder inside the template: give a path from its folder, with no \
305             `.` or `..`"
306        ));
307    }
308    Ok(format!("{trimmed}/"))
309}
310
311/// Whether `id` can be a stage's: lowercase ASCII letters and digits in
312/// words joined by `-`, starting with a letter (it names a Rust module).
313fn is_stage_id(id: &str) -> bool {
314    id.starts_with(|c: char| c.is_ascii_lowercase())
315        && id.split('-').all(|w| {
316            !w.is_empty()
317                && w.chars()
318                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
319        })
320}
321
322impl Template {
323    /// The template in folder `dir`: its `template.ron` and every file
324    /// under it, read and checked against this engine ([`ENGINE_VERSION`]).
325    pub fn load(dir: &Path) -> Result<Self, LoadError> {
326        let origin = dir.to_string_lossy().replace('\\', "/");
327        let mut files = Files::new();
328        read_dir(dir, dir, &mut files).map_err(|message| LoadError {
329            file: origin.clone(),
330            field: String::new(),
331            message,
332        })?;
333        Self::read(&origin, files, &engine_version())
334    }
335
336    /// The template whose folder (`origin`, as errors name it) holds
337    /// `files`, its `template.ron` among them, read and checked for an
338    /// engine of version `engine`.
339    pub fn read(origin: &str, mut files: Files, engine: &Version) -> Result<Self, LoadError> {
340        let file = format!("{}/{MANIFEST}", origin.trim_end_matches('/'));
341        let error = |field: &str, message: String| LoadError {
342            file: file.clone(),
343            field: field.to_owned(),
344            message,
345        };
346        let bytes = files
347            .remove(MANIFEST)
348            .ok_or_else(|| error("", "missing: every template has one".to_owned()))?;
349        let text =
350            std::str::from_utf8(&bytes).map_err(|e| error("", format!("not UTF-8 text: {e}")))?;
351        let mut template: Template = ron::from_str(text).map_err(|e| error("", e.to_string()))?;
352        template.files = files;
353        origin.clone_into(&mut template.origin);
354        template
355            .check(engine)
356            .map_err(|(field, message)| error(&field, message))?;
357        Ok(template)
358    }
359
360    /// The first thing wrong with it, as its field and what is wrong.
361    fn check(&self, engine: &Version) -> Result<(), (String, String)> {
362        if self.format_version != FORMAT_VERSION {
363            return Err((
364                "format_version".to_owned(),
365                format!(
366                    "{}: this shuyi reads templates of format {FORMAT_VERSION}",
367                    self.format_version
368                ),
369            ));
370        }
371        if !self.engine.matches(engine) {
372            return Err((
373                "engine".to_owned(),
374                format!(
375                    "`{}` doesn't match this engine, {engine}: the template makes games for \
376                     another engine",
377                    self.engine
378                ),
379            ));
380        }
381        self.check_questions()?;
382        for (name, answers) in &self.short_names {
383            self.check_answers(answers)
384                .map_err(|message| (format!("short_names[{name}]"), message))?;
385        }
386        self.check_parts()
387    }
388
389    /// Its questions: each id once, each answer id once, the defaults
390    /// among the answers.
391    fn check_questions(&self) -> Result<(), (String, String)> {
392        let mut seen = Vec::new();
393        for question in &self.questions {
394            let field = format!("questions[{}]", question.id);
395            if seen.contains(&question.id.as_str()) {
396                return Err((field, "a second question with this id".to_owned()));
397            }
398            seen.push(&question.id);
399            let mut ids = Vec::new();
400            for answer in &question.answers {
401                if ids.contains(&answer.id.as_str()) {
402                    return Err((
403                        format!("{field}.answers"),
404                        format!("`{}` is in twice", answer.id),
405                    ));
406                }
407                ids.push(&answer.id);
408            }
409            Self::check_picks(question, &question.default)
410                .map_err(|message| (format!("{field}.default"), message))?;
411        }
412        Ok(())
413    }
414
415    /// Whether `picked` are answers of `question`, as many as it takes.
416    fn check_picks(question: &Question, picked: &[String]) -> Result<(), String> {
417        if let Some(unknown) = picked
418            .iter()
419            .find(|a| !question.answers.iter().any(|answer| &answer.id == *a))
420        {
421            return Err(format!(
422                "`{unknown}` is no answer of `{}`: its answers are {}",
423                question.id,
424                ids(question.answers.iter().map(|a| a.id.as_str()))
425            ));
426        }
427        if question.pick == Pick::One && picked.len() > 1 {
428            return Err(format!(
429                "`{}` takes one answer, not {}",
430                question.id,
431                picked.len()
432            ));
433        }
434        Ok(())
435    }
436
437    /// Whether `answers` name its questions and their answers, as many as
438    /// each takes.
439    pub fn check_answers(&self, answers: &Answers) -> Result<(), String> {
440        for (question, picked) in &answers.0 {
441            let question = self.question(question).ok_or_else(|| {
442                format!(
443                    "no question `{question}`: the template asks {}",
444                    ids(self.questions.iter().map(|q| q.id.as_str()))
445                )
446            })?;
447            Self::check_picks(question, picked)?;
448        }
449        Ok(())
450    }
451
452    /// Its parts: each id once, a folder of files each, the parts and
453    /// answers they name there.
454    fn check_parts(&self) -> Result<(), (String, String)> {
455        let mut seen = Vec::new();
456        for part in &self.parts {
457            let field = |name: &str| format!("parts[{}].{name}", part.id);
458            if seen.contains(&part.id.as_str()) {
459                return Err((
460                    format!("parts[{}]", part.id),
461                    "a second part with this id".to_owned(),
462                ));
463            }
464            seen.push(&part.id);
465            let folder = folder_of(&part.files).map_err(|m| (field("files"), m))?;
466            if !self.files.keys().any(|path| path.starts_with(&folder)) {
467                return Err((
468                    field("files"),
469                    format!("the folder `{folder}` holds no file"),
470                ));
471            }
472            for (list, named) in [("requires", &part.requires), ("conflicts", &part.conflicts)] {
473                if let Some(missing) = named
474                    .iter()
475                    .find(|id| !self.parts.iter().any(|p| &p.id == *id))
476                {
477                    return Err((field(list), format!("no part `{missing}`")));
478                }
479            }
480            for (question, answer) in part.when.picks() {
481                let q = self.question(question).ok_or_else(|| {
482                    (
483                        field("when"),
484                        format!("`Picked` names no question of the template: `{question}`"),
485                    )
486                })?;
487                if !q.answers.iter().any(|a| a.id == answer) {
488                    return Err((
489                        field("when"),
490                        format!("`Picked` names no answer of `{question}`: `{answer}`"),
491                    ));
492                }
493            }
494            if let Some(stage) = &part.stage
495                && !is_stage_id(stage)
496            {
497                return Err((
498                    field("stage"),
499                    format!(
500                        "`{stage}`: a stage's id is lowercase letters and digits in words \
501                         joined by `-`, starting with a letter"
502                    ),
503                ));
504            }
505        }
506        Ok(())
507    }
508
509    /// Its question `id`.
510    pub fn question(&self, id: &str) -> Option<&Question> {
511        self.questions.iter().find(|q| q.id == id)
512    }
513
514    /// The answers its short name `name` stands for.
515    pub fn short_name(&self, name: &str) -> Option<&Answers> {
516        self.short_names.get(name)
517    }
518
519    /// The answers picked: `answers`, and each question they leave out at
520    /// its default.
521    pub fn with_defaults(&self, answers: &Answers) -> Answers {
522        let mut all = answers.clone();
523        for question in &self.questions {
524            all.0
525                .entry(question.id.clone())
526                .or_insert_with(|| question.default.clone());
527        }
528        all
529    }
530}
531
532/// `ids` in backticks, joined by commas; `none` if there is none.
533fn ids<'a>(ids: impl Iterator<Item = &'a str>) -> String {
534    let ids: Vec<String> = ids.map(|id| format!("`{id}`")).collect();
535    if ids.is_empty() {
536        "none".to_owned()
537    } else {
538        ids.join(", ")
539    }
540}
541
542/// The engine's version, parsed.
543pub fn engine_version() -> Version {
544    // The package's version is semver: cargo checks it.
545    Version::parse(ENGINE_VERSION).unwrap_or_else(|_| Version::new(0, 0, 0))
546}
547
548/// Every file under `dir`, into `files` by its path from `root`.
549fn read_dir(root: &Path, dir: &Path, files: &mut Files) -> Result<(), String> {
550    let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
551    for entry in entries {
552        let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
553        let path = entry.path();
554        let kind = entry
555            .file_type()
556            .map_err(|e| format!("{}: {e}", path.display()))?;
557        if kind.is_dir() {
558            read_dir(root, &path, files)?;
559        } else if kind.is_file() {
560            let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
561            let name = path
562                .strip_prefix(root)
563                .unwrap_or(&path)
564                .to_string_lossy()
565                .replace('\\', "/");
566            files.insert(name, bytes);
567        }
568    }
569    Ok(())
570}
571
572/// The files of an embedded folder, at any depth, by their path from it.
573fn embedded_files(dir: &'static Dir<'static>) -> Files {
574    let mut files = Files::new();
575    let mut pending = vec![dir];
576    while let Some(dir) = pending.pop() {
577        for entry in dir.entries() {
578            match entry {
579                DirEntry::Dir(d) => pending.push(d),
580                DirEntry::File(f) => {
581                    let name = f.path().to_string_lossy().replace('\\', "/");
582                    files.insert(name, f.contents().to_vec());
583                }
584            }
585        }
586    }
587    files
588}
589
590/// The engine's own template, `templates/shuyi-default`, as the `shuyi`
591/// binary embeds it.
592pub fn built_in() -> Result<Template, LoadError> {
593    Template::read(
594        BUILT_IN_ORIGIN,
595        embedded_files(&BUILT_IN),
596        &engine_version(),
597    )
598}
599
600#[cfg(test)]
601mod tests;