1use 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#[derive(Debug)]
18pub struct Unit {
19 pub file: FileId,
20 pub path: PathBuf,
21 pub ast: SourceUnit,
22}
23
24#[derive(Debug)]
26pub struct Module {
27 pub name: String,
30 pub dir: PathBuf,
31 pub units: Vec<Unit>,
32}
33
34#[derive(Debug)]
36pub struct Package {
37 pub root: PathBuf,
38 pub config: Config,
39 pub modules: BTreeMap<String, Module>,
40}
41
42pub 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
79fn 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
244fn 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 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}