1use std::path::{Path, PathBuf};
4
5use fallow_config::WorkspaceInfo;
6use fallow_engine::workspace_scope::{WorkspaceScopeError, WorkspaceScopeMode};
7use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
8use fallow_types::path_util::is_absolute_path_any_platform;
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) allow_remote_extends: bool,
24 pub(crate) no_cache: bool,
25 pub(crate) threads: usize,
26 pub(crate) pool: rayon::ThreadPool,
27 pub(crate) diff: Option<DiffIndex>,
28 pub(crate) production_override: Option<bool>,
29 pub(crate) changed_since: Option<String>,
30 pub(crate) workspace: Option<Vec<String>>,
31 pub(crate) changed_workspaces: Option<String>,
32 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
33 pub(crate) explain: bool,
34}
35
36pub fn resolve_programmatic_analysis_context(
43 options: &AnalysisOptions,
44) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
45 resolve_programmatic_analysis_context_inner(options, true)
46}
47
48pub fn resolve_programmatic_analysis_context_deferred_workspace(
49 options: &AnalysisOptions,
50) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
51 resolve_programmatic_analysis_context_inner(options, false)
52}
53
54fn resolve_programmatic_analysis_context_inner(
55 options: &AnalysisOptions,
56 resolve_workspace: bool,
57) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
58 validate_analysis_option_shape(options)?;
59 let root = resolve_analysis_root(options.root.as_deref())?;
60 validate_analysis_config_path(options.config_path.as_deref())?;
61 let threads = options.threads.unwrap_or_else(default_threads);
62 let pool = fallow_engine::thread_pool::worker_pool_builder(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 allow_remote_extends: options.allow_remote_extends,
87 no_cache: options.no_cache,
88 threads,
89 pool,
90 diff,
91 production_override: options
92 .production_override
93 .or_else(|| options.production.then_some(true)),
94 changed_since: options.changed_since.clone(),
95 workspace: options.workspace.clone(),
96 changed_workspaces: options.changed_workspaces.clone(),
97 workspace_roots,
98 explain: options.explain,
99 })
100}
101
102fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
103 if options.threads == Some(0) {
104 return Err(
105 ProgrammaticError::new("`threads` must be greater than 0", 2)
106 .with_code("FALLOW_INVALID_THREADS")
107 .with_context("analysis.threads"),
108 );
109 }
110 if options.workspace.is_some() && options.changed_workspaces.is_some() {
111 return Err(ProgrammaticError::new(
112 "`workspace` and `changed_workspaces` are mutually exclusive",
113 2,
114 )
115 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
116 .with_context("analysis.workspace"));
117 }
118 Ok(())
119}
120
121fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
122 let root = match root {
123 Some(root) => root.to_path_buf(),
124 None => std::env::current_dir().map_err(|err| {
125 ProgrammaticError::new(
126 format!("failed to resolve current working directory: {err}"),
127 2,
128 )
129 .with_code("FALLOW_CWD_UNAVAILABLE")
130 .with_context("analysis.root")
131 })?,
132 };
133 fallow_engine::validate::validate_root(&root).map_err(|err| {
134 ProgrammaticError::new(err, 2)
135 .with_code("FALLOW_INVALID_ROOT")
136 .with_context("analysis.root")
137 })
138}
139
140fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
141 if let Some(config_path) = config_path
142 && !config_path.exists()
143 {
144 return Err(ProgrammaticError::new(
145 format!("config file does not exist: {}", config_path.display()),
146 2,
147 )
148 .with_code("FALLOW_INVALID_CONFIG_PATH")
149 .with_context("analysis.configPath"));
150 }
151 Ok(())
152}
153
154impl ProgrammaticAnalysisContext {
155 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
157 self.pool.install(f)
158 }
159
160 #[must_use]
162 pub fn root(&self) -> &Path {
163 &self.root
164 }
165
166 #[must_use]
168 pub fn config_path(&self) -> &Option<PathBuf> {
169 &self.config_path
170 }
171
172 #[must_use]
174 pub const fn allow_remote_extends(&self) -> bool {
175 self.allow_remote_extends
176 }
177
178 #[must_use]
180 pub const fn no_cache(&self) -> bool {
181 self.no_cache
182 }
183
184 #[must_use]
186 pub const fn threads(&self) -> usize {
187 self.threads
188 }
189
190 #[must_use]
192 pub const fn diff_index(&self) -> Option<&DiffIndex> {
193 self.diff.as_ref()
194 }
195
196 #[must_use]
198 pub const fn production_override(&self) -> Option<bool> {
199 self.production_override
200 }
201
202 #[must_use]
204 pub fn changed_since(&self) -> Option<&str> {
205 self.changed_since.as_deref()
206 }
207
208 #[must_use]
210 pub fn workspace(&self) -> Option<&[String]> {
211 self.workspace.as_deref()
212 }
213
214 #[must_use]
216 pub fn changed_workspaces(&self) -> Option<&str> {
217 self.changed_workspaces.as_deref()
218 }
219
220 #[must_use]
222 pub const fn explain_enabled(&self) -> bool {
223 self.explain
224 }
225}
226
227fn default_threads() -> usize {
228 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
229}
230
231fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
232 if path == Path::new("-") {
233 return Err(ProgrammaticError::new(
234 "`diff_file` does not support stdin; pass a file path",
235 2,
236 )
237 .with_code("FALLOW_INVALID_DIFF_FILE")
238 .with_context("analysis.diffFile"));
239 }
240 let abs = if is_absolute_path_any_platform(path) {
241 path.to_path_buf()
242 } else {
243 root.join(path)
244 };
245 let meta = std::fs::metadata(&abs).map_err(|err| {
246 ProgrammaticError::new(
247 format!(
248 "diff file does not exist or cannot be read: {} ({err})",
249 abs.display()
250 ),
251 2,
252 )
253 .with_code("FALLOW_INVALID_DIFF_FILE")
254 .with_context("analysis.diffFile")
255 })?;
256 if !meta.is_file() {
257 return Err(ProgrammaticError::new(
258 format!("diff path is not a file: {}", abs.display()),
259 2,
260 )
261 .with_code("FALLOW_INVALID_DIFF_FILE")
262 .with_context("analysis.diffFile"));
263 }
264 if meta.len() > MAX_DIFF_BYTES {
265 return Err(ProgrammaticError::new(
266 format!(
267 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
268 meta.len(),
269 abs.display()
270 ),
271 2,
272 )
273 .with_code("FALLOW_INVALID_DIFF_FILE")
274 .with_context("analysis.diffFile"));
275 }
276 let text = std::fs::read_to_string(&abs).map_err(|err| {
277 ProgrammaticError::new(
278 format!("failed to read diff file {}: {err}", abs.display()),
279 2,
280 )
281 .with_code("FALLOW_INVALID_DIFF_FILE")
282 .with_context("analysis.diffFile")
283 })?;
284 Ok(DiffIndex::from_unified_diff(&text))
285}
286
287pub fn changed_files_for_run(
288 resolved: &ProgrammaticAnalysisContext,
289) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
290 let Some(git_ref) = resolved.changed_since.as_deref() else {
291 return Ok(None);
292 };
293 fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
294 .map(Some)
295 .map_err(|err| {
296 ProgrammaticError::new(
297 format!(
298 "failed to resolve changed files for ref `{git_ref}`: {}",
299 err.describe()
300 ),
301 2,
302 )
303 .with_code("FALLOW_CHANGED_FILES_FAILED")
304 .with_context("analysis.changedSince")
305 })
306}
307
308pub fn workspace_roots_for_session(
309 resolved: &ProgrammaticAnalysisContext,
310 workspaces: &[WorkspaceInfo],
311) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
312 resolve_workspace_scope_from_workspaces(
313 &resolved.root,
314 resolved.workspace.as_deref(),
315 resolved.changed_workspaces.as_deref(),
316 workspaces,
317 )
318}
319
320fn resolve_workspace_scope(
321 root: &Path,
322 workspace: Option<&[String]>,
323 changed_workspaces: Option<&str>,
324) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
325 fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
326 root,
327 workspace,
328 changed_workspaces,
329 )
330 .map_err(map_workspace_scope_error)
331}
332
333fn resolve_workspace_scope_from_workspaces(
334 root: &Path,
335 workspace: Option<&[String]>,
336 changed_workspaces: Option<&str>,
337 workspaces: &[WorkspaceInfo],
338) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
339 fallow_engine::workspace_scope::resolve_workspace_scope_roots(
340 root,
341 workspace,
342 changed_workspaces,
343 workspaces,
344 )
345 .map_err(map_workspace_scope_error)
346}
347
348#[cfg(test)]
349pub fn resolve_workspace_filters(
350 root: &Path,
351 patterns: &[String],
352) -> ProgrammaticResult<Vec<PathBuf>> {
353 fallow_engine::workspace_scope::resolve_workspace_filter_roots_for_project(root, patterns)
354 .map_err(map_workspace_scope_error)
355}
356
357fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
358 match err {
359 WorkspaceScopeError::NoWorkspaces {
360 mode,
361 patterns,
362 git_ref,
363 } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
364 WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
365 format!("invalid `workspace` pattern '{pattern}': {message}"),
366 2,
367 )
368 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
369 .with_context("analysis.workspace"),
370 WorkspaceScopeError::UnmatchedPatterns {
371 patterns,
372 available,
373 } => ProgrammaticError::new(
374 format!(
375 "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
376 if patterns.len() == 1 { "" } else { "s" },
377 quote_owned_patterns(&patterns),
378 ),
379 2,
380 )
381 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
382 .with_context("analysis.workspace"),
383 WorkspaceScopeError::EmptyAfterExclusions { .. } => {
384 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
385 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
386 .with_context("analysis.workspace")
387 }
388 WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
389 ProgrammaticError::new(
390 format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
391 2,
392 )
393 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
394 .with_context("analysis.changedWorkspaces")
395 }
396 WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
397 "`workspace` and `changed_workspaces` are mutually exclusive",
398 2,
399 )
400 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
401 .with_context("analysis.workspace"),
402 }
403}
404
405fn map_no_workspaces_error(
406 mode: WorkspaceScopeMode,
407 patterns: &[String],
408 git_ref: Option<&str>,
409) -> ProgrammaticError {
410 match mode {
411 WorkspaceScopeMode::Workspace => ProgrammaticError::new(
412 format!(
413 "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
414 quote_owned_patterns(patterns)
415 ),
416 2,
417 )
418 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
419 .with_context("analysis.workspace"),
420 WorkspaceScopeMode::ChangedWorkspaces => {
421 let git_ref = git_ref.unwrap_or_default();
422 ProgrammaticError::new(
423 format!(
424 "`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\"."
425 ),
426 2,
427 )
428 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
429 .with_context("analysis.changedWorkspaces")
430 }
431 }
432}
433
434fn quote_owned_patterns(patterns: &[String]) -> String {
435 patterns
436 .iter()
437 .map(|pattern| format!("'{pattern}'"))
438 .collect::<Vec<_>>()
439 .join(", ")
440}
441
442#[cfg(test)]
443mod tests {
444 use std::process::Command;
445
446 use crate::AnalysisOptions;
447
448 const STACK_PROBE_ENV: &str = "FALLOW_API_STACK_PROBE_CHILD";
449 const STACK_PROBE_TEST: &str =
450 "analysis_context::tests::programmatic_pool_survives_deep_worker_stack_probe";
451
452 #[test]
459 fn programmatic_pool_survives_deep_worker_stack_probe() {
460 if std::env::var_os(STACK_PROBE_ENV).is_some() {
461 run_stack_probe_child();
462 return;
463 }
464
465 let current_exe = std::env::current_exe().expect("current test binary should be known");
466 let output = Command::new(current_exe)
467 .arg("--exact")
468 .arg(STACK_PROBE_TEST)
469 .arg("--nocapture")
470 .env(STACK_PROBE_ENV, "1")
471 .env_remove("RUST_MIN_STACK")
472 .output()
473 .expect("stack probe child should start");
474
475 assert!(
476 output.status.success(),
477 "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
478 output.status.code(),
479 String::from_utf8_lossy(&output.stdout),
480 String::from_utf8_lossy(&output.stderr)
481 );
482 }
483
484 fn run_stack_probe_child() {
485 let root = tempfile::tempdir().expect("stack probe needs a temp analysis root");
486 let options = AnalysisOptions {
487 root: Some(root.path().to_path_buf()),
488 threads: Some(1),
489 ..AnalysisOptions::default()
490 };
491 let context = super::resolve_programmatic_analysis_context(&options)
492 .expect("stack probe context should resolve");
493 assert_eq!(context.install(|| consume_stack(5_000)), 5_000);
494 }
495
496 #[inline(never)]
497 fn consume_stack(depth: usize) -> usize {
498 let frame = [0_u8; 2048];
499 std::hint::black_box(&frame);
500 if depth == 0 {
501 usize::from(frame[0])
502 } else {
503 1 + consume_stack(depth - 1)
504 }
505 }
506}