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) 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
35/// Resolve common programmatic analysis options once for a concrete runtime.
36///
37/// # Errors
38///
39/// Returns a structured programmatic error for invalid roots, configs, thread
40/// counts, workspace scopes, or explicit diff files.
41pub 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    /// Run work inside the per-call Rayon pool.
155    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
156        self.pool.install(f)
157    }
158
159    /// Resolved analysis root.
160    #[must_use]
161    pub fn root(&self) -> &Path {
162        &self.root
163    }
164
165    /// Config path supplied by the caller, if any.
166    #[must_use]
167    pub fn config_path(&self) -> &Option<PathBuf> {
168        &self.config_path
169    }
170
171    /// Whether parser cache use is disabled for this call.
172    #[must_use]
173    pub const fn no_cache(&self) -> bool {
174        self.no_cache
175    }
176
177    /// Effective parser thread count for this call.
178    #[must_use]
179    pub const fn threads(&self) -> usize {
180        self.threads
181    }
182
183    /// Parsed explicit diff file, if supplied.
184    #[must_use]
185    pub const fn diff_index(&self) -> Option<&DiffIndex> {
186        self.diff.as_ref()
187    }
188
189    /// Explicit production override supplied by the caller.
190    #[must_use]
191    pub const fn production_override(&self) -> Option<bool> {
192        self.production_override
193    }
194
195    /// Git ref used to scope changed files.
196    #[must_use]
197    pub fn changed_since(&self) -> Option<&str> {
198        self.changed_since.as_deref()
199    }
200
201    /// Workspace filter patterns supplied by the caller.
202    #[must_use]
203    pub fn workspace(&self) -> Option<&[String]> {
204        self.workspace.as_deref()
205    }
206
207    /// Git ref used to scope changed workspaces.
208    #[must_use]
209    pub fn changed_workspaces(&self) -> Option<&str> {
210        self.changed_workspaces.as_deref()
211    }
212
213    /// Whether API JSON should include explanatory metadata.
214    #[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    fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
319        root,
320        workspace,
321        changed_workspaces,
322    )
323    .map_err(map_workspace_scope_error)
324}
325
326fn resolve_workspace_scope_from_workspaces(
327    root: &Path,
328    workspace: Option<&[String]>,
329    changed_workspaces: Option<&str>,
330    workspaces: &[WorkspaceInfo],
331) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
332    fallow_engine::workspace_scope::resolve_workspace_scope_roots(
333        root,
334        workspace,
335        changed_workspaces,
336        workspaces,
337    )
338    .map_err(map_workspace_scope_error)
339}
340
341#[cfg(test)]
342pub fn resolve_workspace_filters(
343    root: &Path,
344    patterns: &[String],
345) -> ProgrammaticResult<Vec<PathBuf>> {
346    fallow_engine::workspace_scope::resolve_workspace_filter_roots_for_project(root, patterns)
347        .map_err(map_workspace_scope_error)
348}
349
350fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
351    match err {
352        WorkspaceScopeError::NoWorkspaces {
353            mode,
354            patterns,
355            git_ref,
356        } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
357        WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
358            format!("invalid `workspace` pattern '{pattern}': {message}"),
359            2,
360        )
361        .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
362        .with_context("analysis.workspace"),
363        WorkspaceScopeError::UnmatchedPatterns {
364            patterns,
365            available,
366        } => ProgrammaticError::new(
367            format!(
368                "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
369                if patterns.len() == 1 { "" } else { "s" },
370                quote_owned_patterns(&patterns),
371            ),
372            2,
373        )
374        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
375        .with_context("analysis.workspace"),
376        WorkspaceScopeError::EmptyAfterExclusions { .. } => {
377            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
378                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
379                .with_context("analysis.workspace")
380        }
381        WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
382            ProgrammaticError::new(
383                format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
384                2,
385            )
386            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
387            .with_context("analysis.changedWorkspaces")
388        }
389        WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
390            "`workspace` and `changed_workspaces` are mutually exclusive",
391            2,
392        )
393        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
394        .with_context("analysis.workspace"),
395    }
396}
397
398fn map_no_workspaces_error(
399    mode: WorkspaceScopeMode,
400    patterns: &[String],
401    git_ref: Option<&str>,
402) -> ProgrammaticError {
403    match mode {
404        WorkspaceScopeMode::Workspace => ProgrammaticError::new(
405            format!(
406                "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
407                quote_owned_patterns(patterns)
408            ),
409            2,
410        )
411        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
412        .with_context("analysis.workspace"),
413        WorkspaceScopeMode::ChangedWorkspaces => {
414            let git_ref = git_ref.unwrap_or_default();
415            ProgrammaticError::new(
416                format!(
417                    "`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\"."
418                ),
419                2,
420            )
421            .with_code("FALLOW_WORKSPACES_NOT_FOUND")
422            .with_context("analysis.changedWorkspaces")
423        }
424    }
425}
426
427fn quote_owned_patterns(patterns: &[String]) -> String {
428    patterns
429        .iter()
430        .map(|pattern| format!("'{pattern}'"))
431        .collect::<Vec<_>>()
432        .join(", ")
433}