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::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}