Skip to main content

cove_sema/
package.rs

1//! Discovering and loading a Cove package from disk.
2//!
3//! A package is rooted at the nearest `cove.toml`. Module paths are relative to
4//! that root: each directory containing `.cove` files is one module, and every
5//! `.cove` file in it is an implementation unit of that module.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use cove_diag::{Diagnostic, FileId, SourceMap, Span};
11use cove_syntax::ast::SourceUnit;
12
13use crate::config::{self, Config};
14use crate::stdlib;
15
16/// One parsed `.cove` file.
17#[derive(Debug)]
18pub struct Unit {
19    pub file: FileId,
20    pub path: PathBuf,
21    pub ast: SourceUnit,
22}
23
24/// One directory of `.cove` files.
25#[derive(Debug)]
26pub struct Module {
27    /// Dotted name derived from the directory path, such as `hello` or
28    /// `booking.create`.
29    pub name: String,
30    pub dir: PathBuf,
31    pub units: Vec<Unit>,
32}
33
34/// A loaded package: its configuration and every module below its root.
35#[derive(Debug)]
36pub struct Package {
37    pub root: PathBuf,
38    pub config: Config,
39    pub modules: BTreeMap<String, Module>,
40}
41
42/// Loads the package rooted at `root`, reading every source file it contains
43/// into `sources`.
44pub fn load(root: &Path, sources: &mut SourceMap) -> Result<Package, Vec<Diagnostic>> {
45    let config_path = root.join("cove.toml");
46    let text = std::fs::read_to_string(&config_path).map_err(|e| {
47        vec![Diagnostic::error(
48            "cove::package::config",
49            format!("cannot read `{}`: {e}", config_path.display()),
50        )]
51    })?;
52    let config =
53        config::parse(&text).map_err(|e| vec![Diagnostic::error("cove::package::config", e)])?;
54
55    let mut modules = BTreeMap::new();
56    let mut diagnostics = Vec::new();
57    walk(root, root, &mut modules, sources, &mut diagnostics);
58
59    match stdlib::attach(sources) {
60        Ok(std_modules) => {
61            for (name, module) in std_modules {
62                modules.insert(name, module);
63            }
64        }
65        Err(errs) => diagnostics.extend(errs),
66    }
67
68    if diagnostics.is_empty() {
69        Ok(Package {
70            root: root.to_path_buf(),
71            config,
72            modules,
73        })
74    } else {
75        Err(diagnostics)
76    }
77}
78
79/// Recursively visits `dir`, turning every directory with `.cove` files
80/// directly inside it into a module.
81///
82/// A subdirectory holding its own `cove.toml` is a nested package, not a
83/// module of this one: the walk does not enter it, so its own check-time
84/// errors cannot fail resolution of an unrelated outer package.
85fn walk(
86    root: &Path,
87    dir: &Path,
88    modules: &mut BTreeMap<String, Module>,
89    sources: &mut SourceMap,
90    diagnostics: &mut Vec<Diagnostic>,
91) {
92    let entries = match std::fs::read_dir(dir) {
93        Ok(entries) => entries,
94        Err(e) => {
95            diagnostics.push(Diagnostic::error(
96                "cove::package::io",
97                format!("cannot read `{}`: {e}", dir.display()),
98            ));
99            return;
100        }
101    };
102
103    let mut names: Vec<std::ffi::OsString> = Vec::new();
104    for entry in entries {
105        let Ok(entry) = entry else { continue };
106        names.push(entry.file_name());
107    }
108    names.sort();
109
110    let mut cove_files = Vec::new();
111    let mut subdirs = Vec::new();
112    for name in names {
113        let path = dir.join(&name);
114        if path.is_dir() {
115            let name_str = name.to_string_lossy();
116            if name_str.starts_with('.') || name_str == "target" {
117                continue;
118            }
119            subdirs.push(path);
120        } else if path.extension().and_then(|e| e.to_str()) == Some("cove") {
121            cove_files.push(path);
122        }
123    }
124
125    if !cove_files.is_empty() {
126        handle_module_dir(root, dir, &cove_files, modules, sources, diagnostics);
127    }
128
129    for subdir in subdirs {
130        if subdir.join("cove.toml").is_file() {
131            continue;
132        }
133        walk(root, &subdir, modules, sources, diagnostics);
134    }
135}
136
137fn handle_module_dir(
138    root: &Path,
139    dir: &Path,
140    cove_files: &[PathBuf],
141    modules: &mut BTreeMap<String, Module>,
142    sources: &mut SourceMap,
143    diagnostics: &mut Vec<Diagnostic>,
144) {
145    let rel = dir
146        .strip_prefix(root)
147        .expect("walk only visits descendants of root");
148
149    if rel.as_os_str().is_empty() {
150        for file in cove_files {
151            let text = match std::fs::read_to_string(file) {
152                Ok(text) => text,
153                Err(e) => {
154                    diagnostics.push(Diagnostic::error(
155                        "cove::package::io",
156                        format!("cannot read `{}`: {e}", file.display()),
157                    ));
158                    continue;
159                }
160            };
161            let file_id = sources.add(file.clone(), text.clone());
162            diagnostics.push(
163                Diagnostic::error(
164                    "cove::package::root_module",
165                    format!(
166                        "`{}` has no module: source must live in a directory",
167                        file.display()
168                    ),
169                )
170                .at(Span::new(file_id, 0, text.len() as u32))
171                .rule(
172                    "A directory is a module; `.cove` files directly in the package root are not.",
173                )
174                .help(format!(
175                    "Move `{}` into a subdirectory such as `src/{}`.",
176                    file.display(),
177                    file.file_name()
178                        .map(|n| n.to_string_lossy().into_owned())
179                        .unwrap_or_default()
180                )),
181            );
182        }
183        return;
184    }
185
186    let components: Vec<String> = rel
187        .components()
188        .map(|c| c.as_os_str().to_string_lossy().into_owned())
189        .collect();
190
191    if let Some(invalid) = components.iter().find(|c| !is_valid_identifier(c)) {
192        diagnostics.push(
193            Diagnostic::error(
194                "cove::package::module_name",
195                format!(
196                    "`{invalid}` is not a valid module name component in `{}`",
197                    dir.display()
198                ),
199            )
200            .rule(
201                "A module name is derived from its directory path and must be a valid Cove identifier.",
202            )
203            .help(format!(
204                "Rename the `{invalid}` directory to match `[A-Za-z_][A-Za-z0-9_]*`."
205            )),
206        );
207        return;
208    }
209
210    let name = components.join(".");
211    let mut units = Vec::new();
212    for file in cove_files {
213        let text = match std::fs::read_to_string(file) {
214            Ok(text) => text,
215            Err(e) => {
216                diagnostics.push(Diagnostic::error(
217                    "cove::package::io",
218                    format!("cannot read `{}`: {e}", file.display()),
219                ));
220                continue;
221            }
222        };
223        let file_id = sources.add(file.clone(), text);
224        match cove_syntax::parse_file(sources, file_id) {
225            Ok(ast) => units.push(Unit {
226                file: file_id,
227                path: file.clone(),
228                ast,
229            }),
230            Err(errs) => diagnostics.extend(errs),
231        }
232    }
233
234    modules.insert(
235        name.clone(),
236        Module {
237            name,
238            dir: dir.to_path_buf(),
239            units,
240        },
241    );
242}
243
244/// Whether `s` matches `[A-Za-z_][A-Za-z0-9_]*`.
245fn is_valid_identifier(s: &str) -> bool {
246    let mut chars = s.chars();
247    match chars.next() {
248        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
249        _ => return false,
250    }
251    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    struct TempDir(PathBuf);
259
260    impl TempDir {
261        fn new(name: &str) -> Self {
262            let dir = std::env::temp_dir().join(format!(
263                "cove-sema-test-{name}-{}-{}",
264                std::process::id(),
265                nanos()
266            ));
267            std::fs::create_dir_all(&dir).unwrap();
268            TempDir(dir)
269        }
270
271        fn path(&self) -> &Path {
272            &self.0
273        }
274    }
275
276    impl Drop for TempDir {
277        fn drop(&mut self) {
278            let _ = std::fs::remove_dir_all(&self.0);
279        }
280    }
281
282    fn nanos() -> u128 {
283        std::time::SystemTime::now()
284            .duration_since(std::time::UNIX_EPOCH)
285            .unwrap()
286            .as_nanos()
287    }
288
289    fn write(dir: &Path, rel: &str, text: &str) {
290        let path = dir.join(rel);
291        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
292        std::fs::write(path, text).unwrap();
293    }
294
295    const FN_MAIN: &str = "export fn main() -> Result<Unit, Error> {\n  Ok(())\n}\n";
296
297    #[test]
298    fn discovers_one_module_per_directory() {
299        let dir = TempDir::new("modules");
300        write(
301            dir.path(),
302            "cove.toml",
303            "[run.hello]\nentry = \"hello.main\"\n",
304        );
305        write(dir.path(), "hello/main.cove", FN_MAIN);
306        write(dir.path(), "src/booking/create.cove", FN_MAIN);
307
308        let mut sources = SourceMap::new();
309        let package = load(dir.path(), &mut sources).expect("loads");
310        let mut names: Vec<&String> = package
311            .modules
312            .keys()
313            .filter(|name| !stdlib::module_names().contains(&name.as_str()))
314            .collect();
315        names.sort();
316        assert_eq!(names, vec!["hello", "src.booking"]);
317    }
318
319    #[test]
320    fn skips_hidden_and_target_directories() {
321        let dir = TempDir::new("skips");
322        write(
323            dir.path(),
324            "cove.toml",
325            "[run.hello]\nentry = \"hello.main\"\n",
326        );
327        write(dir.path(), "hello/main.cove", FN_MAIN);
328        write(dir.path(), ".git/stray.cove", FN_MAIN);
329        write(dir.path(), "target/stray.cove", FN_MAIN);
330        write(dir.path(), "hello/target/stray.cove", FN_MAIN);
331
332        let mut sources = SourceMap::new();
333        let package = load(dir.path(), &mut sources).expect("loads");
334        assert_eq!(
335            package.modules.len(),
336            1 + stdlib::module_names().len(),
337            "{:?}",
338            package.modules.keys().collect::<Vec<_>>()
339        );
340        assert!(package.modules.contains_key("hello"));
341    }
342
343    #[test]
344    fn a_nested_cove_toml_is_a_package_boundary() {
345        let dir = TempDir::new("nested-package");
346        write(
347            dir.path(),
348            "cove.toml",
349            "[run.hello]\nentry = \"hello.main\"\n",
350        );
351        write(dir.path(), "hello/main.cove", FN_MAIN);
352        // A subdirectory with its own `cove.toml` is a separate package.
353        // Its `.cove` files are not modules of this one, so even a file that
354        // could not resolve on its own does not fail this package's load.
355        write(
356            dir.path(),
357            "nested/cove.toml",
358            "[run.hello]\nentry = \"hello.main\"\n",
359        );
360        write(
361            dir.path(),
362            "nested/hello/main.cove",
363            "not valid cove source {{{",
364        );
365
366        let mut sources = SourceMap::new();
367        let package = load(dir.path(), &mut sources).expect("loads");
368        let mut names: Vec<&String> = package
369            .modules
370            .keys()
371            .filter(|name| !stdlib::module_names().contains(&name.as_str()))
372            .collect();
373        names.sort();
374        assert_eq!(names, vec!["hello"]);
375    }
376
377    #[test]
378    fn rejects_cove_file_directly_in_root() {
379        let dir = TempDir::new("root-module");
380        write(
381            dir.path(),
382            "cove.toml",
383            "[run.hello]\nentry = \"hello.main\"\n",
384        );
385        write(dir.path(), "main.cove", FN_MAIN);
386
387        let mut sources = SourceMap::new();
388        let errs = load(dir.path(), &mut sources).unwrap_err();
389        assert!(errs.iter().any(|d| d.code == "cove::package::root_module"));
390    }
391
392    #[test]
393    fn rejects_invalid_module_name() {
394        let dir = TempDir::new("bad-name");
395        write(
396            dir.path(),
397            "cove.toml",
398            "[run.hello]\nentry = \"hello.main\"\n",
399        );
400        write(dir.path(), "not-an-ident/main.cove", FN_MAIN);
401
402        let mut sources = SourceMap::new();
403        let errs = load(dir.path(), &mut sources).unwrap_err();
404        assert!(errs.iter().any(|d| d.code == "cove::package::module_name"));
405    }
406
407    #[test]
408    fn missing_config_produces_a_diagnostic() {
409        let dir = TempDir::new("no-config");
410        let mut sources = SourceMap::new();
411        let errs = load(dir.path(), &mut sources).unwrap_err();
412        assert_eq!(errs.len(), 1);
413        assert_eq!(errs[0].code, "cove::package::config");
414    }
415
416    #[test]
417    fn loads_the_real_examples_package() {
418        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
419        let mut sources = SourceMap::new();
420        let package = load(&root, &mut sources).expect("examples package loads");
421        assert!(package.modules.contains_key("hello"));
422        assert!(package.modules.contains_key("server"));
423    }
424}