1use std::fs;
4use std::path::{Path, PathBuf};
5
6use wolf_diag::{Diagnostic, codes};
7
8use crate::manifest::{Kind, Manifest};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Role {
13 Entry,
15 Src,
17 Test,
19 Example,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct ModuleFile {
26 pub module: Vec<String>,
28 pub role: Role,
30 pub path: PathBuf,
32 pub display: String,
34}
35
36const 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
67pub 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
80pub 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 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 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 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 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
313pub 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}