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}