Skip to main content

code_moniker_workspace/sources/
mod.rs

1// code-moniker: ignore-file[smell-clone-reflex]
2// Source discovery clones paths and labels into durable workspace source records.
3use 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}