Skip to main content

nifty_cli/
frontend.rs

1//! Reading a project and turning it into a checked program, shared by every command.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use nifty_check::hir::Program;
7use nifty_rt::SourceLocation;
8use nifty_syntax::{Diagnostic, FileId, Span};
9
10use crate::error::CliError;
11use crate::project::{self, ProjectFile};
12
13/// One source file of a project and its text.
14#[derive(Debug, PartialEq, Eq)]
15pub(crate) struct SourceFile {
16    pub path: PathBuf,
17    /// Where the file sits in its project; see [`ProjectFile::relative`].
18    pub relative: PathBuf,
19    pub text: String,
20}
21
22impl SourceFile {
23    pub(crate) fn name(&self) -> String {
24        self.path.display().to_string()
25    }
26}
27
28/// Every source file of a command target, the entry file first. File `i` is
29/// `FileId::new(i)` in the program's spans.
30#[derive(Debug, PartialEq, Eq)]
31pub(crate) struct Sources {
32    target: PathBuf,
33    files: Vec<SourceFile>,
34}
35
36impl Sources {
37    pub(crate) fn read(target: &Path) -> Result<Self, CliError> {
38        let files = project::source_files(target)?
39            .into_iter()
40            .map(|ProjectFile { path, relative }| {
41                let text = fs::read_to_string(&path).map_err(|error| {
42                    CliError::new(format!("can't read {}: {error}", path.display()))
43                })?;
44                Ok(SourceFile {
45                    path,
46                    relative,
47                    text,
48                })
49            })
50            .collect::<Result<_, CliError>>()?;
51        Ok(Self {
52            target: target.to_path_buf(),
53            files,
54        })
55    }
56
57    /// The project folder or single file, as the user named it.
58    pub(crate) fn name(&self) -> String {
59        self.target.display().to_string()
60    }
61
62    pub(crate) fn entry(&self) -> &SourceFile {
63        self.file(FileId::default())
64    }
65
66    pub(crate) fn files(&self) -> &[SourceFile] {
67        &self.files
68    }
69
70    /// The file a span points into.
71    pub(crate) fn file(&self, id: FileId) -> &SourceFile {
72        &self.files[id.index()]
73    }
74
75    /// Where `span` starts: the file as named in its project, and its line and column,
76    /// counting characters from 1.
77    pub(crate) fn locate(&self, span: Span) -> SourceLocation {
78        let file = self.file(span.file);
79        let before = &file.text[..span.start.min(file.text.len())];
80        let line_start = before.rfind('\n').map_or(0, |newline| newline + 1);
81        SourceLocation {
82            file: file.relative.display().to_string(),
83            line: count(before.matches('\n').count() + 1),
84            column: count(before[line_start..].chars().count() + 1),
85        }
86    }
87
88    pub(crate) fn compile(&self) -> Result<Program, Vec<Diagnostic>> {
89        let parsed: Vec<_> = self
90            .files
91            .iter()
92            .enumerate()
93            .map(|(index, file)| nifty_syntax::parse(&file.text, FileId::new(index)))
94            .collect();
95        nifty_check::compile(&parsed.iter().collect::<Vec<_>>())
96    }
97}
98
99/// A line or column number; one too large for a `u32` is beyond any source Nifty reads.
100fn count(number: usize) -> u32 {
101    u32::try_from(number).unwrap_or(u32::MAX)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    const SPLIT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures/split");
109
110    #[test]
111    fn a_project_split_across_files_compiles_as_one_program() {
112        let sources = Sources::read(Path::new(SPLIT)).expect("the fixture is readable");
113        let relative: Vec<&Path> = sources
114            .files()
115            .iter()
116            .map(|file| file.relative.as_path())
117            .collect();
118        assert_eq!(relative, [Path::new("main.nf"), Path::new("ui/header.nf")]);
119        let program = sources.compile().expect("the fixture checks");
120        assert_eq!(program.views[0].name, "Header");
121    }
122
123    #[test]
124    fn locations_name_the_file_in_its_project_and_count_characters() {
125        let sources = Sources::read(Path::new(SPLIT)).expect("the fixture is readable");
126        let header = &sources.files()[1];
127        let start = header
128            .text
129            .find("view")
130            .expect("the header declares a view");
131        let location = sources.locate(Span::new(FileId::new(1), start, start + 4));
132        let relative = Path::new("ui").join("header.nf");
133        assert_eq!(location.file, relative.display().to_string());
134        let line = header.text[..start].matches('\n').count() + 1;
135        assert_eq!(location.line, u32::try_from(line).expect("a short file"));
136        assert_eq!(location.column, 1);
137    }
138}