Skip to main content

pfx_game/
launcher.rs

1use std::process::ExitCode;
2use std::rc::Rc;
3
4#[cfg(feature = "tools")]
5use pfx_core::cli::{self, Refusal};
6use pfx_play::Game;
7
8use crate::config::Config;
9
10pub type Command = Box<dyn Fn(&[String]) -> Result<ExitCode, CommandError>>;
11pub type Factory = Rc<dyn Fn() -> Box<dyn Game>>;
12pub type Edit = Box<dyn FnOnce(Factory, &[String]) -> Result<(), String>>;
13pub type Rest = Box<dyn FnOnce(&[String]) -> Result<Unmatched, CommandError>>;
14
15pub const USAGE: u8 = 2;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct CommandError {
19    pub code: u8,
20    pub message: String,
21}
22
23impl CommandError {
24    pub fn new(code: u8, message: impl Into<String>) -> Self {
25        Self {
26            code,
27            message: message.into(),
28        }
29    }
30
31    pub fn usage(message: impl Into<String>) -> Self {
32        Self::new(USAGE, message)
33    }
34}
35
36impl<E: std::fmt::Display> From<E> for CommandError {
37    fn from(error: E) -> Self {
38        Self::new(1, error.to_string())
39    }
40}
41
42pub trait Outcome {
43    fn outcome(self) -> Result<ExitCode, CommandError>;
44}
45
46impl Outcome for () {
47    fn outcome(self) -> Result<ExitCode, CommandError> {
48        Ok(ExitCode::SUCCESS)
49    }
50}
51
52impl Outcome for ExitCode {
53    fn outcome(self) -> Result<ExitCode, CommandError> {
54        Ok(self)
55    }
56}
57
58impl Outcome for u8 {
59    fn outcome(self) -> Result<ExitCode, CommandError> {
60        Ok(ExitCode::from(self))
61    }
62}
63
64impl<T: Outcome, E: Into<CommandError>> Outcome for Result<T, E> {
65    fn outcome(self) -> Result<ExitCode, CommandError> {
66        self.map_err(Into::into).and_then(Outcome::outcome)
67    }
68}
69
70#[derive(Debug, PartialEq)]
71pub enum Unmatched {
72    Run,
73    Command(Vec<String>),
74    Exit(ExitCode),
75}
76
77pub const BUILT_IN: [&str; 3] = ["edit", "help", "version"];
78
79pub const TOOLS_MARKER: &str =
80    "pfx-game tools build: the launcher's commands and pfx's editor are linked in";
81
82#[derive(Default)]
83pub struct Commands {
84    #[cfg(feature = "tools")]
85    list: Vec<(String, String, Command)>,
86    #[cfg(feature = "tools")]
87    edit: Option<(String, Edit)>,
88    #[cfg(feature = "tools")]
89    problems: Vec<String>,
90    #[cfg(feature = "tools")]
91    rest: Option<Rest>,
92    #[cfg(feature = "tools")]
93    shapes: Vec<(String, String)>,
94}
95
96impl Commands {
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    #[cfg(feature = "tools")]
102    pub fn add<R: Outcome>(
103        &mut self,
104        name: &str,
105        about: &str,
106        command: impl Fn(&[String]) -> R + 'static,
107    ) -> &mut Self {
108        if let Some(problem) = self.refuse(name) {
109            self.problems.push(problem);
110        } else {
111            self.list.push((
112                name.to_string(),
113                about.to_string(),
114                Box::new(move |args: &[String]| command(args).outcome()),
115            ));
116        }
117        self
118    }
119
120    #[cfg(not(feature = "tools"))]
121    pub fn add<R: Outcome>(
122        &mut self,
123        _name: &str,
124        _about: &str,
125        _command: impl Fn(&[String]) -> R + 'static,
126    ) -> &mut Self {
127        self
128    }
129
130    #[cfg(feature = "tools")]
131    pub fn usage(&mut self, command: &str, shape: &str) -> &mut Self {
132        self.shapes.retain(|(name, _)| name != command);
133        self.shapes.push((command.to_string(), shape.to_string()));
134        self
135    }
136
137    #[cfg(not(feature = "tools"))]
138    pub fn usage(&mut self, _command: &str, _shape: &str) -> &mut Self {
139        self
140    }
141
142    #[cfg(feature = "tools")]
143    fn shape(&self, command: &str) -> Option<&str> {
144        self.shapes
145            .iter()
146            .find(|(name, _)| name == command)
147            .map(|(_, shape)| shape.as_str())
148    }
149
150    #[cfg(feature = "tools")]
151    fn usage_line(&self, name: &str, command: &str) -> String {
152        let shape = self.shape(command).unwrap_or("[ARGS]...");
153        format!("{name} {command} {shape}").trim_end().to_string()
154    }
155
156    #[cfg(feature = "tools")]
157    fn takes_nothing(&self, command: &str, args: &[String]) -> Option<String> {
158        match (self.shape(command), args.first()) {
159            (Some(""), Some(extra)) => Some(cli::unexpected(extra)),
160            _ => None,
161        }
162    }
163
164    #[cfg(feature = "tools")]
165    pub fn unmatched(
166        &mut self,
167        hook: impl FnOnce(&[String]) -> Result<Unmatched, CommandError> + 'static,
168    ) -> &mut Self {
169        self.rest = Some(Box::new(hook));
170        self
171    }
172
173    #[cfg(not(feature = "tools"))]
174    pub fn unmatched(
175        &mut self,
176        _hook: impl FnOnce(&[String]) -> Result<Unmatched, CommandError> + 'static,
177    ) -> &mut Self {
178        self
179    }
180
181    #[cfg(feature = "tools")]
182    pub fn edit(
183        &mut self,
184        about: &str,
185        hook: impl FnOnce(Factory, &[String]) -> Result<(), String> + 'static,
186    ) -> &mut Self {
187        self.edit = Some((about.to_string(), Box::new(hook)));
188        self
189    }
190
191    #[cfg(not(feature = "tools"))]
192    pub fn edit(
193        &mut self,
194        _about: &str,
195        _hook: impl FnOnce(Factory, &[String]) -> Result<(), String> + 'static,
196    ) -> &mut Self {
197        self
198    }
199
200    #[cfg(feature = "tools")]
201    fn refuse(&self, name: &str) -> Option<String> {
202        if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) {
203            return Some(format!(
204                "command {name:?}: a name is one word that does not start with '-'"
205            ));
206        }
207        if BUILT_IN.contains(&name) {
208            return Some(format!("command {name:?}: that name is built in"));
209        }
210        if self.list.iter().any(|(taken, _, _)| taken == name) {
211            return Some(format!("command {name:?}: registered twice"));
212        }
213        None
214    }
215
216    #[cfg(feature = "tools")]
217    pub fn names(&self) -> Vec<&str> {
218        self.list.iter().map(|(name, _, _)| name.as_str()).collect()
219    }
220
221    #[cfg(feature = "tools")]
222    pub fn help(&self, name: &str, version: &str) -> String {
223        let mut rows: Vec<(&str, &str)> = Vec::new();
224        if let Some((about, _)) = &self.edit {
225            rows.push(("edit", about));
226        }
227        rows.push(("help", "lists these commands"));
228        rows.push(("version", "prints the game's version"));
229        rows.extend(
230            self.list
231                .iter()
232                .map(|(name, about, _)| (name.as_str(), about.as_str())),
233        );
234        let width = rows.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
235        let mut text = format!(
236            "{name} {version}\n\nUsage: {name} [COMMAND]\n\nWith no command, {name} runs the game.\n\nCommands:\n"
237        );
238        for (command, about) in rows {
239            text.push_str(&format!("  {command:<width$}  {about}\n"));
240        }
241        text
242    }
243}
244
245pub enum Decision {
246    Run,
247    Done(ExitCode),
248    #[cfg(feature = "tools")]
249    Edit(Edit, Vec<String>),
250}
251
252impl std::fmt::Debug for Decision {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            Decision::Run => f.write_str("Run"),
256            Decision::Done(code) => write!(f, "Done({code:?})"),
257            #[cfg(feature = "tools")]
258            Decision::Edit(_, args) => write!(f, "Edit({args:?})"),
259        }
260    }
261}
262
263#[cfg(feature = "tools")]
264pub fn decide(
265    args: &[String],
266    name: &str,
267    version: &str,
268    register: impl FnOnce(&mut Commands),
269    out: &mut impl std::io::Write,
270    err: &mut impl std::io::Write,
271) -> Decision {
272    decide_in(args, name, version, register, out, err, false)
273}
274
275#[cfg(feature = "tools")]
276fn decide_in(
277    args: &[String],
278    name: &str,
279    version: &str,
280    register: impl FnOnce(&mut Commands),
281    out: &mut impl std::io::Write,
282    err: &mut impl std::io::Write,
283    color: bool,
284) -> Decision {
285    std::hint::black_box(TOOLS_MARKER);
286    if args.is_empty() {
287        return Decision::Run;
288    }
289    let mut commands = Commands::new();
290    register(&mut commands);
291    for (command, _) in &commands.shapes {
292        if command != "edit" && !commands.list.iter().any(|(taken, _, _)| taken == command) {
293            commands
294                .problems
295                .push(format!("usage {command:?}: no such command"));
296        }
297    }
298    if !commands.problems.is_empty() {
299        for problem in &commands.problems {
300            let _ = writeln!(err, "{name}: {problem}");
301        }
302        return Decision::Done(ExitCode::from(USAGE));
303    }
304    let mut talk = Talk {
305        name,
306        version,
307        out,
308        err,
309        color,
310    };
311    route(&mut commands, args, &mut talk)
312}
313
314#[cfg(feature = "tools")]
315struct Talk<'a, O, E> {
316    name: &'a str,
317    version: &'a str,
318    out: &'a mut O,
319    err: &'a mut E,
320    color: bool,
321}
322
323#[cfg(feature = "tools")]
324impl<O: std::io::Write, E: std::io::Write> Talk<'_, O, E> {
325    fn refuse(&mut self, message: String, usage: String) -> Decision {
326        let refusal = Refusal::new(message).under(usage);
327        let _ = writeln!(self.err, "{}", refusal.render(self.color));
328        Decision::Done(ExitCode::from(USAGE))
329    }
330
331    fn top(&self) -> String {
332        format!("{} [COMMAND]", self.name)
333    }
334}
335
336#[cfg(feature = "tools")]
337fn route<O: std::io::Write, E: std::io::Write>(
338    commands: &mut Commands,
339    args: &[String],
340    talk: &mut Talk<'_, O, E>,
341) -> Decision {
342    let Some((first, rest)) = args.split_first() else {
343        return Decision::Run;
344    };
345    let name = talk.name;
346    match first.as_str() {
347        "help" | "--help" | "-h" => {
348            let _ = write!(talk.out, "{}", commands.help(name, talk.version));
349            Decision::Done(ExitCode::SUCCESS)
350        }
351        "version" | "--version" | "-V" => {
352            let _ = writeln!(talk.out, "{name} {}", talk.version);
353            Decision::Done(ExitCode::SUCCESS)
354        }
355        "edit" => match (commands.takes_nothing("edit", rest), commands.edit.take()) {
356            (_, None) => talk.refuse(cli::unrecognized("edit"), talk.top()),
357            (Some(message), Some(_)) => talk.refuse(message, commands.usage_line(name, "edit")),
358            (None, Some((_, hook))) => Decision::Edit(hook, rest.to_vec()),
359        },
360        command => {
361            if let Some((_, _, run)) = commands.list.iter().find(|(taken, _, _)| taken == command) {
362                if let Some(message) = commands.takes_nothing(command, rest) {
363                    return talk.refuse(message, commands.usage_line(name, command));
364                }
365                return match run(rest) {
366                    Ok(code) => Decision::Done(code),
367                    Err(error) if error.code == USAGE => {
368                        talk.refuse(error.message, commands.usage_line(name, command))
369                    }
370                    Err(error) => {
371                        let _ = writeln!(talk.err, "{name} {command}: {}", error.message);
372                        Decision::Done(ExitCode::from(error.code))
373                    }
374                };
375            }
376            match commands.rest.take() {
377                Some(hook) => match hook(args) {
378                    Ok(Unmatched::Run) => Decision::Run,
379                    Ok(Unmatched::Exit(code)) => Decision::Done(code),
380                    Ok(Unmatched::Command(again)) => route(commands, &again, talk),
381                    Err(error) => {
382                        let _ = writeln!(talk.err, "{name}: {}", error.message);
383                        Decision::Done(ExitCode::from(error.code))
384                    }
385                },
386                None if command.starts_with('-') => {
387                    talk.refuse(cli::unexpected(command), talk.top())
388                }
389                None => talk.refuse(cli::unrecognized(command), talk.top()),
390            }
391        }
392    }
393}
394
395#[cfg(not(feature = "tools"))]
396pub fn decide(
397    _args: &[String],
398    _name: &str,
399    _version: &str,
400    _register: impl FnOnce(&mut Commands),
401    _out: &mut impl std::io::Write,
402    _err: &mut impl std::io::Write,
403) -> Decision {
404    Decision::Run
405}
406
407pub fn factory<G: Game + 'static>(make: impl Fn() -> G + 'static) -> Factory {
408    Rc::new(move || Box::new(make()) as Box<dyn Game>)
409}
410
411fn play<G: Game + 'static>(game: G, config: Config) -> ExitCode {
412    match crate::host::run(game, config) {
413        Ok(code) => ExitCode::from(code),
414        Err(_) => ExitCode::FAILURE,
415    }
416}
417
418#[cfg(feature = "tools")]
419pub fn edit(name: &str, hook: Edit, factory: Factory, args: &[String]) -> ExitCode {
420    match hook(factory, args) {
421        Ok(()) => ExitCode::SUCCESS,
422        Err(error) => {
423            eprintln!("{name} edit: {error}");
424            ExitCode::FAILURE
425        }
426    }
427}
428
429#[cfg(feature = "tools")]
430pub fn launch<G: Game + 'static>(
431    make: impl Fn() -> G + 'static,
432    config: Config,
433    register: impl FnOnce(&mut Commands),
434) -> ExitCode {
435    let args: Vec<String> = std::env::args().skip(1).collect();
436    let decision = decide_in(
437        &args,
438        &config.name,
439        &config.version,
440        register,
441        &mut std::io::stdout(),
442        &mut std::io::stderr(),
443        cli::color(),
444    );
445    match decision {
446        Decision::Run => play(make(), config),
447        Decision::Done(code) => code,
448        Decision::Edit(hook, rest) => edit(&config.name, hook, factory(make), &rest),
449    }
450}
451
452#[cfg(not(feature = "tools"))]
453pub fn launch<G: Game + 'static>(
454    make: impl Fn() -> G + 'static,
455    config: Config,
456    _register: impl FnOnce(&mut Commands),
457) -> ExitCode {
458    play(make(), config)
459}