Skip to main content

aoc_runtime/
language.rs

1//! Supported solution languages and the commands that drive them.
2//!
3//! Everything a language needs - its CLI name, base file extension, entry
4//! point and the commands used to scaffold, build and run it - lives in a
5//! single match arm, so adding a language is one variant and one arm.
6
7use crate::process::{CommandSpec, ProcessError};
8use clap::ValueEnum;
9use std::{
10    fmt,
11    path::{Path, PathBuf},
12    str::FromStr,
13};
14
15/// A language a solution can be written in.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
17#[clap(rename_all = "lowercase")]
18pub enum Language {
19    /// Rust, scaffolded and driven through Cargo.
20    Rust,
21    /// C#, scaffolded and driven through the .NET SDK.
22    CSharp,
23    /// Java, compiled with `javac` and run with `java`.
24    Java,
25    /// Python, run directly by the interpreter.
26    Python,
27}
28
29/// The commands used to scaffold, build and run a solution.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LanguageCommands {
32    /// Scaffolding command, if the language needs more than an empty entry file.
33    pub init: Option<CommandSpec>,
34    /// Compilation command, if the language is compiled.
35    pub build: Option<CommandSpec>,
36    /// The command that executes the solution.
37    pub run: CommandSpec,
38    /// An alternative to try if [`LanguageCommands::run`]'s program is missing.
39    pub run_fallback: Option<CommandSpec>,
40}
41
42impl Language {
43    /// Every supported language.
44    pub const ALL: &'static [Self] = &[Self::Rust, Self::CSharp, Self::Java, Self::Python];
45
46    /// The language's canonical lowercase name, as used on the command line and
47    /// in path templates.
48    #[must_use]
49    pub const fn name(self) -> &'static str {
50        match self {
51            Self::Rust => "rust",
52            Self::CSharp => "csharp",
53            Self::Java => "java",
54            Self::Python => "python",
55        }
56    }
57
58    /// The extension of this language's base file in the config directory.
59    #[must_use]
60    pub const fn base_extension(self) -> &'static str {
61        match self {
62            Self::Rust => "rs",
63            Self::CSharp => "cs",
64            Self::Java => "java",
65            Self::Python => "py",
66        }
67    }
68
69    /// The optional starting-point file copied over a freshly scaffolded
70    /// project, for example `~/.config/aoc/base.rs`.
71    #[must_use]
72    pub fn base_file(self, config_dir: &Path) -> PathBuf {
73        config_dir.join(format!("base.{}", self.base_extension()))
74    }
75
76    /// The file inside a project that holds the solution.
77    #[must_use]
78    pub fn entry_file(self, project: &Path) -> PathBuf {
79        match self {
80            Self::Rust => project.join("src").join("main.rs"),
81            Self::CSharp => project.join("Program.cs"),
82            Self::Java => project.join("Main.java"),
83            Self::Python => project.join("main.py"),
84        }
85    }
86
87    /// Builds every command needed to work with a project in this language.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`ProcessError::NoDirectoryName`] if a command needs the
92    /// project's directory name but `project` has none, such as a filesystem
93    /// root or a path ending in `..`.
94    pub fn commands(self, project: &Path) -> Result<LanguageCommands, ProcessError> {
95        let commands = match self {
96            Self::Rust => {
97                let manifest = project.join("Cargo.toml");
98                LanguageCommands {
99                    init: Some(
100                        CommandSpec::new("cargo")
101                            .args(["init", "--bin"])
102                            .arg(project),
103                    ),
104                    build: Some(
105                        CommandSpec::new("cargo")
106                            .args(["build", "--release", "--manifest-path"])
107                            .arg(&manifest),
108                    ),
109                    run: CommandSpec::new("cargo")
110                        .args(["run", "--release", "--quiet", "--manifest-path"])
111                        .arg(&manifest),
112                    run_fallback: None,
113                }
114            }
115            Self::CSharp => {
116                let name = project
117                    .file_name()
118                    .ok_or_else(|| ProcessError::NoDirectoryName {
119                        path: project.to_path_buf(),
120                    })?;
121
122                LanguageCommands {
123                    init: Some(
124                        CommandSpec::new("dotnet")
125                            .args(["new", "console", "--name"])
126                            .arg(name)
127                            .arg("--output")
128                            .arg(project),
129                    ),
130                    build: Some(
131                        CommandSpec::new("dotnet")
132                            .args(["build", "-c", "Release", "--nologo", "-v", "q"])
133                            .arg(project),
134                    ),
135                    run: CommandSpec::new("dotnet")
136                        .args(["run", "-c", "Release", "--no-build", "--project"])
137                        .arg(project),
138                    run_fallback: None,
139                }
140            }
141            Self::Java => LanguageCommands {
142                init: None,
143                build: Some(CommandSpec::new("javac").arg(self.entry_file(project))),
144                run: CommandSpec::new("java").arg("-cp").arg(project).arg("Main"),
145                run_fallback: None,
146            },
147            Self::Python => LanguageCommands {
148                init: None,
149                build: None,
150                run: CommandSpec::new("python3").arg(self.entry_file(project)),
151                run_fallback: Some(CommandSpec::new("python").arg(self.entry_file(project))),
152            },
153        };
154
155        Ok(commands.with_working_dir(project))
156    }
157}
158
159impl LanguageCommands {
160    fn with_working_dir(self, project: &Path) -> Self {
161        Self {
162            init: self.init.map(|spec| spec.current_dir(project)),
163            build: self.build.map(|spec| spec.current_dir(project)),
164            run: self.run.current_dir(project),
165            run_fallback: self.run_fallback.map(|spec| spec.current_dir(project)),
166        }
167    }
168}
169
170impl fmt::Display for Language {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(self.name())
173    }
174}
175
176/// The error returned when a string does not name a supported language.
177#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
178#[error("unknown language `{name}` (expected one of: rust, csharp, java, python)")]
179pub struct UnknownLanguage {
180    /// The unrecognised name.
181    pub name: String,
182}
183
184impl FromStr for Language {
185    type Err = UnknownLanguage;
186
187    fn from_str(value: &str) -> Result<Self, Self::Err> {
188        Self::ALL
189            .iter()
190            .copied()
191            .find(|language| language.name() == value)
192            .ok_or_else(|| UnknownLanguage {
193                name: value.to_owned(),
194            })
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use std::ffi::OsStr;
202
203    fn project() -> PathBuf {
204        PathBuf::from("/aoc/2024/day07/rust")
205    }
206
207    fn args_of(spec: &CommandSpec) -> Vec<String> {
208        spec.arguments()
209            .iter()
210            .map(|arg| arg.to_string_lossy().into_owned())
211            .collect()
212    }
213
214    #[test]
215    fn every_variant_has_commands_and_a_unique_name() {
216        let mut names: Vec<_> = Language::ALL.iter().map(|l| l.name()).collect();
217        names.sort_unstable();
218        names.dedup();
219        assert_eq!(names.len(), Language::ALL.len());
220
221        for language in Language::ALL {
222            let commands = language
223                .commands(&project())
224                .expect("commands should build");
225            assert_eq!(commands.run.working_dir(), Some(project().as_path()));
226        }
227    }
228
229    #[test]
230    fn names_match_the_command_line_values_clap_accepts() {
231        for language in Language::ALL {
232            let value = language
233                .to_possible_value()
234                .expect("every variant is selectable");
235            assert_eq!(value.get_name(), language.name());
236        }
237    }
238
239    #[test]
240    fn parses_from_its_own_name() {
241        for language in Language::ALL {
242            assert_eq!(language.name().parse(), Ok(*language));
243        }
244        assert!("Rust".parse::<Language>().is_err());
245        assert!("c-sharp".parse::<Language>().is_err());
246    }
247
248    #[test]
249    fn rust_runs_the_optimized_build() {
250        let commands = Language::Rust
251            .commands(&project())
252            .expect("commands should build");
253
254        assert_eq!(commands.run.program(), "cargo");
255        let args = args_of(&commands.run);
256        assert!(args.contains(&"run".to_owned()), "{args:?}");
257        assert!(args.contains(&"--release".to_owned()), "{args:?}");
258        let manifest = project().join("Cargo.toml");
259        assert!(
260            args.contains(&manifest.to_string_lossy().into_owned()),
261            "{args:?}"
262        );
263    }
264
265    #[test]
266    fn csharp_names_the_project_after_its_directory() {
267        let commands = Language::CSharp
268            .commands(Path::new("/aoc/2024/day07/csharp"))
269            .expect("commands should build");
270        let init = commands.init.expect("csharp scaffolds with dotnet new");
271
272        assert_eq!(
273            args_of(&init),
274            [
275                "new",
276                "console",
277                "--name",
278                "csharp",
279                "--output",
280                "/aoc/2024/day07/csharp"
281            ]
282        );
283    }
284
285    #[test]
286    fn csharp_run_reuses_the_build_output() {
287        let commands = Language::CSharp
288            .commands(&project())
289            .expect("commands should build");
290
291        assert!(args_of(&commands.run).contains(&"--no-build".to_owned()));
292        assert!(
293            args_of(&commands.build.expect("csharp is compiled")).contains(&"Release".to_owned())
294        );
295    }
296
297    #[test]
298    fn a_project_path_without_a_directory_name_is_an_error() {
299        let error = Language::CSharp
300            .commands(Path::new("/"))
301            .expect_err("root has no directory name");
302
303        assert!(
304            matches!(error, ProcessError::NoDirectoryName { .. }),
305            "got {error:?}"
306        );
307    }
308
309    #[test]
310    fn python_is_interpreted_and_falls_back_to_python2_naming() {
311        let commands = Language::Python
312            .commands(Path::new("/aoc/2024/day07/python"))
313            .expect("commands should build");
314
315        assert!(commands.build.is_none());
316        assert!(commands.init.is_none());
317        assert_eq!(commands.run.program(), "python3");
318        assert_eq!(
319            commands.run_fallback.as_ref().map(CommandSpec::program),
320            Some(OsStr::new("python"))
321        );
322    }
323
324    #[test]
325    fn java_compiles_and_runs_the_main_class() {
326        let path = Path::new("/aoc/2024/day07/java");
327        let commands = Language::Java
328            .commands(path)
329            .expect("commands should build");
330
331        assert!(commands.init.is_none());
332        assert_eq!(
333            args_of(&commands.build.expect("java is compiled")),
334            [path.join("Main.java").to_string_lossy().into_owned()]
335        );
336        assert_eq!(
337            args_of(&commands.run),
338            ["-cp", "/aoc/2024/day07/java", "Main"]
339        );
340    }
341
342    #[test]
343    fn entry_and_base_files_follow_language_conventions() {
344        let config = Path::new("/home/u/.config/aoc");
345        let project = Path::new("/aoc/2024/day07/x");
346
347        let expected = [
348            (
349                Language::Rust,
350                "/aoc/2024/day07/x/src/main.rs",
351                "/home/u/.config/aoc/base.rs",
352            ),
353            (
354                Language::CSharp,
355                "/aoc/2024/day07/x/Program.cs",
356                "/home/u/.config/aoc/base.cs",
357            ),
358            (
359                Language::Java,
360                "/aoc/2024/day07/x/Main.java",
361                "/home/u/.config/aoc/base.java",
362            ),
363            (
364                Language::Python,
365                "/aoc/2024/day07/x/main.py",
366                "/home/u/.config/aoc/base.py",
367            ),
368        ];
369
370        for (language, entry, base) in expected {
371            assert_eq!(language.entry_file(project), Path::new(entry));
372            assert_eq!(language.base_file(config), Path::new(base));
373        }
374    }
375}