1use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, OnceLock};
6
7use fallow_config::WorkspaceInfo;
8use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
9use fallow_output::{DiffIndex, MAX_DIFF_BYTES, RequestName, RequestOutcome, RequestOutcomes};
10use fallow_types::path_util::is_absolute_path_any_platform;
11use rustc_hash::FxHashSet;
12
13use crate::{AnalysisOptions, ProgrammaticError};
14
15type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
16
17pub struct ProgrammaticAnalysisContext {
23 pub(crate) root: PathBuf,
24 pub(crate) config_path: Option<PathBuf>,
25 pub(crate) allow_remote_extends: bool,
26 pub(crate) no_cache: bool,
27 pub(crate) threads: usize,
28 pub(crate) pool: rayon::ThreadPool,
29 pub(crate) diff: Option<DiffIndex>,
30 pub(crate) diff_request: Option<RequestOutcome>,
32 pub(crate) production_override: Option<bool>,
33 pub(crate) changed_since: Option<String>,
36 pub(crate) changed_since_request: OnceLock<RequestOutcome>,
39 pub(crate) changed_since_files: OnceLock<FxHashSet<PathBuf>>,
41 pub(crate) changed_since_analyzed: Mutex<Option<FxHashSet<PathBuf>>>,
44 pub(crate) workspace: Option<Vec<String>>,
45 pub(crate) changed_workspaces: Option<String>,
46 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
47 pub(crate) explain: bool,
48 pub(crate) cancellation: Option<Arc<AtomicBool>>,
49}
50
51pub fn resolve_programmatic_analysis_context(
58 options: &AnalysisOptions,
59) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
60 resolve_programmatic_analysis_context_inner(options, true)
61}
62
63pub fn resolve_programmatic_analysis_context_deferred_workspace(
64 options: &AnalysisOptions,
65) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
66 resolve_programmatic_analysis_context_inner(options, false)
67}
68
69fn resolve_programmatic_analysis_context_inner(
70 options: &AnalysisOptions,
71 resolve_workspace: bool,
72) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
73 validate_analysis_option_shape(options)?;
74 let root = resolve_analysis_root(options.root.as_deref())?;
75 validate_analysis_config_path(options.config_path.as_deref())?;
76 let threads = options.threads.unwrap_or_else(default_threads);
77 let pool = fallow_engine::thread_pool::worker_pool_builder(threads)
78 .build()
79 .map_err(|err| {
80 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
81 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
82 .with_context("analysis.threads")
83 })?;
84 let (diff, diff_request) = resolve_diff(options, &root)?;
85 let changed_since_request = OnceLock::new();
86 let changed_since_files = OnceLock::new();
87 let changed_since =
88 resolve_changed_since(options, &root, &changed_since_request, &changed_since_files);
89 let workspace_roots = if resolve_workspace {
90 resolve_workspace_scope(
91 &root,
92 options.workspace.as_deref(),
93 options.changed_workspaces.as_deref(),
94 )?
95 } else {
96 None
97 };
98 Ok(ProgrammaticAnalysisContext {
99 root,
100 config_path: options.config_path.clone(),
101 allow_remote_extends: options.allow_remote_extends,
102 no_cache: options.no_cache,
103 threads,
104 pool,
105 diff,
106 diff_request,
107 production_override: options
108 .production_override
109 .or_else(|| options.production.then_some(true)),
110 changed_since,
111 changed_since_request,
112 changed_since_files,
113 changed_since_analyzed: Mutex::new(None),
114 workspace: options.workspace.clone(),
115 changed_workspaces: options.changed_workspaces.clone(),
116 workspace_roots,
117 explain: options.explain,
118 cancellation: options.cancellation.clone(),
119 })
120}
121
122fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
123 if options.threads == Some(0) {
124 return Err(
125 ProgrammaticError::new("`threads` must be greater than 0", 2)
126 .with_code("FALLOW_INVALID_THREADS")
127 .with_context("analysis.threads"),
128 );
129 }
130 if options.workspace.is_some() && options.changed_workspaces.is_some() {
131 return Err(ProgrammaticError::new(
132 "`workspace` and `changed_workspaces` are mutually exclusive",
133 2,
134 )
135 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
136 .with_context("analysis.workspace"));
137 }
138 Ok(())
139}
140
141pub fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
142 let root = match root {
143 Some(root) => root.to_path_buf(),
144 None => std::env::current_dir().map_err(|err| {
145 ProgrammaticError::new(
146 format!("failed to resolve current working directory: {err}"),
147 2,
148 )
149 .with_code("FALLOW_CWD_UNAVAILABLE")
150 .with_context("analysis.root")
151 })?,
152 };
153 fallow_engine::validate::validate_root(&root).map_err(|err| {
154 ProgrammaticError::new(err, 2)
155 .with_code("FALLOW_INVALID_ROOT")
156 .with_context("analysis.root")
157 })
158}
159
160pub fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
161 if let Some(config_path) = config_path
162 && !config_path.exists()
163 {
164 return Err(ProgrammaticError::new(
165 format!("config file does not exist: {}", config_path.display()),
166 2,
167 )
168 .with_code("FALLOW_INVALID_CONFIG_PATH")
169 .with_context("analysis.configPath"));
170 }
171 Ok(())
172}
173
174impl ProgrammaticAnalysisContext {
175 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
177 self.pool.install(f)
178 }
179
180 #[must_use]
182 pub fn root(&self) -> &Path {
183 &self.root
184 }
185
186 #[must_use]
188 pub fn config_path(&self) -> &Option<PathBuf> {
189 &self.config_path
190 }
191
192 #[must_use]
194 pub const fn allow_remote_extends(&self) -> bool {
195 self.allow_remote_extends
196 }
197
198 #[must_use]
200 pub const fn no_cache(&self) -> bool {
201 self.no_cache
202 }
203
204 #[must_use]
206 pub const fn threads(&self) -> usize {
207 self.threads
208 }
209
210 #[must_use]
212 pub const fn diff_index(&self) -> Option<&DiffIndex> {
213 self.diff.as_ref()
214 }
215
216 #[must_use]
222 pub fn request_outcomes(&self) -> Option<RequestOutcomes> {
223 let mut requests = RequestOutcomes::new();
224 requests.insert_if(RequestName::ChangedSince, self.changed_since_outcome());
225 requests.insert_if(RequestName::DiffFilter, self.diff_request.clone());
226 requests.into_option()
227 }
228
229 fn changed_since_outcome(&self) -> Option<RequestOutcome> {
232 let outcome = self.changed_since_request.get()?.clone();
233 let size = self
234 .changed_since_analyzed
235 .lock()
236 .ok()
237 .and_then(|analyzed| analyzed.as_ref().map(|files| files.len() as u64));
238 Some(match size {
239 Some(size) if outcome.status == fallow_output::RequestStatus::Applied => {
240 RequestOutcome {
241 scope_size: Some(size),
242 ..outcome
243 }
244 }
245 _ => outcome,
246 })
247 }
248
249 pub(crate) fn measure_changed_since_scope<'a>(
252 &self,
253 analyzed: impl IntoIterator<Item = &'a Path>,
254 ) {
255 let Some(changed) = self.changed_since_files.get() else {
256 return;
257 };
258 let Ok(mut union) = self.changed_since_analyzed.lock() else {
259 return;
260 };
261 union.get_or_insert_with(FxHashSet::default).extend(
262 analyzed
263 .into_iter()
264 .map(dunce::simplified)
265 .filter(|path| changed.contains(*path))
266 .map(Path::to_path_buf),
267 );
268 }
269
270 fn record_changed_since_applied(&self, git_ref: &str, files: &FxHashSet<PathBuf>) {
273 let _ = self.changed_since_files.set(
274 files
275 .iter()
276 .map(|path| dunce::simplified(path).to_path_buf())
277 .collect(),
278 );
279 let _ = self
280 .changed_since_request
281 .set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
282 }
283
284 pub(crate) fn record_changed_since_from_runner(&self, kept: Option<&[PathBuf]>) {
287 let (Some(git_ref), Some(kept)) = (self.changed_since.as_deref(), kept) else {
288 return;
289 };
290 if self.changed_since_files.get().is_none() {
291 let files: FxHashSet<PathBuf> = kept.iter().cloned().collect();
292 self.record_changed_since_applied(git_ref, &files);
293 }
294 self.measure_changed_since_scope(kept.iter().map(PathBuf::as_path));
295 }
296
297 #[must_use]
299 pub const fn production_override(&self) -> Option<bool> {
300 self.production_override
301 }
302
303 #[must_use]
305 pub fn changed_since(&self) -> Option<&str> {
306 self.changed_since.as_deref()
307 }
308
309 #[must_use]
311 pub fn workspace(&self) -> Option<&[String]> {
312 self.workspace.as_deref()
313 }
314
315 #[must_use]
317 pub fn changed_workspaces(&self) -> Option<&str> {
318 self.changed_workspaces.as_deref()
319 }
320
321 #[must_use]
323 pub const fn explain_enabled(&self) -> bool {
324 self.explain
325 }
326
327 #[must_use]
329 pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
330 self.cancellation.as_ref()
331 }
332
333 #[must_use]
335 pub fn is_cancelled(&self) -> bool {
336 self.cancellation
337 .as_ref()
338 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
339 }
340
341 pub fn ensure_not_cancelled(&self, stage: &str) -> ProgrammaticResult<()> {
352 if self.is_cancelled() {
353 return Err(cancelled_error(stage));
354 }
355 Ok(())
356 }
357}
358
359pub fn ensure_options_not_cancelled(
368 options: &AnalysisOptions,
369 stage: &str,
370) -> ProgrammaticResult<()> {
371 if options
372 .cancellation
373 .as_ref()
374 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
375 {
376 return Err(cancelled_error(stage));
377 }
378 Ok(())
379}
380
381#[must_use]
386pub fn cancelled_error(stage: &str) -> ProgrammaticError {
387 cancelled_error_message(&format!("analysis was cancelled before {stage}"))
388}
389
390#[must_use]
392pub fn cancelled_error_message(message: &str) -> ProgrammaticError {
393 ProgrammaticError::new(message, 2)
394 .with_code("FALLOW_CANCELLED")
395 .with_context("analysis.cancellation")
396}
397
398fn default_threads() -> usize {
399 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
400}
401
402fn resolve_diff(
410 options: &AnalysisOptions,
411 root: &Path,
412) -> ProgrammaticResult<(Option<DiffIndex>, Option<RequestOutcome>)> {
413 if let Some(path) = options.diff_file.as_deref() {
414 let index = load_explicit_diff_file(path, root)?;
415 let request = diff_applied(format!("diffFile {}", path.display()), &index);
416 return Ok((Some(index), Some(request)));
417 }
418 let Some(path) = options.ambient_diff_file.as_deref() else {
419 return Ok((None, None));
420 };
421 Ok(load_ambient_diff_file(path, root))
422}
423
424fn load_ambient_diff_file(path: &Path, root: &Path) -> (Option<DiffIndex>, Option<RequestOutcome>) {
427 let abs = if path.is_absolute() {
428 path.to_path_buf()
429 } else {
430 root.join(path)
431 };
432 let label = format!("$FALLOW_DIFF_FILE {}", abs.display());
433 let placed = fallow_engine::diff_source::read_diff_file(&abs, &label).and_then(|text| {
434 fallow_engine::diff_source::place_diff(
435 DiffIndex::from_unified_diff(&text),
436 root,
437 &fallow_engine::diff_source::diff_base_candidates(root),
438 &label,
439 )
440 });
441 match placed {
442 Ok(index) => {
443 let request = diff_applied(label, &index);
444 (Some(index), Some(request))
445 }
446 Err(stand_down) => {
447 let (reason, message) = stand_down.into_parts();
448 let request =
449 RequestOutcome::not_applied(RequestName::DiffFilter, label, reason, message);
450 (None, Some(request))
451 }
452 }
453}
454
455fn diff_applied(label: String, index: &DiffIndex) -> RequestOutcome {
457 RequestOutcome::applied_with_scope_size(
458 RequestName::DiffFilter,
459 label,
460 index.added_line_count() as u64,
461 )
462}
463
464fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
465 if path == Path::new("-") {
466 return Err(ProgrammaticError::new(
467 "`diff_file` does not support stdin; pass a file path",
468 2,
469 )
470 .with_code("FALLOW_INVALID_DIFF_FILE")
471 .with_context("analysis.diffFile"));
472 }
473 let abs = if is_absolute_path_any_platform(path) {
474 path.to_path_buf()
475 } else {
476 root.join(path)
477 };
478 let meta = std::fs::metadata(&abs).map_err(|err| {
479 ProgrammaticError::new(
480 format!(
481 "diff file does not exist or cannot be read: {} ({err})",
482 abs.display()
483 ),
484 2,
485 )
486 .with_code("FALLOW_INVALID_DIFF_FILE")
487 .with_context("analysis.diffFile")
488 })?;
489 if !meta.is_file() {
490 return Err(ProgrammaticError::new(
491 format!("diff path is not a file: {}", abs.display()),
492 2,
493 )
494 .with_code("FALLOW_INVALID_DIFF_FILE")
495 .with_context("analysis.diffFile"));
496 }
497 if meta.len() > MAX_DIFF_BYTES {
498 return Err(ProgrammaticError::new(
499 format!(
500 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
501 meta.len(),
502 abs.display()
503 ),
504 2,
505 )
506 .with_code("FALLOW_INVALID_DIFF_FILE")
507 .with_context("analysis.diffFile"));
508 }
509 let text = std::fs::read_to_string(&abs).map_err(|err| {
510 ProgrammaticError::new(
511 format!("failed to read diff file {}: {err}", abs.display()),
512 2,
513 )
514 .with_code("FALLOW_INVALID_DIFF_FILE")
515 .with_context("analysis.diffFile")
516 })?;
517 Ok(DiffIndex::from_unified_diff(&text))
518}
519
520fn resolve_changed_since(
530 options: &AnalysisOptions,
531 root: &Path,
532 request: &OnceLock<RequestOutcome>,
533 files: &OnceLock<FxHashSet<PathBuf>>,
534) -> Option<String> {
535 if let Some(git_ref) = options.changed_since.as_deref() {
536 return Some(git_ref.to_owned());
537 }
538 let git_ref = options.ambient_changed_since.as_deref()?;
539 match fallow_engine::changed_files::changed_files(root, git_ref) {
540 Ok(changed) => {
541 let _ = files.set(
542 changed
543 .iter()
544 .map(|path| dunce::simplified(path).to_path_buf())
545 .collect(),
546 );
547 let _ = request.set(RequestOutcome::applied(RequestName::ChangedSince, git_ref));
548 Some(git_ref.to_owned())
549 }
550 Err(err) => {
551 let _ = request.set(RequestOutcome::not_applied(
552 RequestName::ChangedSince,
553 git_ref,
554 err.reason(),
555 err.changed_since_message(git_ref),
556 ));
557 None
558 }
559 }
560}
561
562pub fn changed_files_for_run(
563 resolved: &ProgrammaticAnalysisContext,
564) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
565 let Some(git_ref) = resolved.changed_since.as_deref() else {
566 return Ok(None);
567 };
568 fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
569 .inspect(|files| resolved.record_changed_since_applied(git_ref, files))
570 .map(Some)
571 .map_err(|err| {
572 ProgrammaticError::new(
573 format!(
574 "failed to resolve changed files for ref `{git_ref}`: {}",
575 err.describe()
576 ),
577 2,
578 )
579 .with_code("FALLOW_CHANGED_FILES_FAILED")
580 .with_context("analysis.changedSince")
581 })
582}
583
584pub fn workspace_roots_for_session(
585 resolved: &ProgrammaticAnalysisContext,
586 workspaces: &[WorkspaceInfo],
587) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
588 resolve_workspace_scope_from_workspaces(
589 &resolved.root,
590 resolved.workspace.as_deref(),
591 resolved.changed_workspaces.as_deref(),
592 workspaces,
593 )
594}
595
596fn resolve_workspace_scope(
597 root: &Path,
598 workspace: Option<&[String]>,
599 changed_workspaces: Option<&str>,
600) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
601 fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
602 root,
603 workspace,
604 changed_workspaces,
605 )
606 .map_err(map_workspace_scope_error)
607}
608
609fn resolve_workspace_scope_from_workspaces(
610 root: &Path,
611 workspace: Option<&[String]>,
612 changed_workspaces: Option<&str>,
613 workspaces: &[WorkspaceInfo],
614) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
615 fallow_engine::workspace_scope::resolve_workspace_scope_roots(
616 root,
617 workspace,
618 changed_workspaces,
619 workspaces,
620 )
621 .map_err(map_workspace_scope_error)
622}
623
624fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
625 match err {
626 WorkspaceScopeError::NoWorkspaces {
627 mode,
628 patterns,
629 git_ref,
630 } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
631 WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
632 format!("invalid `workspace` pattern '{pattern}': {message}"),
633 2,
634 )
635 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
636 .with_context("analysis.workspace"),
637 WorkspaceScopeError::UnmatchedPatterns {
638 patterns,
639 available,
640 } => ProgrammaticError::new(
641 format!(
642 "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
643 if patterns.len() == 1 { "" } else { "s" },
644 quote_owned_patterns(&patterns),
645 ),
646 2,
647 )
648 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
649 .with_context("analysis.workspace"),
650 WorkspaceScopeError::EmptyAfterExclusions { .. } => {
651 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
652 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
653 .with_context("analysis.workspace")
654 }
655 WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
656 ProgrammaticError::new(
657 format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
658 2,
659 )
660 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
661 .with_context("analysis.changedWorkspaces")
662 }
663 WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
664 "`workspace` and `changed_workspaces` are mutually exclusive",
665 2,
666 )
667 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
668 .with_context("analysis.workspace"),
669 }
670}
671
672fn map_no_workspaces_error(
673 mode: WorkspaceScopeMode,
674 patterns: &[String],
675 git_ref: Option<&str>,
676) -> ProgrammaticError {
677 match mode {
678 WorkspaceScopeMode::Workspace => ProgrammaticError::new(
679 format!(
680 "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
681 quote_owned_patterns(patterns)
682 ),
683 2,
684 )
685 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
686 .with_context("analysis.workspace"),
687 WorkspaceScopeMode::ChangedWorkspaces => {
688 let git_ref = git_ref.unwrap_or_default();
689 ProgrammaticError::new(
690 format!(
691 "`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\"."
692 ),
693 2,
694 )
695 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
696 .with_context("analysis.changedWorkspaces")
697 }
698 }
699}
700
701fn quote_owned_patterns(patterns: &[String]) -> String {
702 patterns
703 .iter()
704 .map(|pattern| format!("'{pattern}'"))
705 .collect::<Vec<_>>()
706 .join(", ")
707}
708
709#[cfg(test)]
710mod tests {
711 use std::process::Command;
712
713 use crate::AnalysisOptions;
714
715 const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
716 const STACK_PROBE_TEST: &str =
717 "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
718
719 #[test]
726 fn programmatic_pool_survives_deep_worker_stack_probe() {
727 if std::env::var_os(STACK_PROBE_ENV).is_some() {
728 run_stack_probe_child();
729 return;
730 }
731
732 let current_exe = std::env::current_exe().expect("current test binary should be known");
733 let output = Command::new(current_exe)
734 .arg("--exact")
735 .arg(STACK_PROBE_TEST)
736 .arg("--nocapture")
737 .env(STACK_PROBE_ENV, "1")
738 .env_remove("RUST_MIN_STACK")
739 .output()
740 .expect("stack probe child should start");
741
742 assert!(
743 output.status.success(),
744 "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
745 output.status.code(),
746 String::from_utf8_lossy(&output.stdout),
747 String::from_utf8_lossy(&output.stderr)
748 );
749 }
750
751 fn run_stack_probe_child() {
752 let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
753 let options = AnalysisOptions {
754 root: Some(root.path().to_path_buf()),
755 threads: Some(1),
756 ..AnalysisOptions::default()
757 };
758 let context = super::resolve_programmatic_analysis_context(&options)
759 .expect("stack probe context should resolve");
760 assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
761 }
762
763 #[inline(never)]
764 fn consume_stack(depth: usize) -> usize {
765 let frame = [0_u8; 2048];
766 std::hint::black_box(&frame);
767 if depth == 0 {
768 usize::from(frame[0])
769 } else {
770 1 + consume_stack(depth - 1)
771 }
772 }
773}