Skip to main content

code_moniker_workspace/
sources.rs

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