Skip to main content

nifty_cli/
dev.rs

1//! `nf dev`: run the app and reload it in place every time one of its source files is saved.
2//! State the new version still declares with the same name and type keeps its value.
3
4use std::cell::RefCell;
5use std::path::{Path, PathBuf};
6use std::process::ExitCode;
7use std::rc::Rc;
8use std::time::Duration;
9
10use nifty_interp::{ErrorHandler, LiveApp, RuntimeError};
11use nifty_platform::PlatformBackend;
12use nifty_runtime::Waker;
13use nifty_syntax::project;
14use notify_debouncer_mini::notify::{RecommendedWatcher, RecursiveMode};
15use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
16
17use crate::error::CliError;
18use crate::frontend::Sources;
19use crate::{CommandResult, report};
20
21/// Editors often save in several steps; changes this close together reload once.
22const SAVE_SETTLE_TIME: Duration = Duration::from_millis(100);
23
24/// The sources of the version showing now, which runtime errors point into.
25type Showing = Rc<RefCell<Option<Rc<Sources>>>>;
26
27pub(crate) fn run(target: &Path) -> CommandResult {
28    let first = Sources::read(target)?;
29    let (folder, mode) = watched(target);
30    let showing = Showing::default();
31    let session = Rc::new(DevSession {
32        target: target.to_path_buf(),
33        live: LiveApp::new(
34            nifty_platform::platform_backend(),
35            report_and_continue(&showing),
36        ),
37        showing,
38        last_read: RefCell::new(None),
39    });
40    session.show(first);
41
42    let waker = session.live.waker(Box::new({
43        let session = Rc::downgrade(&session);
44        move || {
45            if let Some(session) = session.upgrade() {
46                session.reload();
47            }
48        }
49    }));
50    let _watcher = watch(&folder, mode, waker)?;
51    println!(
52        "watching {} for changes; quit the app or press Ctrl-C to stop",
53        folder.display()
54    );
55    session.live.run();
56    Ok(ExitCode::SUCCESS)
57}
58
59struct DevSession {
60    target: PathBuf,
61    live: LiveApp<PlatformBackend>,
62    showing: Showing,
63    /// The sources last read, so saving without changes doesn't reload.
64    last_read: RefCell<Option<Rc<Sources>>>,
65}
66
67impl DevSession {
68    fn reload(&self) {
69        match Sources::read(&self.target) {
70            Ok(sources) => self.show(sources),
71            Err(error) => eprintln!("error: {error}"),
72        }
73    }
74
75    /// Checks and shows `sources` unless they are the ones read last. When they have
76    /// errors, the errors are reported and the version showing before stays.
77    fn show(&self, sources: Sources) {
78        if self.last_read.borrow().as_deref() == Some(&sources) {
79            return;
80        }
81        let sources = Rc::new(sources);
82        self.last_read.replace(Some(Rc::clone(&sources)));
83
84        let was_showing = self.showing.borrow().is_some();
85        if self.load(&sources) {
86            let verb = if was_showing { "reloaded" } else { "started" };
87            println!("{}: {verb}", sources.name());
88            self.showing.replace(Some(sources));
89        } else if was_showing {
90            eprintln!("still showing the last version that worked; save a fix to reload");
91        } else {
92            eprintln!("save a fix to start the app");
93        }
94    }
95
96    /// Checks and shows `sources`, reporting any errors; returns whether it is showing now.
97    fn load(&self, sources: &Sources) -> bool {
98        match sources.compile() {
99            Ok(program) => match self.live.load(program) {
100                Ok(()) => true,
101                Err(error) => {
102                    report::runtime_error(sources, &error);
103                    false
104                }
105            },
106            Err(diagnostics) => {
107                if let Err(error) = report::print(sources, &diagnostics) {
108                    eprintln!("error: {error}");
109                }
110                false
111            }
112        }
113    }
114}
115
116/// While developing, an error in an event is reported and the app keeps running, so the
117/// next save can fix it.
118fn report_and_continue(showing: &Showing) -> ErrorHandler {
119    let showing = Rc::clone(showing);
120    Rc::new(move |error: RuntimeError| match &*showing.borrow() {
121        Some(sources) => report::runtime_error(sources, &error),
122        None => eprintln!("error: {}", error.message),
123    })
124}
125
126/// Where saves show up: a project's whole folder, where files can also be added or removed,
127/// or the folder holding a single file. Watching a folder rather than files catches editors
128/// that save by replacing the file.
129fn watched(target: &Path) -> (PathBuf, RecursiveMode) {
130    if target.is_dir() {
131        return (target.to_path_buf(), RecursiveMode::Recursive);
132    }
133    let folder = match target.parent() {
134        Some(folder) if !folder.as_os_str().is_empty() => folder.to_path_buf(),
135        _ => PathBuf::from("."),
136    };
137    (folder, RecursiveMode::NonRecursive)
138}
139
140/// Wakes the UI thread whenever a source file in `folder` changes.
141fn watch(
142    folder: &Path,
143    mode: RecursiveMode,
144    waker: impl Waker,
145) -> Result<Debouncer<RecommendedWatcher>, CliError> {
146    let watch_error = |error| {
147        CliError::new(format!(
148            "can't watch {} for changes: {error}",
149            folder.display()
150        ))
151    };
152    let mut debouncer =
153        new_debouncer(
154            SAVE_SETTLE_TIME,
155            move |result: DebounceEventResult| match result {
156                Ok(events) => {
157                    if events.iter().any(|event| project::is_source(&event.path)) {
158                        waker.wake();
159                    }
160                }
161                Err(error) => eprintln!("error: watching for changes failed: {error}"),
162            },
163        )
164        .map_err(watch_error)?;
165    debouncer
166        .watcher()
167        .watch(folder, mode)
168        .map_err(watch_error)?;
169    Ok(debouncer)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::test_support::scratch_dir;
176
177    #[test]
178    fn a_single_file_is_watched_in_its_folder() {
179        assert_eq!(
180            watched(Path::new("demo.nf")),
181            (PathBuf::from("."), RecursiveMode::NonRecursive)
182        );
183        assert_eq!(
184            watched(Path::new("apps/demo.nf")),
185            (PathBuf::from("apps"), RecursiveMode::NonRecursive)
186        );
187    }
188
189    #[test]
190    fn a_project_is_watched_with_all_its_subfolders() {
191        let dir = scratch_dir("dev-watch-project");
192        assert_eq!(watched(&dir), (dir.clone(), RecursiveMode::Recursive));
193    }
194
195    #[test]
196    fn only_nf_files_count_as_source() {
197        assert!(project::is_source(Path::new("/app/main.nf")));
198        assert!(!project::is_source(Path::new("/app/main.nf.swp")));
199        assert!(!project::is_source(Path::new("/app/notes.txt")));
200    }
201}