1use std::collections::{BTreeMap, HashSet};
4use std::path::Component;
5use std::path::{Path, PathBuf};
6
7use crate::extract;
8use crate::gitignore::GitignoreStack;
9use crate::lang::path_to_lang;
10use crate::tsconfig::{self, TsResolution};
11use crate::walk::{self, WalkedFile};
12
13#[derive(Clone, Debug)]
14pub struct SourceSet {
15 pub roots: Vec<SourceRoot>,
16 pub files: Vec<SourceFile>,
17 pub multi: bool,
18}
19
20#[derive(Clone, Debug)]
21pub struct SourceRoot {
22 pub input: PathBuf,
23 pub path: PathBuf,
24 pub label: String,
25 pub ctx: extract::Context,
26}
27
28#[derive(Clone, Debug)]
29pub struct SourceFile {
30 pub source: usize,
31 pub path: PathBuf,
32 pub rel_path: PathBuf,
33 pub anchor: PathBuf,
34 pub lang: code_moniker_core::lang::Lang,
35 pub retired: bool,
36}
37
38struct SourceScope {
39 source: usize,
40 root_is_dir: bool,
41 root: SourceRoot,
42}
43
44impl SourceSet {
45 #[allow(dead_code)]
46 pub fn display_path(&self) -> String {
47 if self.multi {
48 self.roots
49 .iter()
50 .map(|source| source.input.display().to_string())
51 .collect::<Vec<_>>()
52 .join(", ")
53 } else {
54 self.roots
55 .first()
56 .map(|source| source.input.display().to_string())
57 .unwrap_or_else(|| "<empty>".to_string())
58 }
59 }
60}
61
62pub fn discover(paths: &[PathBuf], project: Option<String>) -> anyhow::Result<SourceSet> {
63 let scopes = discover_scopes(paths, project)?;
64 let multi = scopes.len() > 1;
65 let mut files = Vec::new();
66 for scope in &scopes {
67 let walked = if scope.root_is_dir {
68 walk::walk_lang_files(&scope.root.input)
69 } else {
70 let lang = path_to_lang(&scope.root.input)?;
71 vec![WalkedFile {
72 path: scope.root.input.clone(),
73 lang,
74 }]
75 };
76 for walked in walked {
77 files.push(source_file_from_walked(scope, walked, multi));
78 }
79 }
80 files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
81 Ok(SourceSet {
82 roots: scopes.into_iter().map(|scope| scope.root).collect(),
83 files,
84 multi,
85 })
86}
87
88pub fn discover_files(
89 root: &Path,
90 files: &[PathBuf],
91 project: Option<String>,
92) -> anyhow::Result<SourceSet> {
93 let meta = std::fs::metadata(root)
94 .map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", root.display()))?;
95 if !meta.is_dir() {
96 return Err(anyhow::anyhow!(
97 "--file requires a directory check path, got {}",
98 root.display()
99 ));
100 }
101 let scopes = discover_scopes(&[root.to_path_buf()], project)?;
102 let Some(scope) = scopes.first() else {
103 return Err(anyhow::anyhow!(
104 "discover_scopes returned no scope for {}",
105 root.display()
106 ));
107 };
108 let abs_root = normalize_absolute(&scope.root.path)?;
109 let ignore_rules = GitignoreStack::for_root(&abs_root);
110 let mut source_files = Vec::new();
111 let mut seen = HashSet::new();
112 for file in files {
113 for path in filter_file_candidates(&scope.root.path, file) {
114 let abs_path = normalize_absolute(&path)?;
115 if !abs_path.starts_with(&abs_root) {
116 continue;
117 }
118 if seen.contains(&abs_path) {
119 break;
120 }
121 if ignore_rules.is_ignored(&abs_path, false) {
122 continue;
123 }
124 let Some(walked) = walk::explicit_lang_file(&path) else {
125 continue;
126 };
127 seen.insert(abs_path);
128 source_files.push(source_file_from_walked(scope, walked, false));
129 break;
130 }
131 }
132 source_files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
133 Ok(SourceSet {
134 roots: scopes.into_iter().map(|scope| scope.root).collect(),
135 files: source_files,
136 multi: false,
137 })
138}
139
140fn discover_scopes(paths: &[PathBuf], project: Option<String>) -> anyhow::Result<Vec<SourceScope>> {
141 if paths.is_empty() {
142 return Err(anyhow::anyhow!("at least one source path is required"));
143 }
144 let multi = paths.len() > 1;
145 let labels = unique_labels(paths);
146 let mut scopes = Vec::with_capacity(paths.len());
147 for (source_idx, path) in paths.iter().enumerate() {
148 let meta = std::fs::metadata(path)
149 .map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
150 let root_is_dir = meta.is_dir();
151 let root = if root_is_dir {
152 path.clone()
153 } else {
154 path.parent()
155 .unwrap_or_else(|| Path::new("."))
156 .to_path_buf()
157 };
158 let label = labels[source_idx].clone();
159 let source_project = project.clone();
160 let mut ts = tsconfig::load(&root);
161 if multi {
162 prefix_ts_aliases(&mut ts, &label);
163 }
164 scopes.push(SourceScope {
165 source: source_idx,
166 root_is_dir,
167 root: SourceRoot {
168 input: path.clone(),
169 path: root,
170 label,
171 ctx: extract::Context {
172 ts,
173 project: source_project,
174 },
175 },
176 });
177 }
178 Ok(scopes)
179}
180
181pub(crate) fn source_file_for_new_path(sources: &SourceSet, path: &Path) -> Option<SourceFile> {
182 let lang = path_to_lang(path).ok()?;
183 let abs = path
184 .canonicalize()
185 .or_else(|_| normalize_absolute(path))
186 .ok()?;
187 let (source, root) = sources
188 .roots
189 .iter()
190 .enumerate()
191 .filter_map(|(idx, root)| {
192 let root_path = canonical_root_path(&root.path)?;
193 abs.starts_with(&root_path)
194 .then(|| (idx, root, root_path.components().count()))
195 })
196 .max_by_key(|(_, _, depth)| *depth)
197 .map(|(idx, root, _)| (idx, root))?;
198 let root_path = canonical_root_path(&root.path)?;
199 let rel = abs.strip_prefix(&root_path).ok()?.to_path_buf();
200 let rel_path = if sources.multi {
201 PathBuf::from(&root.label).join(&rel)
202 } else {
203 rel.clone()
204 };
205 let anchor = if sources.multi {
206 rel_path.clone()
207 } else if root_path.is_dir() {
208 anchor_with_source_context(&root_path, &rel)
209 } else {
210 abs.clone()
211 };
212 Some(SourceFile {
213 source,
214 path: abs,
215 rel_path,
216 anchor,
217 lang,
218 retired: false,
219 })
220}
221
222fn canonical_root_path(root: &Path) -> Option<PathBuf> {
223 root.canonicalize()
224 .or_else(|_| normalize_absolute(root))
225 .ok()
226}
227
228fn source_file_from_walked(scope: &SourceScope, walked: WalkedFile, multi: bool) -> SourceFile {
229 let root = normalize_absolute(&scope.root.path).unwrap_or_else(|_| scope.root.path.clone());
230 let path = normalize_absolute(&walked.path).unwrap_or_else(|_| walked.path.clone());
231 let rel = path.strip_prefix(&root).unwrap_or(&path).to_path_buf();
232 let rel_path = if multi {
233 PathBuf::from(&scope.root.label).join(&rel)
234 } else {
235 rel.clone()
236 };
237 let anchor = if multi {
238 rel_path.clone()
239 } else if scope.root_is_dir {
240 anchor_with_source_context(&root, &rel)
241 } else {
242 walked.path.clone()
243 };
244 SourceFile {
245 source: scope.source,
246 path: walked.path,
247 rel_path,
248 anchor,
249 retired: false,
250 lang: walked.lang,
251 }
252}
253
254fn normalize_absolute(path: &Path) -> anyhow::Result<PathBuf> {
255 let path = if path.is_absolute() {
256 path.to_path_buf()
257 } else {
258 std::env::current_dir()?.join(path)
259 };
260 let mut out = PathBuf::new();
261 for component in path.components() {
262 match component {
263 Component::CurDir => {}
264 Component::ParentDir => {
265 out.pop();
266 }
267 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
268 Component::RootDir => out.push(component.as_os_str()),
269 Component::Normal(part) => out.push(part),
270 }
271 }
272 Ok(out)
273}
274
275fn filter_file_candidates(root: &Path, file: &Path) -> Vec<PathBuf> {
276 let mut candidates = Vec::new();
277 if file.is_absolute() {
278 candidates.push(file.to_path_buf());
279 return candidates;
280 }
281 push_unique_path(&mut candidates, file.to_path_buf());
282 if let Some(parent) = root.parent() {
283 if file_starts_with_root_name(root, file) {
284 push_unique_path(&mut candidates, parent.join(file));
285 }
286 }
287 push_unique_path(&mut candidates, root.join(file));
288 if let Some(parent) = root.parent() {
289 push_unique_path(&mut candidates, parent.join(file));
290 }
291 candidates
292}
293
294fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
295 if !paths.iter().any(|existing| existing == &path) {
296 paths.push(path);
297 }
298}
299
300fn file_starts_with_root_name(root: &Path, file: &Path) -> bool {
301 let Some(root_name) = root.file_name() else {
302 return false;
303 };
304 file.components()
305 .next()
306 .is_some_and(|component| component.as_os_str() == root_name)
307}
308
309fn anchor_with_source_context(root: &Path, rel: &Path) -> PathBuf {
310 if path_has_source_set(rel) {
311 return rel.to_path_buf();
312 }
313 source_set_suffix_from_scope(root, rel).unwrap_or_else(|| rel.to_path_buf())
314}
315
316fn source_set_suffix_from_scope(root: &Path, rel: &Path) -> Option<PathBuf> {
317 let root_parts: Vec<_> = root.components().collect();
318 let rel_parts: Vec<_> = rel.components().collect();
319 let rel_first = rel_parts
320 .first()
321 .and_then(|component| component.as_os_str().to_str());
322 for idx in (0..root_parts.len()).rev() {
323 let name = root_parts[idx].as_os_str().to_str()?;
324 if name != "src" {
325 continue;
326 }
327 if let Some(next) = root_parts
328 .get(idx + 1)
329 .and_then(|component| component.as_os_str().to_str())
330 {
331 if matches!(next, "main" | "test" | "tests") {
332 return Some(root_parts[idx..].iter().chain(rel_parts.iter()).collect());
333 }
334 } else if rel_first.is_some_and(|first| matches!(first, "main" | "test" | "tests")) {
335 return Some(root_parts[idx..].iter().chain(rel_parts.iter()).collect());
336 }
337 }
338 None
339}
340
341fn path_has_source_set(path: &Path) -> bool {
342 path.components()
343 .filter_map(|component| component.as_os_str().to_str())
344 .collect::<Vec<_>>()
345 .windows(2)
346 .any(|window| matches!(window, ["src", "main" | "test" | "tests"]))
347}
348
349fn unique_labels(paths: &[PathBuf]) -> Vec<String> {
350 let base: Vec<String> = paths
351 .iter()
352 .enumerate()
353 .map(|(idx, path)| {
354 path.file_stem()
355 .or_else(|| path.file_name())
356 .and_then(|name| name.to_str())
357 .filter(|name| !name.is_empty())
358 .map(ToOwned::to_owned)
359 .unwrap_or_else(|| format!("source{}", idx + 1))
360 })
361 .collect();
362 let mut seen = BTreeMap::<String, usize>::new();
363 base.into_iter()
364 .map(|label| {
365 let count = seen.entry(label.clone()).or_default();
366 *count += 1;
367 if *count == 1 {
368 label
369 } else {
370 format!("{label}-{}", *count)
371 }
372 })
373 .collect()
374}
375
376fn prefix_ts_aliases(ts: &mut TsResolution, label: &str) {
377 for alias in &mut ts.aliases {
378 alias.substitution = prefix_project_rooted_substitution(&alias.substitution, label);
379 }
380}
381
382fn prefix_project_rooted_substitution(substitution: &str, label: &str) -> String {
383 let rest = substitution.strip_prefix("./").unwrap_or(substitution);
384 format!("./{label}/{rest}")
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn write(root: &Path, rel: &str, body: &str) {
392 let p = root.join(rel);
393 if let Some(parent) = p.parent() {
394 std::fs::create_dir_all(parent).unwrap();
395 }
396 std::fs::write(p, body).unwrap();
397 }
398
399 #[test]
400 fn discovers_multiple_roots_with_labels_and_prefixed_anchors() {
401 let tmp = tempfile::tempdir().unwrap();
402 let service_a = tmp.path().join("service-a");
403 let service_b = tmp.path().join("service-b");
404 write(&service_a, "src/A.java", "class A {}\n");
405 write(&service_b, "src/B.java", "class B {}\n");
406
407 let set = discover(&[service_a.clone(), service_b.clone()], None).unwrap();
408
409 assert!(set.multi);
410 assert_eq!(set.roots[0].label, "service-a");
411 assert_eq!(set.roots[0].ctx.project, None);
412 assert_eq!(set.roots[1].ctx.project, None);
413 assert!(set.display_path().contains("service-a"));
414 assert!(set.display_path().contains("service-b"));
415 assert!(
416 set.files
417 .iter()
418 .any(|file| file.rel_path.as_path() == Path::new("service-a/src/A.java"))
419 );
420 assert!(
421 set.files
422 .iter()
423 .any(|file| file.anchor.as_path() == Path::new("service-b/src/B.java"))
424 );
425 }
426
427 #[test]
428 fn keeps_single_root_paths_compatible() {
429 let tmp = tempfile::tempdir().unwrap();
430 write(tmp.path(), "src/A.java", "class A {}\n");
431
432 let set = discover(&[tmp.path().to_path_buf()], None).unwrap();
433
434 assert!(!set.multi);
435 assert_eq!(set.roots[0].ctx.project, None);
436 assert_eq!(set.display_path(), tmp.path().display().to_string());
437 assert_eq!(set.files[0].rel_path, PathBuf::from("src/A.java"));
438 assert_eq!(set.files[0].anchor, PathBuf::from("src/A.java"));
439 }
440
441 #[test]
442 fn prefixes_ts_path_aliases_in_multi_source_mode() {
443 let tmp = tempfile::tempdir().unwrap();
444 let service_a = tmp.path().join("service-a");
445 let service_b = tmp.path().join("service-b");
446 write(
447 &service_a,
448 "tsconfig.json",
449 r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
450 );
451 write(&service_a, "src/A.ts", "export class A {}\n");
452 write(&service_b, "src/B.ts", "export class B {}\n");
453
454 let set = discover(&[service_a, service_b], None).unwrap();
455
456 assert!(
457 set.roots[0]
458 .ctx
459 .ts
460 .aliases
461 .iter()
462 .any(|alias| alias.pattern == "@/*" && alias.substitution == "./service-a/src/*"),
463 "{:?}",
464 set.roots[0].ctx.ts.aliases,
465 );
466 }
467
468 #[test]
469 fn keeps_single_file_display_path_compatible() {
470 let tmp = tempfile::tempdir().unwrap();
471 write(tmp.path(), "A.java", "class A {}\n");
472 let path = tmp.path().join("A.java");
473
474 let set = discover(std::slice::from_ref(&path), None).unwrap();
475
476 assert!(!set.multi);
477 assert_eq!(set.display_path(), path.display().to_string());
478 assert_eq!(set.files[0].rel_path, PathBuf::from("A.java"));
479 assert_eq!(set.files[0].anchor, path);
480 }
481
482 #[test]
483 fn source_set_context_uses_scope_suffix_not_parent_directories() {
484 let tmp = tempfile::tempdir().unwrap();
485 let root = tmp.path().join("outer/src/test/project/src");
486 write(
487 &root,
488 "main/java/com/acme/Foo.java",
489 "package com.acme;\nclass Foo {}\n",
490 );
491
492 let set = discover_files(
493 &root,
494 &[PathBuf::from("src/main/java/com/acme/Foo.java")],
495 None,
496 )
497 .unwrap();
498
499 assert_eq!(set.files.len(), 1);
500 assert_eq!(
501 set.files[0].anchor,
502 PathBuf::from("src/main/java/com/acme/Foo.java")
503 );
504 }
505
506 #[test]
507 fn filter_candidates_try_project_relative_scope_prefixed_paths_before_scope_join() {
508 let tmp = tempfile::tempdir().unwrap();
509 let root = tmp.path().join("project/src");
510 write(&root, "order.ts", "class Bad {}\n");
511 write(&root, "src/order.ts", "class Duplicate {}\n");
512
513 let candidates = filter_file_candidates(&root, Path::new("src/order.ts"));
514
515 assert_eq!(candidates[0], PathBuf::from("src/order.ts"));
516 assert_eq!(candidates[1], tmp.path().join("project/src/order.ts"));
517 assert_eq!(candidates[2], tmp.path().join("project/src/src/order.ts"));
518 }
519}