Skip to main content

wolf_project/
layout.rs

1//! The project layout (spec chapter 6, sections 1 and 7).
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use wolf_diag::{Diagnostic, codes};
7
8use crate::manifest::{Kind, Manifest};
9
10/// What a source file is for.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Role {
13    /// `src/main.wolf` or `src/lib.wolf`.
14    Entry,
15    /// Another file under `src/`.
16    Src,
17    /// A file under `tests/`.
18    Test,
19    /// A file under `examples/`.
20    Example,
21}
22
23/// One `.wolf` file of a project.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct ModuleFile {
26    /// The module path derived from the file path, for example `["users", "storage"]`.
27    pub module: Vec<String>,
28    /// The role of the file.
29    pub role: Role,
30    /// The file on disk.
31    pub path: PathBuf,
32    /// The path relative to the project root with `/`, as shown in diagnostics.
33    pub display: String,
34}
35
36/// Reserved words that cannot be module path segments (spec chapter 1, section 4).
37const RESERVED: &[&str] = &[
38    "module",
39    "extern",
40    "impl",
41    "fn",
42    "pub",
43    "let",
44    "var",
45    "type",
46    "struct",
47    "enum",
48    "interface",
49    "if",
50    "else",
51    "match",
52    "while",
53    "for",
54    "in",
55    "return",
56    "break",
57    "continue",
58    "mut",
59    "own",
60    "self",
61    "true",
62    "false",
63    "async",
64    "await",
65];
66
67/// Finds the project root: the nearest directory, starting at `start`, containing
68/// `wolfpack.toml` (spec chapter 6, rule 6.6).
69pub fn find_root(start: &Path) -> Option<PathBuf> {
70    let mut dir = Some(start);
71    while let Some(current) = dir {
72        if current.join("wolfpack.toml").is_file() {
73            return Some(current.to_path_buf());
74        }
75        dir = current.parent();
76    }
77    None
78}
79
80/// Lists the `.wolf` files of the project in a fixed order and reports layout errors (E0612).
81pub fn discover(root: &Path, manifest: &Manifest) -> (Vec<ModuleFile>, Vec<Diagnostic>) {
82    let mut out = Walk {
83        root: root.to_path_buf(),
84        files: Vec::new(),
85        diagnostics: Vec::new(),
86    };
87    let src = root.join("src");
88    if !src.is_dir() {
89        out.diagnostics.push(
90            Diagnostic::new(codes::E0612, "the project has no `src/` directory")
91                .help("create `src/` with the entry file"),
92        );
93        return (out.files, out.diagnostics);
94    }
95    out.walk(&src, &mut Vec::new(), Area::Src);
96    for (dir, area, prefix) in [
97        ("tests", Area::Tests, "tests"),
98        ("examples", Area::Examples, "examples"),
99    ] {
100        let path = root.join(dir);
101        if path.is_dir() {
102            out.walk(&path, &mut vec![prefix.to_string()], area);
103        }
104    }
105    out.check_entry(manifest);
106    out.files.sort_by(|a, b| a.display.cmp(&b.display));
107    (out.files, out.diagnostics)
108}
109
110#[derive(Clone, Copy, PartialEq, Eq)]
111enum Area {
112    Src,
113    Tests,
114    Examples,
115}
116
117struct Walk {
118    root: PathBuf,
119    files: Vec<ModuleFile>,
120    diagnostics: Vec<Diagnostic>,
121}
122
123impl Walk {
124    fn display(&self, path: &Path) -> String {
125        path.strip_prefix(&self.root)
126            .unwrap_or(path)
127            .to_string_lossy()
128            .replace('\\', "/")
129    }
130
131    fn layout_error(&mut self, message: String, help: &str) {
132        self.diagnostics
133            .push(Diagnostic::new(codes::E0612, message).help(help.to_string()));
134    }
135
136    fn walk(&mut self, dir: &Path, module: &mut Vec<String>, area: Area) {
137        let Ok(entries) = fs::read_dir(dir) else {
138            let shown = self.display(dir);
139            self.diagnostics.push(Diagnostic::new(
140                codes::E0001,
141                format!("cannot read `{shown}`"),
142            ));
143            return;
144        };
145        let mut children: Vec<(PathBuf, bool, bool)> = entries
146            .flatten()
147            .map(|e| {
148                let file_type = e.file_type().ok();
149                let is_dir = file_type.is_some_and(|t| t.is_dir());
150                let is_link = file_type.is_some_and(|t| t.is_symlink());
151                (e.path(), is_dir, is_link)
152            })
153            .collect();
154        children.sort();
155        let names: Vec<String> = children
156            .iter()
157            .filter_map(|(p, _, _)| p.file_name().and_then(|n| n.to_str()).map(str::to_string))
158            .collect();
159        for (path, is_dir, is_link) in children {
160            let shown = self.display(&path);
161            let Some(name) = path
162                .file_name()
163                .and_then(|n| n.to_str())
164                .map(str::to_string)
165            else {
166                self.layout_error(
167                    format!("file name is not valid UTF-8: `{shown}`"),
168                    "rename the file using ASCII letters, digits and `_`",
169                );
170                continue;
171            };
172            if name.starts_with('.') {
173                continue;
174            }
175            if is_link {
176                // Symbolic links are not followed, so the walk cannot loop.
177                continue;
178            }
179            if is_dir {
180                if !self.check_segment(&name, &shown) {
181                    continue;
182                }
183                if area == Area::Src
184                    && module.is_empty()
185                    && ["std", "tests", "examples"].contains(&name.as_str())
186                {
187                    self.layout_error(
188                        format!("`{shown}/` uses the reserved module name `{name}`"),
189                        "`std`, `tests` and `examples` are reserved root names; rename the directory",
190                    );
191                    continue;
192                }
193                // `src/users.wolf` next to `src/users/` would be an index file (rule 1.5).
194                if names.iter().any(|n| *n == format!("{name}.wolf")) {
195                    self.layout_error(
196                        format!("`{shown}.wolf` and the directory `{shown}/` both exist"),
197                        "a directory is only a namespace; move the code of the file into a module inside the directory",
198                    );
199                }
200                module.push(name);
201                self.walk(&path, module, area);
202                module.pop();
203                continue;
204            }
205            let Some(stem) = name.strip_suffix(".wolf") else {
206                if area == Area::Src {
207                    self.layout_error(
208                        format!("unexpected file `{shown}`"),
209                        "`src/` contains only `.wolf` files and directories",
210                    );
211                }
212                continue;
213            };
214            if !self.check_segment(stem, &shown) {
215                continue;
216            }
217            let mut module_path = module.clone();
218            module_path.push(stem.to_string());
219            let role = match area {
220                Area::Src if module.is_empty() && (stem == "main" || stem == "lib") => Role::Entry,
221                Area::Src => Role::Src,
222                Area::Tests => Role::Test,
223                Area::Examples => Role::Example,
224            };
225            if area == Area::Src && module.is_empty() {
226                if let Some(reserved) = ["std", "tests", "examples"].iter().find(|r| **r == stem) {
227                    self.layout_error(
228                        format!("`{shown}` uses the reserved module name `{reserved}`"),
229                        "`std`, `tests` and `examples` are reserved root names; rename the file",
230                    );
231                    continue;
232                }
233            }
234            self.files.push(ModuleFile {
235                module: module_path,
236                role,
237                path,
238                display: shown,
239            });
240        }
241    }
242
243    /// File and directory names are module path segments: `snake_case`, not a keyword.
244    fn check_segment(&mut self, name: &str, shown: &str) -> bool {
245        if RESERVED.contains(&name) {
246            self.layout_error(
247                format!("`{shown}` uses the keyword `{name}` as a module name"),
248                "rename it",
249            );
250            return false;
251        }
252        if !is_snake_case(name) {
253            self.layout_error(
254                format!("`{shown}` is not a valid module name"),
255                "file and directory names are `snake_case`: lower-case words separated by `_`",
256            );
257            return false;
258        }
259        true
260    }
261
262    fn check_entry(&mut self, manifest: &Manifest) {
263        let has = |files: &[ModuleFile], name: &str| {
264            files
265                .iter()
266                .any(|f| f.role == Role::Entry && f.module == [name.to_string()])
267        };
268        let (wanted, other) = match manifest.kind {
269            Kind::Cli => ("main", "lib"),
270            Kind::Lib => ("lib", "main"),
271        };
272        let kind = match manifest.kind {
273            Kind::Cli => "cli",
274            Kind::Lib => "lib",
275        };
276        if !has(&self.files, wanted) {
277            self.diagnostics.push(
278                Diagnostic::new(
279                    codes::E0612,
280                    format!("missing entry file `src/{wanted}.wolf`"),
281                )
282                .primary(
283                    manifest.kind_span,
284                    format!("a `{kind}` project needs `src/{wanted}.wolf`"),
285                ),
286            );
287        }
288        if has(&self.files, other) {
289            self.diagnostics.push(
290                Diagnostic::new(
291                    codes::E0612,
292                    format!("unexpected entry file `src/{other}.wolf`"),
293                )
294                .primary(
295                    manifest.kind_span,
296                    format!("a `{kind}` project has no `src/{other}.wolf`"),
297                )
298                .help(format!(
299                    "rename the file, or change `kind` to `\"{}\"`",
300                    if other == "main" { "cli" } else { "lib" }
301                )),
302            );
303            // The wrong entry is treated as an ordinary module.
304            for file in &mut self.files {
305                if file.role == Role::Entry && file.module == [other.to_string()] {
306                    file.role = Role::Src;
307                }
308            }
309        }
310    }
311}
312
313/// `[a-z][a-z0-9]*(_[a-z0-9]+)*` (spec chapter 1, section 3).
314pub fn is_snake_case(name: &str) -> bool {
315    let mut parts = name.split('_');
316    let first_ok = parts.next().is_some_and(|p| {
317        p.starts_with(|c: char| c.is_ascii_lowercase())
318            && p.chars()
319                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
320    });
321    first_ok
322        && parts.all(|p| {
323            !p.is_empty()
324                && p.chars()
325                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
326        })
327}