1use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use fallow_config::WorkspaceInfo;
8use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
9use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
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) production_override: Option<bool>,
31 pub(crate) changed_since: Option<String>,
32 pub(crate) workspace: Option<Vec<String>>,
33 pub(crate) changed_workspaces: Option<String>,
34 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
35 pub(crate) explain: bool,
36 pub(crate) cancellation: Option<Arc<AtomicBool>>,
37}
38
39pub fn resolve_programmatic_analysis_context(
46 options: &AnalysisOptions,
47) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
48 resolve_programmatic_analysis_context_inner(options, true)
49}
50
51pub fn resolve_programmatic_analysis_context_deferred_workspace(
52 options: &AnalysisOptions,
53) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
54 resolve_programmatic_analysis_context_inner(options, false)
55}
56
57fn resolve_programmatic_analysis_context_inner(
58 options: &AnalysisOptions,
59 resolve_workspace: bool,
60) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
61 validate_analysis_option_shape(options)?;
62 let root = resolve_analysis_root(options.root.as_deref())?;
63 validate_analysis_config_path(options.config_path.as_deref())?;
64 let threads = options.threads.unwrap_or_else(default_threads);
65 let pool = fallow_engine::thread_pool::worker_pool_builder(threads)
66 .build()
67 .map_err(|err| {
68 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
69 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
70 .with_context("analysis.threads")
71 })?;
72 let diff = options
73 .diff_file
74 .as_deref()
75 .map(|path| load_explicit_diff_file(path, &root))
76 .transpose()?;
77 let workspace_roots = if resolve_workspace {
78 resolve_workspace_scope(
79 &root,
80 options.workspace.as_deref(),
81 options.changed_workspaces.as_deref(),
82 )?
83 } else {
84 None
85 };
86 Ok(ProgrammaticAnalysisContext {
87 root,
88 config_path: options.config_path.clone(),
89 allow_remote_extends: options.allow_remote_extends,
90 no_cache: options.no_cache,
91 threads,
92 pool,
93 diff,
94 production_override: options
95 .production_override
96 .or_else(|| options.production.then_some(true)),
97 changed_since: options.changed_since.clone(),
98 workspace: options.workspace.clone(),
99 changed_workspaces: options.changed_workspaces.clone(),
100 workspace_roots,
101 explain: options.explain,
102 cancellation: options.cancellation.clone(),
103 })
104}
105
106fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
107 if options.threads == Some(0) {
108 return Err(
109 ProgrammaticError::new("`threads` must be greater than 0", 2)
110 .with_code("FALLOW_INVALID_THREADS")
111 .with_context("analysis.threads"),
112 );
113 }
114 if options.workspace.is_some() && options.changed_workspaces.is_some() {
115 return Err(ProgrammaticError::new(
116 "`workspace` and `changed_workspaces` are mutually exclusive",
117 2,
118 )
119 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
120 .with_context("analysis.workspace"));
121 }
122 Ok(())
123}
124
125pub fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
126 let root = match root {
127 Some(root) => root.to_path_buf(),
128 None => std::env::current_dir().map_err(|err| {
129 ProgrammaticError::new(
130 format!("failed to resolve current working directory: {err}"),
131 2,
132 )
133 .with_code("FALLOW_CWD_UNAVAILABLE")
134 .with_context("analysis.root")
135 })?,
136 };
137 fallow_engine::validate::validate_root(&root).map_err(|err| {
138 ProgrammaticError::new(err, 2)
139 .with_code("FALLOW_INVALID_ROOT")
140 .with_context("analysis.root")
141 })
142}
143
144pub fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
145 if let Some(config_path) = config_path
146 && !config_path.exists()
147 {
148 return Err(ProgrammaticError::new(
149 format!("config file does not exist: {}", config_path.display()),
150 2,
151 )
152 .with_code("FALLOW_INVALID_CONFIG_PATH")
153 .with_context("analysis.configPath"));
154 }
155 Ok(())
156}
157
158impl ProgrammaticAnalysisContext {
159 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
161 self.pool.install(f)
162 }
163
164 #[must_use]
166 pub fn root(&self) -> &Path {
167 &self.root
168 }
169
170 #[must_use]
172 pub fn config_path(&self) -> &Option<PathBuf> {
173 &self.config_path
174 }
175
176 #[must_use]
178 pub const fn allow_remote_extends(&self) -> bool {
179 self.allow_remote_extends
180 }
181
182 #[must_use]
184 pub const fn no_cache(&self) -> bool {
185 self.no_cache
186 }
187
188 #[must_use]
190 pub const fn threads(&self) -> usize {
191 self.threads
192 }
193
194 #[must_use]
196 pub const fn diff_index(&self) -> Option<&DiffIndex> {
197 self.diff.as_ref()
198 }
199
200 #[must_use]
202 pub const fn production_override(&self) -> Option<bool> {
203 self.production_override
204 }
205
206 #[must_use]
208 pub fn changed_since(&self) -> Option<&str> {
209 self.changed_since.as_deref()
210 }
211
212 #[must_use]
214 pub fn workspace(&self) -> Option<&[String]> {
215 self.workspace.as_deref()
216 }
217
218 #[must_use]
220 pub fn changed_workspaces(&self) -> Option<&str> {
221 self.changed_workspaces.as_deref()
222 }
223
224 #[must_use]
226 pub const fn explain_enabled(&self) -> bool {
227 self.explain
228 }
229
230 #[must_use]
232 pub fn cancellation(&self) -> Option<&Arc<AtomicBool>> {
233 self.cancellation.as_ref()
234 }
235
236 #[must_use]
238 pub fn is_cancelled(&self) -> bool {
239 self.cancellation
240 .as_ref()
241 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
242 }
243
244 pub fn ensure_not_cancelled(&self, stage: &str) -> ProgrammaticResult<()> {
255 if self.is_cancelled() {
256 return Err(cancelled_error(stage));
257 }
258 Ok(())
259 }
260}
261
262pub fn ensure_options_not_cancelled(
271 options: &AnalysisOptions,
272 stage: &str,
273) -> ProgrammaticResult<()> {
274 if options
275 .cancellation
276 .as_ref()
277 .is_some_and(|cancelled| cancelled.load(Ordering::SeqCst))
278 {
279 return Err(cancelled_error(stage));
280 }
281 Ok(())
282}
283
284#[must_use]
289pub fn cancelled_error(stage: &str) -> ProgrammaticError {
290 cancelled_error_message(&format!("analysis was cancelled before {stage}"))
291}
292
293#[must_use]
295pub fn cancelled_error_message(message: &str) -> ProgrammaticError {
296 ProgrammaticError::new(message, 2)
297 .with_code("FALLOW_CANCELLED")
298 .with_context("analysis.cancellation")
299}
300
301fn default_threads() -> usize {
302 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
303}
304
305fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
306 if path == Path::new("-") {
307 return Err(ProgrammaticError::new(
308 "`diff_file` does not support stdin; pass a file path",
309 2,
310 )
311 .with_code("FALLOW_INVALID_DIFF_FILE")
312 .with_context("analysis.diffFile"));
313 }
314 let abs = if is_absolute_path_any_platform(path) {
315 path.to_path_buf()
316 } else {
317 root.join(path)
318 };
319 let meta = std::fs::metadata(&abs).map_err(|err| {
320 ProgrammaticError::new(
321 format!(
322 "diff file does not exist or cannot be read: {} ({err})",
323 abs.display()
324 ),
325 2,
326 )
327 .with_code("FALLOW_INVALID_DIFF_FILE")
328 .with_context("analysis.diffFile")
329 })?;
330 if !meta.is_file() {
331 return Err(ProgrammaticError::new(
332 format!("diff path is not a file: {}", abs.display()),
333 2,
334 )
335 .with_code("FALLOW_INVALID_DIFF_FILE")
336 .with_context("analysis.diffFile"));
337 }
338 if meta.len() > MAX_DIFF_BYTES {
339 return Err(ProgrammaticError::new(
340 format!(
341 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
342 meta.len(),
343 abs.display()
344 ),
345 2,
346 )
347 .with_code("FALLOW_INVALID_DIFF_FILE")
348 .with_context("analysis.diffFile"));
349 }
350 let text = std::fs::read_to_string(&abs).map_err(|err| {
351 ProgrammaticError::new(
352 format!("failed to read diff file {}: {err}", abs.display()),
353 2,
354 )
355 .with_code("FALLOW_INVALID_DIFF_FILE")
356 .with_context("analysis.diffFile")
357 })?;
358 Ok(DiffIndex::from_unified_diff(&text))
359}
360
361pub fn changed_files_for_run(
362 resolved: &ProgrammaticAnalysisContext,
363) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
364 let Some(git_ref) = resolved.changed_since.as_deref() else {
365 return Ok(None);
366 };
367 fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
368 .map(Some)
369 .map_err(|err| {
370 ProgrammaticError::new(
371 format!(
372 "failed to resolve changed files for ref `{git_ref}`: {}",
373 err.describe()
374 ),
375 2,
376 )
377 .with_code("FALLOW_CHANGED_FILES_FAILED")
378 .with_context("analysis.changedSince")
379 })
380}
381
382pub fn workspace_roots_for_session(
383 resolved: &ProgrammaticAnalysisContext,
384 workspaces: &[WorkspaceInfo],
385) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
386 resolve_workspace_scope_from_workspaces(
387 &resolved.root,
388 resolved.workspace.as_deref(),
389 resolved.changed_workspaces.as_deref(),
390 workspaces,
391 )
392}
393
394fn resolve_workspace_scope(
395 root: &Path,
396 workspace: Option<&[String]>,
397 changed_workspaces: Option<&str>,
398) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
399 fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
400 root,
401 workspace,
402 changed_workspaces,
403 )
404 .map_err(map_workspace_scope_error)
405}
406
407fn resolve_workspace_scope_from_workspaces(
408 root: &Path,
409 workspace: Option<&[String]>,
410 changed_workspaces: Option<&str>,
411 workspaces: &[WorkspaceInfo],
412) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
413 fallow_engine::workspace_scope::resolve_workspace_scope_roots(
414 root,
415 workspace,
416 changed_workspaces,
417 workspaces,
418 )
419 .map_err(map_workspace_scope_error)
420}
421
422#[cfg(test)]
423pub fn resolve_workspace_filters(
424 root: &Path,
425 patterns: &[String],
426) -> ProgrammaticResult<Vec<PathBuf>> {
427 fallow_engine::workspace_scope::resolve_workspace_filter_roots_for_project(root, patterns)
428 .map_err(map_workspace_scope_error)
429}
430
431fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
432 match err {
433 WorkspaceScopeError::NoWorkspaces {
434 mode,
435 patterns,
436 git_ref,
437 } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
438 WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
439 format!("invalid `workspace` pattern '{pattern}': {message}"),
440 2,
441 )
442 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
443 .with_context("analysis.workspace"),
444 WorkspaceScopeError::UnmatchedPatterns {
445 patterns,
446 available,
447 } => ProgrammaticError::new(
448 format!(
449 "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
450 if patterns.len() == 1 { "" } else { "s" },
451 quote_owned_patterns(&patterns),
452 ),
453 2,
454 )
455 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
456 .with_context("analysis.workspace"),
457 WorkspaceScopeError::EmptyAfterExclusions { .. } => {
458 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
459 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
460 .with_context("analysis.workspace")
461 }
462 WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
463 ProgrammaticError::new(
464 format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
465 2,
466 )
467 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
468 .with_context("analysis.changedWorkspaces")
469 }
470 WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
471 "`workspace` and `changed_workspaces` are mutually exclusive",
472 2,
473 )
474 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
475 .with_context("analysis.workspace"),
476 }
477}
478
479fn map_no_workspaces_error(
480 mode: WorkspaceScopeMode,
481 patterns: &[String],
482 git_ref: Option<&str>,
483) -> ProgrammaticError {
484 match mode {
485 WorkspaceScopeMode::Workspace => ProgrammaticError::new(
486 format!(
487 "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
488 quote_owned_patterns(patterns)
489 ),
490 2,
491 )
492 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
493 .with_context("analysis.workspace"),
494 WorkspaceScopeMode::ChangedWorkspaces => {
495 let git_ref = git_ref.unwrap_or_default();
496 ProgrammaticError::new(
497 format!(
498 "`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\"."
499 ),
500 2,
501 )
502 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
503 .with_context("analysis.changedWorkspaces")
504 }
505 }
506}
507
508fn quote_owned_patterns(patterns: &[String]) -> String {
509 patterns
510 .iter()
511 .map(|pattern| format!("'{pattern}'"))
512 .collect::<Vec<_>>()
513 .join(", ")
514}
515
516#[cfg(test)]
517mod tests {
518 use std::process::Command;
519
520 use crate::AnalysisOptions;
521
522 const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
523 const STACK_PROBE_TEST: &str =
524 "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
525
526 #[test]
533 fn programmatic_pool_survives_deep_worker_stack_probe() {
534 if std::env::var_os(STACK_PROBE_ENV).is_some() {
535 run_stack_probe_child();
536 return;
537 }
538
539 let current_exe = std::env::current_exe().expect("current test binary should be known");
540 let output = Command::new(current_exe)
541 .arg("--exact")
542 .arg(STACK_PROBE_TEST)
543 .arg("--nocapture")
544 .env(STACK_PROBE_ENV, "1")
545 .env_remove("RUST_MIN_STACK")
546 .output()
547 .expect("stack probe child should start");
548
549 assert!(
550 output.status.success(),
551 "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
552 output.status.code(),
553 String::from_utf8_lossy(&output.stdout),
554 String::from_utf8_lossy(&output.stderr)
555 );
556 }
557
558 fn run_stack_probe_child() {
559 let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
560 let options = AnalysisOptions {
561 root: Some(root.path().to_path_buf()),
562 threads: Some(1),
563 ..AnalysisOptions::default()
564 };
565 let context = super::resolve_programmatic_analysis_context(&options)
566 .expect("stack probe context should resolve");
567 assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
568 }
569
570 #[inline(never)]
571 fn consume_stack(depth: usize) -> usize {
572 let frame = [0_u8; 2048];
573 std::hint::black_box(&frame);
574 if depth == 0 {
575 usize::from(frame[0])
576 } else {
577 1 + consume_stack(depth - 1)
578 }
579 }
580}