1use super::manifest::{path_is_dir_async, ProjectManifest};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
8pub struct ProjectSourceIndex {
9 pub files: Vec<ProjectSourceFile>,
10 pub package_dirs: Vec<PathBuf>,
11 pub class_dirs: Vec<PathBuf>,
12 pub private_dirs: Vec<PathBuf>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ProjectSourceFile {
17 pub source_root: PathBuf,
18 pub relative_path: PathBuf,
19 pub qualified_name: String,
20 #[serde(default)]
21 pub package_path: Option<String>,
22 #[serde(default)]
23 pub class_name: Option<String>,
24 #[serde(default)]
29 pub class_qualified_name: Option<String>,
30 pub is_private: bool,
31}
32
33impl ProjectSourceFile {
34 pub fn class_definition_qualified_name(&self) -> Option<&str> {
36 self.class_qualified_name.as_deref().or_else(|| {
37 self.package_path
38 .as_ref()
39 .map(|_| self.qualified_name.as_str())
40 })
41 }
42
43 pub fn function_qualified_name(&self) -> Option<&str> {
45 if self.is_private {
46 return None;
47 }
48 (self.package_path.is_some() || self.class_name.is_some())
49 .then_some(self.qualified_name.as_str())
50 .filter(|name| name.contains('.'))
51 }
52}
53
54#[derive(Debug, Error)]
55pub enum ProjectSourceIndexError {
56 #[error("source root does not exist or is not a directory: {root}")]
57 InvalidSourceRoot { root: PathBuf },
58 #[error("failed to read source path {path}: {source}")]
59 ReadDir {
60 path: PathBuf,
61 #[source]
62 source: std::io::Error,
63 },
64 #[error("failed to read source entry under {path}: {source}")]
65 ReadEntry {
66 path: PathBuf,
67 #[source]
68 source: std::io::Error,
69 },
70}
71
72pub fn build_project_source_index(
73 project_root: &Path,
74 manifest: &ProjectManifest,
75) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
76 let mut index = ProjectSourceIndex::default();
77 for source_root in &manifest.sources.roots {
78 let absolute_root = project_root.join(source_root);
79 if !absolute_root.is_dir() {
80 return Err(ProjectSourceIndexError::InvalidSourceRoot {
81 root: source_root.clone(),
82 });
83 }
84 scan_source_dir(
85 &absolute_root,
86 &absolute_root,
87 source_root,
88 &ScanState::default(),
89 &mut index,
90 project_root,
91 )?;
92 }
93 normalize_source_index(&mut index);
94 Ok(index)
95}
96
97pub async fn build_project_source_index_async(
98 project_root: &Path,
99 manifest: &ProjectManifest,
100) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
101 let mut index = ProjectSourceIndex::default();
102 for source_root in &manifest.sources.roots {
103 let absolute_root = project_root.join(source_root);
104 if !path_is_dir_async(&absolute_root).await {
105 return Err(ProjectSourceIndexError::InvalidSourceRoot {
106 root: source_root.clone(),
107 });
108 }
109 scan_source_dir_async(
110 &absolute_root,
111 &absolute_root,
112 source_root,
113 &ScanState::default(),
114 &mut index,
115 project_root,
116 )
117 .await?;
118 }
119 normalize_source_index(&mut index);
120 Ok(index)
121}
122
123pub fn build_loose_source_index(
125 root: &Path,
126) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
127 let mut index = ProjectSourceIndex::default();
128 let entries = fs::read_dir(root).map_err(|source| ProjectSourceIndexError::ReadDir {
129 path: root.to_path_buf(),
130 source,
131 })?;
132 let mut entries = entries
133 .map(|entry| {
134 entry.map_err(|source| ProjectSourceIndexError::ReadEntry {
135 path: root.to_path_buf(),
136 source,
137 })
138 })
139 .collect::<Result<Vec<_>, _>>()?;
140 entries.sort_by_key(|entry| entry.file_name());
141
142 for entry in entries {
143 let path = entry.path();
144 let file_type = entry
145 .file_type()
146 .map_err(|source| ProjectSourceIndexError::ReadEntry {
147 path: root.to_path_buf(),
148 source,
149 })?;
150 if file_type.is_dir() {
151 let name = entry.file_name();
152 let name = name.to_string_lossy();
153 if name.starts_with('+') || name.starts_with('@') || name == "private" {
154 scan_source_dir(
155 &path,
156 root,
157 Path::new("."),
158 &ScanState::default(),
159 &mut index,
160 root,
161 )?;
162 }
163 continue;
164 }
165 if let Some(source) = project_source_file_from_path(&path, root, Path::new(".")) {
166 index.files.push(source);
167 }
168 }
169 normalize_source_index(&mut index);
170 Ok(index)
171}
172
173pub async fn build_loose_source_index_async(
174 root: &Path,
175) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
176 let mut index = ProjectSourceIndex::default();
177 let mut entries = runmat_filesystem::read_dir_async(root)
178 .await
179 .map_err(|source| ProjectSourceIndexError::ReadDir {
180 path: root.to_path_buf(),
181 source,
182 })?;
183 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_string());
184
185 for entry in entries {
186 let path = entry.path().to_path_buf();
187 if entry.is_dir() {
188 let name = entry.file_name().to_string_lossy().to_string();
189 if name.starts_with('+') || name.starts_with('@') || name == "private" {
190 scan_source_dir_async(
191 &path,
192 root,
193 Path::new("."),
194 &ScanState::default(),
195 &mut index,
196 root,
197 )
198 .await?;
199 }
200 continue;
201 }
202 if let Some(source) = project_source_file_from_path(&path, root, Path::new(".")) {
203 index.files.push(source);
204 }
205 }
206 normalize_source_index(&mut index);
207 Ok(index)
208}
209
210fn normalize_source_index(index: &mut ProjectSourceIndex) {
211 index
212 .files
213 .sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
214 index.package_dirs.sort();
215 index.package_dirs.dedup();
216 index.class_dirs.sort();
217 index.class_dirs.dedup();
218 index.private_dirs.sort();
219 index.private_dirs.dedup();
220}
221
222#[derive(Debug, Clone, Default)]
223struct ScanState {
224 package_segments: Vec<String>,
225 module_segments: Vec<String>,
226 class_name: Option<String>,
227 in_private: bool,
228}
229
230pub fn project_source_file_from_path(
233 source_path: &Path,
234 root_dir: &Path,
235 source_root: &Path,
236) -> Option<ProjectSourceFile> {
237 let relative_path = source_path.strip_prefix(root_dir).ok()?.to_path_buf();
238 if !source_path
239 .extension()
240 .and_then(|extension| extension.to_str())
241 .is_some_and(|extension| extension.eq_ignore_ascii_case("m"))
242 {
243 return None;
244 }
245 let stem = source_path.file_stem()?.to_str()?.trim();
246 if stem.is_empty() {
247 return None;
248 }
249
250 let mut state = ScanState::default();
251 if let Some(parent) = relative_path.parent() {
252 for component in parent.components() {
253 let segment = component.as_os_str().to_str()?;
254 if let Some(package) = segment.strip_prefix('+') {
255 if package.is_empty() {
256 return None;
257 }
258 state.package_segments.push(package.to_string());
259 } else if let Some(class) = segment.strip_prefix('@') {
260 if class.is_empty() {
261 return None;
262 }
263 state.class_name = Some(class.to_string());
264 } else if segment == "private" {
265 state.in_private = true;
266 } else {
267 state.module_segments.push(segment.to_string());
268 }
269 }
270 }
271
272 let mut qualified_segments = state.package_segments.clone();
273 qualified_segments.extend(state.module_segments.iter().cloned());
274 let class_qualified_name = state.class_name.as_ref().map(|class_name| {
275 let mut class_segments = qualified_segments.clone();
276 class_segments.push(class_name.clone());
277 class_segments.join(".")
278 });
279 if let Some(class_name) = &state.class_name {
280 qualified_segments.push(class_name.clone());
281 }
282 qualified_segments.push(stem.to_string());
283 let qualified_name = qualified_segments.join(".");
284 (!qualified_name.is_empty()).then_some(ProjectSourceFile {
285 source_root: source_root.to_path_buf(),
286 relative_path,
287 qualified_name,
288 package_path: (!state.package_segments.is_empty())
289 .then(|| state.package_segments.join(".")),
290 class_name: state.class_name,
291 class_qualified_name,
292 is_private: state.in_private,
293 })
294}
295
296fn scan_source_dir(
297 dir: &Path,
298 root_absolute: &Path,
299 source_root: &Path,
300 state: &ScanState,
301 index: &mut ProjectSourceIndex,
302 project_root: &Path,
303) -> Result<(), ProjectSourceIndexError> {
304 let mut entries = fs::read_dir(dir).map_err(|source| ProjectSourceIndexError::ReadDir {
305 path: dir.to_path_buf(),
306 source,
307 })?;
308 let mut sorted = Vec::new();
309 for entry in &mut entries {
310 sorted.push(entry.map_err(|source| ProjectSourceIndexError::ReadEntry {
311 path: dir.to_path_buf(),
312 source,
313 })?);
314 }
315 sorted.sort_by_key(|entry| entry.file_name());
316
317 for entry in sorted {
318 let path = entry.path();
319 let file_type = entry
320 .file_type()
321 .map_err(|source| ProjectSourceIndexError::ReadEntry {
322 path: dir.to_path_buf(),
323 source,
324 })?;
325 let name = entry.file_name();
326 let name = name.to_string_lossy();
327 if file_type.is_dir() {
328 let mut next = state.clone();
329 if let Some(package) = name.strip_prefix('+') {
330 if !package.is_empty() {
331 next.package_segments.push(package.to_string());
332 if let Ok(relative) = path.strip_prefix(project_root) {
333 index.package_dirs.push(relative.to_path_buf());
334 }
335 }
336 } else if let Some(class) = name.strip_prefix('@') {
337 if !class.is_empty() {
338 next.class_name = Some(class.to_string());
339 if let Ok(relative) = path.strip_prefix(project_root) {
340 index.class_dirs.push(relative.to_path_buf());
341 }
342 }
343 } else if name == "private" {
344 next.in_private = true;
345 if let Ok(relative) = path.strip_prefix(project_root) {
346 index.private_dirs.push(relative.to_path_buf());
347 }
348 } else {
349 next.module_segments.push(name.to_string());
350 }
351 scan_source_dir(
352 &path,
353 root_absolute,
354 source_root,
355 &next,
356 index,
357 project_root,
358 )?;
359 continue;
360 }
361 if let Some(source) = project_source_file_from_path(&path, root_absolute, source_root) {
362 index.files.push(source);
363 }
364 }
365 Ok(())
366}
367
368async fn scan_source_dir_async(
369 dir: &Path,
370 root_absolute: &Path,
371 source_root: &Path,
372 state: &ScanState,
373 index: &mut ProjectSourceIndex,
374 project_root: &Path,
375) -> Result<(), ProjectSourceIndexError> {
376 let mut stack = vec![(dir.to_path_buf(), state.clone())];
377 while let Some((current_dir, current_state)) = stack.pop() {
378 let mut sorted = runmat_filesystem::read_dir_async(¤t_dir)
379 .await
380 .map_err(|source| ProjectSourceIndexError::ReadDir {
381 path: current_dir.clone(),
382 source,
383 })?;
384 sorted.sort_by_key(|entry| entry.file_name().to_string_lossy().to_string());
385
386 for entry in sorted {
387 let path = entry.path().to_path_buf();
388 let name = entry.file_name().to_string_lossy().to_string();
389 if entry.is_dir() {
390 let mut next = current_state.clone();
391 if let Some(package) = name.strip_prefix('+') {
392 if !package.is_empty() {
393 next.package_segments.push(package.to_string());
394 if let Ok(relative) = path.strip_prefix(project_root) {
395 index.package_dirs.push(relative.to_path_buf());
396 }
397 }
398 } else if let Some(class) = name.strip_prefix('@') {
399 if !class.is_empty() {
400 next.class_name = Some(class.to_string());
401 if let Ok(relative) = path.strip_prefix(project_root) {
402 index.class_dirs.push(relative.to_path_buf());
403 }
404 }
405 } else if name == "private" {
406 next.in_private = true;
407 if let Ok(relative) = path.strip_prefix(project_root) {
408 index.private_dirs.push(relative.to_path_buf());
409 }
410 } else {
411 next.module_segments.push(name);
412 }
413 stack.push((path, next));
414 continue;
415 }
416 if let Some(source) = project_source_file_from_path(&path, root_absolute, source_root) {
417 index.files.push(source);
418 }
419 }
420 }
421 Ok(())
422}