1use std::path::{Path, PathBuf};
4
5use fallow_config::WorkspaceInfo;
6use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
7use fallow_types::path_util::is_absolute_path_any_platform;
8use globset::Glob;
9use rustc_hash::FxHashSet;
10
11use crate::{AnalysisOptions, ProgrammaticError};
12
13type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
14
15pub struct ProgrammaticAnalysisContext {
21 pub(crate) root: PathBuf,
22 pub(crate) config_path: Option<PathBuf>,
23 pub(crate) no_cache: bool,
24 pub(crate) threads: usize,
25 pub(crate) pool: rayon::ThreadPool,
26 pub(crate) diff: Option<DiffIndex>,
27 pub(crate) production_override: Option<bool>,
28 pub(crate) changed_since: Option<String>,
29 pub(crate) workspace: Option<Vec<String>>,
30 pub(crate) changed_workspaces: Option<String>,
31 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
32 pub(crate) explain: bool,
33}
34
35pub fn resolve_programmatic_analysis_context(
42 options: &AnalysisOptions,
43) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
44 resolve_programmatic_analysis_context_inner(options, true)
45}
46
47pub fn resolve_programmatic_analysis_context_deferred_workspace(
48 options: &AnalysisOptions,
49) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
50 resolve_programmatic_analysis_context_inner(options, false)
51}
52
53fn resolve_programmatic_analysis_context_inner(
54 options: &AnalysisOptions,
55 resolve_workspace: bool,
56) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
57 validate_analysis_option_shape(options)?;
58 let root = resolve_analysis_root(options.root.as_deref())?;
59 validate_analysis_config_path(options.config_path.as_deref())?;
60 let threads = options.threads.unwrap_or_else(default_threads);
61 let pool = rayon::ThreadPoolBuilder::new()
62 .num_threads(threads)
63 .build()
64 .map_err(|err| {
65 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
66 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
67 .with_context("analysis.threads")
68 })?;
69 let diff = options
70 .diff_file
71 .as_deref()
72 .map(|path| load_explicit_diff_file(path, &root))
73 .transpose()?;
74 let workspace_roots = if resolve_workspace {
75 resolve_workspace_scope(
76 &root,
77 options.workspace.as_deref(),
78 options.changed_workspaces.as_deref(),
79 )?
80 } else {
81 None
82 };
83 Ok(ProgrammaticAnalysisContext {
84 root,
85 config_path: options.config_path.clone(),
86 no_cache: options.no_cache,
87 threads,
88 pool,
89 diff,
90 production_override: options
91 .production_override
92 .or_else(|| options.production.then_some(true)),
93 changed_since: options.changed_since.clone(),
94 workspace: options.workspace.clone(),
95 changed_workspaces: options.changed_workspaces.clone(),
96 workspace_roots,
97 explain: options.explain,
98 })
99}
100
101fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
102 if options.threads == Some(0) {
103 return Err(
104 ProgrammaticError::new("`threads` must be greater than 0", 2)
105 .with_code("FALLOW_INVALID_THREADS")
106 .with_context("analysis.threads"),
107 );
108 }
109 if options.workspace.is_some() && options.changed_workspaces.is_some() {
110 return Err(ProgrammaticError::new(
111 "`workspace` and `changed_workspaces` are mutually exclusive",
112 2,
113 )
114 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
115 .with_context("analysis.workspace"));
116 }
117 Ok(())
118}
119
120fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
121 let root = match root {
122 Some(root) => root.to_path_buf(),
123 None => std::env::current_dir().map_err(|err| {
124 ProgrammaticError::new(
125 format!("failed to resolve current working directory: {err}"),
126 2,
127 )
128 .with_code("FALLOW_CWD_UNAVAILABLE")
129 .with_context("analysis.root")
130 })?,
131 };
132 fallow_engine::validate::validate_root(&root).map_err(|err| {
133 ProgrammaticError::new(err, 2)
134 .with_code("FALLOW_INVALID_ROOT")
135 .with_context("analysis.root")
136 })
137}
138
139fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
140 if let Some(config_path) = config_path
141 && !config_path.exists()
142 {
143 return Err(ProgrammaticError::new(
144 format!("config file does not exist: {}", config_path.display()),
145 2,
146 )
147 .with_code("FALLOW_INVALID_CONFIG_PATH")
148 .with_context("analysis.configPath"));
149 }
150 Ok(())
151}
152
153impl ProgrammaticAnalysisContext {
154 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
156 self.pool.install(f)
157 }
158
159 #[must_use]
161 pub fn root(&self) -> &Path {
162 &self.root
163 }
164
165 #[must_use]
167 pub fn config_path(&self) -> &Option<PathBuf> {
168 &self.config_path
169 }
170
171 #[must_use]
173 pub const fn no_cache(&self) -> bool {
174 self.no_cache
175 }
176
177 #[must_use]
179 pub const fn threads(&self) -> usize {
180 self.threads
181 }
182
183 #[must_use]
185 pub const fn diff_index(&self) -> Option<&DiffIndex> {
186 self.diff.as_ref()
187 }
188
189 #[must_use]
191 pub const fn production_override(&self) -> Option<bool> {
192 self.production_override
193 }
194
195 #[must_use]
197 pub fn changed_since(&self) -> Option<&str> {
198 self.changed_since.as_deref()
199 }
200
201 #[must_use]
203 pub fn workspace(&self) -> Option<&[String]> {
204 self.workspace.as_deref()
205 }
206
207 #[must_use]
209 pub fn changed_workspaces(&self) -> Option<&str> {
210 self.changed_workspaces.as_deref()
211 }
212
213 #[must_use]
215 pub const fn explain_enabled(&self) -> bool {
216 self.explain
217 }
218}
219
220fn default_threads() -> usize {
221 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
222}
223
224fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
225 if path == Path::new("-") {
226 return Err(ProgrammaticError::new(
227 "`diff_file` does not support stdin; pass a file path",
228 2,
229 )
230 .with_code("FALLOW_INVALID_DIFF_FILE")
231 .with_context("analysis.diffFile"));
232 }
233 let abs = if is_absolute_path_any_platform(path) {
234 path.to_path_buf()
235 } else {
236 root.join(path)
237 };
238 let meta = std::fs::metadata(&abs).map_err(|err| {
239 ProgrammaticError::new(
240 format!(
241 "diff file does not exist or cannot be read: {} ({err})",
242 abs.display()
243 ),
244 2,
245 )
246 .with_code("FALLOW_INVALID_DIFF_FILE")
247 .with_context("analysis.diffFile")
248 })?;
249 if !meta.is_file() {
250 return Err(ProgrammaticError::new(
251 format!("diff path is not a file: {}", abs.display()),
252 2,
253 )
254 .with_code("FALLOW_INVALID_DIFF_FILE")
255 .with_context("analysis.diffFile"));
256 }
257 if meta.len() > MAX_DIFF_BYTES {
258 return Err(ProgrammaticError::new(
259 format!(
260 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
261 meta.len(),
262 abs.display()
263 ),
264 2,
265 )
266 .with_code("FALLOW_INVALID_DIFF_FILE")
267 .with_context("analysis.diffFile"));
268 }
269 let text = std::fs::read_to_string(&abs).map_err(|err| {
270 ProgrammaticError::new(
271 format!("failed to read diff file {}: {err}", abs.display()),
272 2,
273 )
274 .with_code("FALLOW_INVALID_DIFF_FILE")
275 .with_context("analysis.diffFile")
276 })?;
277 Ok(DiffIndex::from_unified_diff(&text))
278}
279
280pub fn changed_files_for_run(
281 resolved: &ProgrammaticAnalysisContext,
282) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
283 let Some(git_ref) = resolved.changed_since.as_deref() else {
284 return Ok(None);
285 };
286 fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
287 .map(Some)
288 .map_err(|err| {
289 ProgrammaticError::new(
290 format!(
291 "failed to resolve changed files for ref `{git_ref}`: {}",
292 err.describe()
293 ),
294 2,
295 )
296 .with_code("FALLOW_CHANGED_FILES_FAILED")
297 .with_context("analysis.changedSince")
298 })
299}
300
301pub fn workspace_roots_for_session(
302 resolved: &ProgrammaticAnalysisContext,
303 workspaces: &[WorkspaceInfo],
304) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
305 resolve_workspace_scope_from_workspaces(
306 &resolved.root,
307 resolved.workspace.as_deref(),
308 resolved.changed_workspaces.as_deref(),
309 workspaces,
310 )
311}
312
313fn resolve_workspace_scope(
314 root: &Path,
315 workspace: Option<&[String]>,
316 changed_workspaces: Option<&str>,
317) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
318 match (workspace, changed_workspaces) {
319 (Some(patterns), None) => resolve_workspace_filters(root, patterns).map(Some),
320 (None, Some(git_ref)) => resolve_changed_workspaces(root, git_ref).map(Some),
321 (None, None) => Ok(None),
322 (Some(_), Some(_)) => Err(ProgrammaticError::new(
323 "`workspace` and `changed_workspaces` are mutually exclusive",
324 2,
325 )
326 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
327 .with_context("analysis.workspace")),
328 }
329}
330
331fn resolve_workspace_scope_from_workspaces(
332 root: &Path,
333 workspace: Option<&[String]>,
334 changed_workspaces: Option<&str>,
335 workspaces: &[WorkspaceInfo],
336) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
337 match (workspace, changed_workspaces) {
338 (Some(patterns), None) => {
339 resolve_workspace_filters_from_workspaces(root, patterns, workspaces).map(Some)
340 }
341 (None, Some(git_ref)) => {
342 resolve_changed_workspaces_from_workspaces(root, git_ref, workspaces).map(Some)
343 }
344 (None, None) => Ok(None),
345 (Some(_), Some(_)) => Err(ProgrammaticError::new(
346 "`workspace` and `changed_workspaces` are mutually exclusive",
347 2,
348 )
349 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
350 .with_context("analysis.workspace")),
351 }
352}
353
354pub fn resolve_workspace_filters(
355 root: &Path,
356 patterns: &[String],
357) -> ProgrammaticResult<Vec<PathBuf>> {
358 let workspaces = fallow_config::discover_workspaces(root);
359 resolve_workspace_filters_from_workspaces(root, patterns, &workspaces)
360}
361
362fn resolve_workspace_filters_from_workspaces(
363 root: &Path,
364 patterns: &[String],
365 workspaces: &[WorkspaceInfo],
366) -> ProgrammaticResult<Vec<PathBuf>> {
367 if workspaces.is_empty() {
368 let joined = patterns
369 .iter()
370 .map(|pattern| format!("'{pattern}'"))
371 .collect::<Vec<_>>()
372 .join(", ");
373 return Err(ProgrammaticError::new(
374 format!(
375 "`workspace` {joined} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
376 ),
377 2,
378 )
379 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
380 .with_context("analysis.workspace"));
381 }
382
383 let rel_paths = workspaces
384 .iter()
385 .map(|workspace| relative_workspace_path(&workspace.root, root))
386 .collect::<Vec<_>>();
387 let (positive, negative) = split_workspace_patterns(patterns);
388 let mut matched = match_positive_workspace_patterns(&positive, workspaces, &rel_paths)?;
389
390 for pattern in &negative {
391 for index in find_workspace_matches(pattern, workspaces, &rel_paths)? {
392 matched.remove(&index);
393 }
394 }
395
396 if matched.is_empty() {
397 return Err(
398 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
399 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
400 .with_context("analysis.workspace"),
401 );
402 }
403
404 let mut roots = matched
405 .into_iter()
406 .map(|index| workspaces[index].root.clone())
407 .collect::<Vec<_>>();
408 roots.sort();
409 Ok(roots)
410}
411
412fn resolve_changed_workspaces(root: &Path, git_ref: &str) -> ProgrammaticResult<Vec<PathBuf>> {
413 let workspaces = fallow_config::discover_workspaces(root);
414 resolve_changed_workspaces_from_workspaces(root, git_ref, &workspaces)
415}
416
417fn resolve_changed_workspaces_from_workspaces(
418 root: &Path,
419 git_ref: &str,
420 workspaces: &[WorkspaceInfo],
421) -> ProgrammaticResult<Vec<PathBuf>> {
422 if workspaces.is_empty() {
423 return Err(ProgrammaticError::new(
424 format!(
425 "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
426 ),
427 2,
428 )
429 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
430 .with_context("analysis.changedWorkspaces"));
431 }
432 let changed_files =
433 fallow_engine::changed_files::changed_files(root, git_ref).map_err(|err| {
434 ProgrammaticError::new(
435 format!(
436 "failed to resolve changed workspaces for ref `{git_ref}`: {}",
437 err.describe()
438 ),
439 2,
440 )
441 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
442 .with_context("analysis.changedWorkspaces")
443 })?;
444 let mut roots = workspaces
445 .iter()
446 .filter(|workspace| {
447 changed_files
448 .iter()
449 .any(|file| file.starts_with(&workspace.root))
450 })
451 .map(|workspace| workspace.root.clone())
452 .collect::<Vec<_>>();
453 roots.sort();
454 Ok(roots)
455}
456
457fn match_positive_workspace_patterns(
458 positive: &[&str],
459 workspaces: &[WorkspaceInfo],
460 rel_paths: &[String],
461) -> ProgrammaticResult<FxHashSet<usize>> {
462 let mut matched = FxHashSet::default();
463 let mut unmatched = Vec::new();
464
465 if positive.is_empty() {
466 matched.extend(0..workspaces.len());
467 } else {
468 for pattern in positive {
469 let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
470 if hits.is_empty() {
471 unmatched.push((*pattern).to_string());
472 }
473 matched.extend(hits);
474 }
475 }
476
477 if !unmatched.is_empty() {
478 return Err(ProgrammaticError::new(
479 format!(
480 "`workspace` matched no workspace for pattern{}: {}. Available: {}",
481 if unmatched.len() == 1 { "" } else { "s" },
482 unmatched
483 .iter()
484 .map(|pattern| format!("'{pattern}'"))
485 .collect::<Vec<_>>()
486 .join(", "),
487 format_available_workspaces(workspaces),
488 ),
489 2,
490 )
491 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
492 .with_context("analysis.workspace"));
493 }
494
495 Ok(matched)
496}
497
498fn find_workspace_matches(
499 pattern: &str,
500 workspaces: &[WorkspaceInfo],
501 rel_paths: &[String],
502) -> ProgrammaticResult<Vec<usize>> {
503 if let Some(index) = workspaces
504 .iter()
505 .position(|workspace| workspace.name == pattern)
506 {
507 return Ok(vec![index]);
508 }
509 if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
510 return Ok(vec![index]);
511 }
512
513 let glob = Glob::new(pattern).map_err(|err| {
514 ProgrammaticError::new(format!("invalid `workspace` pattern '{pattern}': {err}"), 2)
515 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
516 .with_context("analysis.workspace")
517 })?;
518 let matcher = glob.compile_matcher();
519 let hits = workspaces
520 .iter()
521 .enumerate()
522 .filter_map(|(index, workspace)| {
523 (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
524 .then_some(index)
525 })
526 .collect();
527 Ok(hits)
528}
529
530fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
531 let mut positive = Vec::new();
532 let mut negative = Vec::new();
533 for pattern in patterns {
534 let trimmed = pattern.trim();
535 if trimmed.is_empty() {
536 continue;
537 }
538 if let Some(negative_pattern) = trimmed.strip_prefix('!') {
539 let negative_pattern = negative_pattern.trim();
540 if !negative_pattern.is_empty() {
541 negative.push(negative_pattern);
542 }
543 } else {
544 positive.push(trimmed);
545 }
546 }
547 (positive, negative)
548}
549
550fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
551 const MAX_SHOWN: usize = 10;
552 let total = workspaces.len();
553 if total <= MAX_SHOWN {
554 return workspaces
555 .iter()
556 .map(|workspace| workspace.name.as_str())
557 .collect::<Vec<_>>()
558 .join(", ");
559 }
560 let shown = workspaces
561 .iter()
562 .take(MAX_SHOWN)
563 .map(|workspace| workspace.name.as_str())
564 .collect::<Vec<_>>()
565 .join(", ");
566 format!(
567 "{shown}, ... and {} more ({total} total)",
568 total - MAX_SHOWN
569 )
570}
571
572fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
573 workspace_root
574 .strip_prefix(root)
575 .unwrap_or(workspace_root)
576 .to_string_lossy()
577 .replace('\\', "/")
578}