Skip to main content

fallow_api/
analysis_context.rs

1//! Shared programmatic analysis context resolution.
2
3use 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
15/// Resolved common programmatic analysis context.
16///
17/// This owns validation, root/config/diff resolution, production overrides,
18/// workspace scope, and the per-call thread pool shared by programmatic
19/// analysis families. API runtimes and engine-backed runners use it directly.
20pub 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
36/// Resolve common programmatic analysis options once for a concrete runtime.
37///
38/// # Errors
39///
40/// Returns a structured programmatic error for invalid roots, configs, thread
41/// counts, workspace scopes, or explicit diff files.
42pub 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    /// Run work inside the per-call Rayon pool.
156    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
157        self.pool.install(f)
158    }
159
160    /// Resolved analysis root.
161    #[must_use]
162    pub fn root(&self) -> &Path {
163        &self.root
164    }
165
166    /// Config path supplied by the caller, if any.
167    #[must_use]
168    pub fn config_path(&self) -> &Option<PathBuf> {
169        &self.config_path
170    }
171
172    /// Whether this call permits remote config inheritance.
173    #[must_use]
174    pub const fn allow_remote_extends(&self) -> bool {
175        self.allow_remote_extends
176    }
177
178    /// Whether parser cache use is disabled for this call.
179    #[must_use]
180    pub const fn no_cache(&self) -> bool {
181        self.no_cache
182    }
183
184    /// Effective parser thread count for this call.
185    #[must_use]
186    pub const fn threads(&self) -> usize {
187        self.threads
188    }
189
190    /// Parsed explicit diff file, if supplied.
191    #[must_use]
192    pub const fn diff_index(&self) -> Option<&DiffIndex> {
193        self.diff.as_ref()
194    }
195
196    /// Explicit production override supplied by the caller.
197    #[must_use]
198    pub const fn production_override(&self) -> Option<bool> {
199        self.production_override
200    }
201
202    /// Git ref used to scope changed files.
203    #[must_use]
204    pub fn changed_since(&self) -> Option<&str> {
205        self.changed_since.as_deref()
206    }
207
208    /// Workspace filter patterns supplied by the caller.
209    #[must_use]
210    pub fn workspace(&self) -> Option<&[String]> {
211        self.workspace.as_deref()
212    }
213
214    /// Git ref used to scope changed workspaces.
215    #[must_use]
216    pub fn changed_workspaces(&self) -> Option<&str> {
217        self.changed_workspaces.as_deref()
218    }
219
220    /// Whether API JSON should include explanatory metadata.
221    #[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    // A stack overflow aborts the whole process, so the probe re-runs this
453    // test binary as a child and asserts on its exit status; the same pattern
454    // guards the CLI global pool in crates/cli/src/rayon_pool.rs. The child
455    // drops RUST_MIN_STACK (pinned to 16 MiB in .cargo/config.toml, and
456    // inherited by default-sized rayon workers) so the probe still fails if
457    // the pool loses its explicit stack_size.
458    #[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}