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