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