Skip to main content

nifty_cli/
build.rs

1//! `nf build`: check the app, compile it to machine code, and package it as a native app
2//! for an operating system.
3
4mod macos;
5mod windows;
6
7use std::env;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::process::{self, ExitCode};
12use std::sync::atomic::{AtomicUsize, Ordering};
13
14use nifty_check::hir::Program;
15use nifty_codegen::{RUNTIME_LIBRARY, compile_app, link};
16
17use crate::error::CliError;
18use crate::frontend::Sources;
19use crate::{CommandResult, EXIT_HAS_ERRORS, report};
20
21/// Built apps go in this folder, next to the project's entry file.
22const OUTPUT_DIR: &str = "build";
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25enum Target {
26    MacOs,
27    Windows,
28    Linux,
29}
30
31impl Target {
32    const ALL: [Self; 3] = [Self::MacOs, Self::Windows, Self::Linux];
33
34    /// The system `nf` itself was built for.
35    const HOST: Self = if cfg!(target_os = "macos") {
36        Self::MacOs
37    } else if cfg!(target_os = "windows") {
38        Self::Windows
39    } else {
40        Self::Linux
41    };
42
43    fn flag(self) -> &'static str {
44        match self {
45            Self::MacOs => "--macos",
46            Self::Windows => "--windows",
47            Self::Linux => "--linux",
48        }
49    }
50
51    fn name(self) -> &'static str {
52        match self {
53            Self::MacOs => "macOS",
54            Self::Windows => "Windows",
55            Self::Linux => "Linux",
56        }
57    }
58
59    fn from_flag(flag: &str) -> Option<Self> {
60        Self::ALL.into_iter().find(|target| target.flag() == flag)
61    }
62}
63
64#[derive(Debug, PartialEq)]
65struct Request<'a> {
66    project: &'a Path,
67    target: Target,
68}
69
70pub(crate) fn run(args: &[&str]) -> CommandResult {
71    let request = parse(args)?;
72    // `nf` compiles for the system it runs on, and has only that system's runtime library.
73    if request.target != Target::HOST {
74        return Err(CliError::new(format!(
75            "nf builds apps for the system it runs on, so build {0} apps on {0}",
76            request.target.name()
77        )));
78    }
79    if request.target == Target::Linux {
80        return Err(CliError::new(
81            "Nifty has no Linux backend yet, so it can't build Linux apps",
82        ));
83    }
84    let sources = Sources::read(request.project)?;
85    let program = match sources.compile() {
86        Ok(program) => program,
87        Err(diagnostics) => {
88            report::print(&sources, &diagnostics)?;
89            return Ok(ExitCode::from(EXIT_HAS_ERRORS));
90        }
91    };
92    let name = app_name(request.project)?;
93    let output = output_dir(&sources.entry().path);
94    let app = build(&sources, &program, &name, &output, &runtime_library()?)?;
95    println!("built {}", app.display());
96    Ok(ExitCode::SUCCESS)
97}
98
99/// Compiles and links `program` in a scratch folder, then writes the app around the
100/// executable, so a failure leaves an earlier build as it was. Returns the app.
101fn build(
102    sources: &Sources,
103    program: &Program,
104    name: &str,
105    output: &Path,
106    runtime: &Path,
107) -> Result<PathBuf, CliError> {
108    // Unique to this build, even with other builds running in this process or others.
109    static BUILDS: AtomicUsize = AtomicUsize::new(0);
110    let number = BUILDS.fetch_add(1, Ordering::Relaxed);
111    let scratch = env::temp_dir().join(format!("nf-build-{}-{number}", process::id()));
112    let app =
113        link_in(&scratch, sources, program, name, runtime).and_then(
114            |executable| match Target::HOST {
115                Target::MacOs => macos::write_app(output, name, &executable),
116                Target::Windows | Target::Linux => windows::write_app(output, name, &executable),
117            },
118        );
119    let cleaned = fs::remove_dir_all(&scratch)
120        .map_err(|error| CliError::new(format!("can't remove {}: {error}", scratch.display())));
121    let app = app?;
122    cleaned?;
123    Ok(app)
124}
125
126/// Compiles and links `program` into an executable in `scratch`, and returns its path.
127fn link_in(
128    scratch: &Path,
129    sources: &Sources,
130    program: &Program,
131    name: &str,
132    runtime: &Path,
133) -> Result<PathBuf, CliError> {
134    let failed =
135        |error: io::Error| CliError::new(format!("can't write {}: {error}", scratch.display()));
136    fs::create_dir_all(scratch).map_err(failed)?;
137    let compiled = compile_app(name, program, &|span| sources.locate(span))
138        .map_err(|error| CliError::new(format!("can't compile {name}: {error}")))?;
139    let object = scratch.join("app.o");
140    fs::write(&object, compiled).map_err(failed)?;
141    let executable = scratch.join(format!("app{}", env::consts::EXE_SUFFIX));
142    link(&object, runtime, &executable).map_err(|error| CliError::new(error.to_string()))?;
143    Ok(executable)
144}
145
146fn parse<'a>(args: &[&'a str]) -> Result<Request<'a>, CliError> {
147    let mut project = None;
148    let mut target = None;
149    for arg in args {
150        if arg.starts_with('-') {
151            let flags = Target::ALL.map(Target::flag).join(", ");
152            let chosen = Target::from_flag(arg)
153                .ok_or_else(|| CliError::new(format!("unknown option `{arg}`; use {flags}")))?;
154            if target.replace(chosen).is_some() {
155                return Err(CliError::new(format!(
156                    "build for one system at a time: {flags}"
157                )));
158            }
159        } else if project.replace(Path::new(*arg)).is_some() {
160            return Err(CliError::new("build one project at a time"));
161        }
162    }
163    Ok(Request {
164        project: project.unwrap_or(Path::new(".")),
165        target: target.unwrap_or(Target::HOST),
166    })
167}
168
169/// A project folder's name, or a single file's name without `.nf`.
170fn app_name(project: &Path) -> Result<String, CliError> {
171    let path = fs::canonicalize(project)
172        .map_err(|error| CliError::new(format!("can't find {}: {error}", project.display())))?;
173    let name = if path.is_dir() {
174        path.file_name()
175    } else {
176        path.file_stem()
177    };
178    name.and_then(|name| name.to_str())
179        .map(str::to_owned)
180        .ok_or_else(|| {
181            CliError::new(format!(
182                "can't name an app after {}; give the project folder a plain name",
183                path.display()
184            ))
185        })
186}
187
188fn output_dir(entry: &Path) -> PathBuf {
189    entry.parent().unwrap_or(Path::new("")).join(OUTPUT_DIR)
190}
191
192/// The runtime library every app is linked against, installed next to `nf`. Package
193/// managers put a link to `nf` on the `PATH`, so the lookup starts from where that link
194/// points.
195fn runtime_library() -> Result<PathBuf, CliError> {
196    let nf = env::current_exe()
197        .and_then(fs::canonicalize)
198        .map_err(|error| CliError::new(format!("can't find where nf is installed: {error}")))?;
199    let library = nf.with_file_name(RUNTIME_LIBRARY);
200    if library.is_file() {
201        Ok(library)
202    } else {
203        Err(CliError::new(format!(
204            "{} is missing; reinstall Nifty to get it back",
205            library.display()
206        )))
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use nifty_codegen::testing::runtime_library;
214
215    use crate::test_support::scratch_dir;
216
217    #[test]
218    fn builds_the_current_folder_for_this_system_by_default() {
219        assert_eq!(
220            parse(&[]).expect("no arguments is fine"),
221            Request {
222                project: Path::new("."),
223                target: Target::HOST
224            }
225        );
226    }
227
228    #[test]
229    fn takes_a_project_and_a_target_in_any_order() {
230        let expected = Request {
231            project: Path::new("todos"),
232            target: Target::Windows,
233        };
234        assert_eq!(parse(&["todos", "--windows"]).expect("valid"), expected);
235        assert_eq!(parse(&["--windows", "todos"]).expect("valid"), expected);
236    }
237
238    #[test]
239    fn rejects_unknown_repeated_and_conflicting_arguments() {
240        for args in [
241            &["--win"][..],
242            &["--macos", "--linux"],
243            &["--macos", "--macos"],
244            &["a", "b"],
245        ] {
246            assert!(parse(args).is_err(), "{args:?} should be rejected");
247        }
248    }
249
250    #[test]
251    fn apps_are_named_after_their_folder_or_file() {
252        let dir = scratch_dir("build-name");
253        let folder = dir.join("todos");
254        fs::create_dir(&folder).expect("create project folder");
255        let file = dir.join("demo.nf");
256        fs::write(&file, "").expect("write demo.nf");
257
258        assert_eq!(app_name(&folder).expect("a folder"), "todos");
259        assert_eq!(app_name(&file).expect("a file"), "demo");
260    }
261
262    #[test]
263    fn apps_are_built_next_to_the_entry_file() {
264        assert_eq!(
265            output_dir(Path::new("todos/main.nf")),
266            Path::new("todos/build")
267        );
268        assert_eq!(output_dir(Path::new("main.nf")), Path::new("build"));
269    }
270
271    /// A project in `dir` whose only file is `main.nf`, holding `source`.
272    fn project(dir: &Path, source: &str) -> (PathBuf, Sources, Program) {
273        let project = dir.join("project");
274        fs::create_dir_all(&project).expect("create the project folder");
275        fs::write(project.join("main.nf"), source).expect("write main.nf");
276        let sources = Sources::read(&project).expect("read the project");
277        let program = sources.compile().expect("the project checks");
278        (project, sources, program)
279    }
280
281    /// The executable inside the app `nf build` wrote.
282    fn executable(app: &Path, name: &str) -> PathBuf {
283        match Target::HOST {
284            Target::MacOs => app.join("Contents/MacOS").join(name),
285            Target::Windows | Target::Linux => app.join(format!("{name}.exe")),
286        }
287    }
288
289    #[test]
290    fn builds_the_counter_example() {
291        let dir = scratch_dir("build-counter");
292        let source = include_str!("../../../fixtures/counter.nf");
293        let (_, sources, program) = project(&dir, source);
294
295        let app = build(
296            &sources,
297            &program,
298            "Counter",
299            &dir.join("build"),
300            &runtime_library(),
301        )
302        .unwrap_or_else(|error| panic!("{error}"));
303
304        assert!(executable(&app, "Counter").is_file());
305    }
306
307    #[test]
308    fn built_apps_run_compiled_and_point_errors_at_the_source() {
309        let dir = scratch_dir("build-failing");
310        let source = "fn zero() -> int {\n  return 0\n}\n\nstate count = 1 / zero()\n\nwindow(\"Never\") {}\n";
311        let (_, sources, program) = project(&dir, source);
312
313        let app = build(
314            &sources,
315            &program,
316            "broken",
317            &dir.join("build"),
318            &runtime_library(),
319        )
320        .unwrap_or_else(|error| panic!("{error}"));
321
322        let output = std::process::Command::new(executable(&app, "broken"))
323            .output()
324            .expect("run the app");
325        assert_eq!(output.status.code(), Some(1));
326        assert_eq!(
327            String::from_utf8_lossy(&output.stderr).trim_end(),
328            "main.nf:5:15: can't divide by zero"
329        );
330    }
331}