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