1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
//! Source-root detection and output paths (shared by `build_project` and `compiler`).
use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
/// Find the actual source root for a Windjammer file.
///
/// Walks up from the file looking for a directory that is a project source root.
/// Priority order (first match closest to the file wins):
/// 1. `src` — only if it looks like a real project source dir
/// (sibling Cargo.toml, or contains mod.wj / .wj files)
/// 2. Topmost directory containing mod.wj
/// 3. The file's immediate parent (standalone file fallback)
pub fn find_source_root(file_path: &Path) -> Option<&Path> {
let mut current = file_path;
let mut topmost_mod_wj_dir = None;
let mut found_project_src: Option<&Path> = None;
let mut depth = 0;
while let Some(parent) = current.parent() {
if let Some(dir_name) = parent.file_name().and_then(|n| n.to_str()) {
if dir_name == "src"
&& found_project_src.is_none()
&& is_project_source_dir(parent, depth)
{
found_project_src = Some(parent);
}
}
if parent.join("mod.wj").exists() {
topmost_mod_wj_dir = Some(parent);
}
current = parent;
depth += 1;
}
if let Some(src) = found_project_src {
return Some(src);
}
if let Some(mod_wj_dir) = topmost_mod_wj_dir {
return Some(mod_wj_dir);
}
file_path.parent()
}
/// Check if a `src/` directory is a real project source root, not just any
/// directory named "src" (e.g. `/Users/dev/src/` is a personal code directory).
///
/// The `depth` parameter indicates how many levels we've walked up from the original file.
/// This prevents matching distant ancestor `src/` directories.
fn is_project_source_dir(src_dir: &Path, depth: usize) -> bool {
if let Some(project_dir) = src_dir.parent() {
if project_dir.join("Cargo.toml").exists() {
return true;
}
if project_dir.join("mod.wj").exists() {
return true;
}
}
if src_dir.join("mod.wj").exists() {
return true;
}
if src_dir.join("lib.wj").exists() || src_dir.join("main.wj").exists() {
return true;
}
// TDD FIX: Recognize bare src/ directories with .wj files (even without mod.wj)
// This fixes the case where `wj build src/ecs/entity.wj` should use `src/` as root.
// CONSTRAINT: Only apply this heuristic if we're close to the original file (depth <= 3)
// to avoid matching distant ancestor directories like /Users/username/src/
if depth <= 3 && src_dir.file_name().and_then(|n| n.to_str()) == Some("src") {
// If the src/ directory contains any .wj files (directly or in subdirs), it's a source root
if let Ok(entries) = fs::read_dir(src_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wj") {
return true;
}
if path.is_dir() {
// Check one level deep for .wj files (covers src/ecs/entity.wj case)
if let Ok(sub_entries) = fs::read_dir(&path) {
if sub_entries.flatten().any(|e| {
e.path().extension().and_then(|ext| ext.to_str()) == Some("wj")
}) {
return true;
}
}
}
}
}
}
false
}
/// Calculate output path that preserves directory structure
///
/// Example:
/// - source_root: "windjammer-game/src"
/// - input_path: "windjammer-game/src/math/vec2.wj"
/// - output_dir: "build"
/// - Result: "build/math/vec2.rs"
pub fn get_relative_output_path(
source_root: &Path,
input_path: &Path,
output_dir: &Path,
) -> Result<PathBuf> {
// Get the relative path from source_root to input_path
let relative = input_path.strip_prefix(source_root).unwrap_or(input_path);
// Get the base name without extension
let base_name = relative
.file_stem()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
// TDD FIX: Check if there's a directory module with the same name
// If both window.wj and window/ exist, put window.wj content into window/mod.rs
// This prevents E0761: file for module `window` found at both "window.rs" and "window/mod.rs"
let source_dir_for_module = if let Some(parent) = input_path.parent() {
parent.join(base_name)
} else {
PathBuf::from(base_name)
};
let has_directory_module = source_dir_for_module.is_dir()
&& source_dir_for_module
.read_dir()
.map(|mut entries| {
entries.any(|e| {
e.ok()
.and_then(|e| e.file_name().into_string().ok())
.map(|name| name.ends_with(".wj"))
.unwrap_or(false)
})
})
.unwrap_or(false);
// Replace .wj extension with .rs
let rs_filename = relative
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.replace(".wj", ".rs"))
.ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
// Construct output path preserving directory structure
let mut output_path = output_dir.to_path_buf();
// Add parent directories if they exist
if let Some(parent) = relative.parent() {
if parent != Path::new("") {
output_path.push(parent);
}
}
// mod.wj code (traits, structs, impls) goes to _mod_items.rs so it doesn't
// conflict with the mod.rs generated by the module system. The module system
// will append _mod_items.rs content into mod.rs after generating declarations.
if base_name == "mod" {
output_path.push("_mod_items.rs");
} else if has_directory_module {
output_path.push(base_name);
output_path.push("mod.rs");
} else {
// Add the .rs filename
output_path.push(rs_filename);
}
Ok(output_path)
}
/// Output path for a `.wj` file (directory-module layout when `stem/stem.wj` + `stem/*.wj` co-exist).
/// Deletes a stale flat `stem.rs` when emitting `stem/mod.rs` so rustc never sees both (E0761).
///
/// When `library` is true and the source is `mod.wj`, the compiled content goes to
/// `_mod_items.rs` instead of `mod.rs`. This prevents the `--module-file` pass from
/// overwriting code defined in `mod.wj` (structs, traits, impls). The module-file
/// generator in `build_utils.rs` merges `_mod_items.rs` back into `mod.rs`.
pub fn resolve_wj_output_path(
source_root: &Path,
wj_file: &Path,
output_dir: &Path,
) -> Result<PathBuf> {
resolve_wj_output_path_ext(source_root, wj_file, output_dir, false)
}
pub fn resolve_wj_output_path_library(
source_root: &Path,
wj_file: &Path,
output_dir: &Path,
) -> Result<PathBuf> {
resolve_wj_output_path_ext(source_root, wj_file, output_dir, true)
}
fn resolve_wj_output_path_ext(
source_root: &Path,
wj_file: &Path,
output_dir: &Path,
library: bool,
) -> Result<PathBuf> {
let path = get_relative_output_path(source_root, wj_file, output_dir)?;
if path.file_name().and_then(|s| s.to_str()) == Some("mod.rs") {
if let Ok(rel) = wj_file.strip_prefix(source_root) {
let stale = output_dir.join(rel.with_extension("rs"));
if stale != path {
let _ = fs::remove_file(stale);
}
}
// In library mode, redirect mod.wj output to _mod_items.rs so that
// the --module-file pass can merge it without overwriting.
if library {
let items_path = path.with_file_name("_mod_items.rs");
return Ok(items_path);
}
}
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_get_relative_output_path_nested() {
let source_root = Path::new("src");
let input_path = Path::new("src/math/vec2.wj");
let output_dir = Path::new("build");
let result = get_relative_output_path(source_root, input_path, output_dir).unwrap();
assert_eq!(result, PathBuf::from("build/math/vec2.rs"));
}
#[test]
fn test_get_relative_output_path_flat() {
let source_root = Path::new("src");
let input_path = Path::new("src/vec2.wj");
let output_dir = Path::new("build");
let result = get_relative_output_path(source_root, input_path, output_dir).unwrap();
assert_eq!(result, PathBuf::from("build/vec2.rs"));
}
#[test]
fn test_get_relative_output_path_deeply_nested() {
let source_root = Path::new("game/src");
let input_path = Path::new("game/src/rendering/shaders/vertex.wj");
let output_dir = Path::new("build");
let result = get_relative_output_path(source_root, input_path, output_dir).unwrap();
assert_eq!(result, PathBuf::from("build/rendering/shaders/vertex.rs"));
}
}