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/// The directories of `native/`, one per target (spec chapter 6, section 7, rule 7).
81pub const NATIVE_DIRS: &[&str] = &["ts", "go", "rust"];
82
83/// The files of `native/<target>/` with their paths relative to it, in a fixed order; empty
84/// when the directory does not exist. Hidden files and directories are left out.
85pub fn native_files(root: &Path, target: &str) -> Vec<(PathBuf, PathBuf)> {
86    fn walk(dir: &Path, rel: &Path, out: &mut Vec<(PathBuf, PathBuf)>) {
87        let Ok(entries) = std::fs::read_dir(dir) else {
88            return;
89        };
90        let mut entries: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
91        entries.sort();
92        for path in entries {
93            let Some(name) = path.file_name() else {
94                continue;
95            };
96            if name.to_string_lossy().starts_with('.') {
97                continue;
98            }
99            let rel = rel.join(name);
100            if path.is_dir() {
101                walk(&path, &rel, out);
102            } else {
103                out.push((rel, path));
104            }
105        }
106    }
107    let mut out = Vec::new();
108    walk(&root.join("native").join(target), Path::new(""), &mut out);
109    out
110}
111
112/// Lists the `.wolf` files of the project in a fixed order and reports layout errors (E0612).
113pub fn discover(root: &Path, manifest: &Manifest) -> (Vec<ModuleFile>, Vec<Diagnostic>) {
114    let mut out = Walk {
115        root: root.to_path_buf(),
116        files: Vec::new(),
117        diagnostics: Vec::new(),
118    };
119    let src = root.join("src");
120    if !src.is_dir() {
121        out.diagnostics.push(
122            Diagnostic::new(codes::E0612, "the project has no `src/` directory")
123                .help("create `src/` with the entry file"),
124        );
125        return (out.files, out.diagnostics);
126    }
127    out.walk(&src, &mut Vec::new(), Area::Src);
128    for (dir, area, prefix) in [
129        ("tests", Area::Tests, "tests"),
130        ("examples", Area::Examples, "examples"),
131    ] {
132        let path = root.join(dir);
133        if path.is_dir() {
134            out.walk(&path, &mut vec![prefix.to_string()], area);
135        }
136    }
137    out.check_native();
138    out.check_entry(manifest);
139    out.files.sort_by(|a, b| a.display.cmp(&b.display));
140    (out.files, out.diagnostics)
141}
142
143#[derive(Clone, Copy, PartialEq, Eq)]
144enum Area {
145    Src,
146    Tests,
147    Examples,
148}
149
150struct Walk {
151    root: PathBuf,
152    files: Vec<ModuleFile>,
153    diagnostics: Vec<Diagnostic>,
154}
155
156impl Walk {
157    /// `native/` holds only the directories of the targets.
158    fn check_native(&mut self) {
159        let Ok(entries) = std::fs::read_dir(self.root.join("native")) else {
160            return;
161        };
162        let mut names: Vec<String> = entries
163            .flatten()
164            .map(|e| e.file_name().to_string_lossy().into_owned())
165            .filter(|n| !n.starts_with('.'))
166            .filter(|n| {
167                !(NATIVE_DIRS.contains(&n.as_str()) && self.root.join("native").join(n).is_dir())
168            })
169            .collect();
170        names.sort();
171        for name in names {
172            self.diagnostics.push(
173                Diagnostic::new(codes::E0612, format!("unexpected `native/{name}`"))
174                    .help("`native/` holds only the directories `ts`, `go` and `rust`"),
175            );
176        }
177    }
178
179    fn display(&self, path: &Path) -> String {
180        path.strip_prefix(&self.root)
181            .unwrap_or(path)
182            .to_string_lossy()
183            .replace('\\', "/")
184    }
185
186    fn layout_error(&mut self, message: String, help: &str) {
187        self.diagnostics
188            .push(Diagnostic::new(codes::E0612, message).help(help.to_string()));
189    }
190
191    fn walk(&mut self, dir: &Path, module: &mut Vec<String>, area: Area) {
192        let Ok(entries) = fs::read_dir(dir) else {
193            let shown = self.display(dir);
194            self.diagnostics.push(Diagnostic::new(
195                codes::E0001,
196                format!("cannot read `{shown}`"),
197            ));
198            return;
199        };
200        let mut children: Vec<(PathBuf, bool, bool)> = entries
201            .flatten()
202            .map(|e| {
203                let file_type = e.file_type().ok();
204                let is_dir = file_type.is_some_and(|t| t.is_dir());
205                let is_link = file_type.is_some_and(|t| t.is_symlink());
206                (e.path(), is_dir, is_link)
207            })
208            .collect();
209        children.sort();
210        let names: Vec<String> = children
211            .iter()
212            .filter_map(|(p, _, _)| p.file_name().and_then(|n| n.to_str()).map(str::to_string))
213            .collect();
214        for (path, is_dir, is_link) in children {
215            let shown = self.display(&path);
216            let Some(name) = path
217                .file_name()
218                .and_then(|n| n.to_str())
219                .map(str::to_string)
220            else {
221                self.layout_error(
222                    format!("file name is not valid UTF-8: `{shown}`"),
223                    "rename the file using ASCII letters, digits and `_`",
224                );
225                continue;
226            };
227            if name.starts_with('.') {
228                continue;
229            }
230            if is_link {
231                // Symbolic links are not followed, so the walk cannot loop.
232                continue;
233            }
234            if is_dir {
235                if !self.check_segment(&name, &shown) {
236                    continue;
237                }
238                if area == Area::Src
239                    && module.is_empty()
240                    && ["std", "tests", "examples", "native"].contains(&name.as_str())
241                {
242                    self.layout_error(
243                        format!("`{shown}/` uses the reserved module name `{name}`"),
244                        "`std`, `tests`, `examples` and `native` are reserved root names; rename the directory",
245                    );
246                    continue;
247                }
248                // `src/users.wolf` next to `src/users/` would be an index file (rule 1.5).
249                if names.iter().any(|n| *n == format!("{name}.wolf")) {
250                    self.layout_error(
251                        format!("`{shown}.wolf` and the directory `{shown}/` both exist"),
252                        "a directory is only a namespace; move the code of the file into a module inside the directory",
253                    );
254                }
255                module.push(name);
256                self.walk(&path, module, area);
257                module.pop();
258                continue;
259            }
260            let Some(stem) = name.strip_suffix(".wolf") else {
261                if area == Area::Src {
262                    self.layout_error(
263                        format!("unexpected file `{shown}`"),
264                        "`src/` contains only `.wolf` files and directories",
265                    );
266                }
267                continue;
268            };
269            if !self.check_segment(stem, &shown) {
270                continue;
271            }
272            let mut module_path = module.clone();
273            module_path.push(stem.to_string());
274            let role = match area {
275                Area::Src if module.is_empty() && (stem == "main" || stem == "lib") => Role::Entry,
276                Area::Src => Role::Src,
277                Area::Tests => Role::Test,
278                Area::Examples => Role::Example,
279            };
280            if area == Area::Src && module.is_empty() {
281                if let Some(reserved) = ["std", "tests", "examples", "native"]
282                    .iter()
283                    .find(|r| **r == stem)
284                {
285                    self.layout_error(
286                        format!("`{shown}` uses the reserved module name `{reserved}`"),
287                        "`std`, `tests`, `examples` and `native` are reserved root names; rename the file",
288                    );
289                    continue;
290                }
291            }
292            self.files.push(ModuleFile {
293                module: module_path,
294                role,
295                path,
296                display: shown,
297            });
298        }
299    }
300
301    /// File and directory names are module path segments: `snake_case`, not a keyword.
302    fn check_segment(&mut self, name: &str, shown: &str) -> bool {
303        if RESERVED.contains(&name) {
304            self.layout_error(
305                format!("`{shown}` uses the keyword `{name}` as a module name"),
306                "rename it",
307            );
308            return false;
309        }
310        if !is_snake_case(name) {
311            self.layout_error(
312                format!("`{shown}` is not a valid module name"),
313                "file and directory names are `snake_case`: lower-case words separated by `_`",
314            );
315            return false;
316        }
317        true
318    }
319
320    fn check_entry(&mut self, manifest: &Manifest) {
321        let has = |files: &[ModuleFile], name: &str| {
322            files
323                .iter()
324                .any(|f| f.role == Role::Entry && f.module == [name.to_string()])
325        };
326        let (wanted, other) = match manifest.kind {
327            Kind::Cli => ("main", "lib"),
328            Kind::Lib => ("lib", "main"),
329        };
330        let kind = match manifest.kind {
331            Kind::Cli => "cli",
332            Kind::Lib => "lib",
333        };
334        if !has(&self.files, wanted) {
335            self.diagnostics.push(
336                Diagnostic::new(
337                    codes::E0612,
338                    format!("missing entry file `src/{wanted}.wolf`"),
339                )
340                .primary(
341                    manifest.kind_span,
342                    format!("a `{kind}` project needs `src/{wanted}.wolf`"),
343                ),
344            );
345        }
346        if has(&self.files, other) {
347            self.diagnostics.push(
348                Diagnostic::new(
349                    codes::E0612,
350                    format!("unexpected entry file `src/{other}.wolf`"),
351                )
352                .primary(
353                    manifest.kind_span,
354                    format!("a `{kind}` project has no `src/{other}.wolf`"),
355                )
356                .help(format!(
357                    "rename the file, or change `kind` to `\"{}\"`",
358                    if other == "main" { "cli" } else { "lib" }
359                )),
360            );
361            // The wrong entry is treated as an ordinary module.
362            for file in &mut self.files {
363                if file.role == Role::Entry && file.module == [other.to_string()] {
364                    file.role = Role::Src;
365                }
366            }
367        }
368    }
369}
370
371/// `[a-z][a-z0-9]*(_[a-z0-9]+)*` (spec chapter 1, section 3).
372pub fn is_snake_case(name: &str) -> bool {
373    let mut parts = name.split('_');
374    let first_ok = parts.next().is_some_and(|p| {
375        p.starts_with(|c: char| c.is_ascii_lowercase())
376            && p.chars()
377                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
378    });
379    first_ok
380        && parts.all(|p| {
381            !p.is_empty()
382                && p.chars()
383                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
384        })
385}