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