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 = rayon::ThreadPoolBuilder::new()
63        .num_threads(threads)
64        .build()
65        .map_err(|err| {
66            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
67                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
68                .with_context("analysis.threads")
69        })?;
70    let diff = options
71        .diff_file
72        .as_deref()
73        .map(|path| load_explicit_diff_file(path, &root))
74        .transpose()?;
75    let workspace_roots = if resolve_workspace {
76        resolve_workspace_scope(
77            &root,
78            options.workspace.as_deref(),
79            options.changed_workspaces.as_deref(),
80        )?
81    } else {
82        None
83    };
84    Ok(ProgrammaticAnalysisContext {
85        root,
86        config_path: options.config_path.clone(),
87        allow_remote_extends: options.allow_remote_extends,
88        no_cache: options.no_cache,
89        threads,
90        pool,
91        diff,
92        production_override: options
93            .production_override
94            .or_else(|| options.production.then_some(true)),
95        changed_since: options.changed_since.clone(),
96        workspace: options.workspace.clone(),
97        changed_workspaces: options.changed_workspaces.clone(),
98        workspace_roots,
99        explain: options.explain,
100    })
101}
102
103fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
104    if options.threads == Some(0) {
105        return Err(
106            ProgrammaticError::new("`threads` must be greater than 0", 2)
107                .with_code("FALLOW_INVALID_THREADS")
108                .with_context("analysis.threads"),
109        );
110    }
111    if options.workspace.is_some() && options.changed_workspaces.is_some() {
112        return Err(ProgrammaticError::new(
113            "`workspace` and `changed_workspaces` are mutually exclusive",
114            2,
115        )
116        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
117        .with_context("analysis.workspace"));
118    }
119    Ok(())
120}
121
122fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
123    let root = match root {
124        Some(root) => root.to_path_buf(),
125        None => std::env::current_dir().map_err(|err| {
126            ProgrammaticError::new(
127                format!("failed to resolve current working directory: {err}"),
128                2,
129            )
130            .with_code("FALLOW_CWD_UNAVAILABLE")
131            .with_context("analysis.root")
132        })?,
133    };
134    fallow_engine::validate::validate_root(&root).map_err(|err| {
135        ProgrammaticError::new(err, 2)
136            .with_code("FALLOW_INVALID_ROOT")
137            .with_context("analysis.root")
138    })
139}
140
141fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
142    if let Some(config_path) = config_path
143        && !config_path.exists()
144    {
145        return Err(ProgrammaticError::new(
146            format!("config file does not exist: {}", config_path.display()),
147            2,
148        )
149        .with_code("FALLOW_INVALID_CONFIG_PATH")
150        .with_context("analysis.configPath"));
151    }
152    Ok(())
153}
154
155impl ProgrammaticAnalysisContext {
156    /// Run work inside the per-call Rayon pool.
157    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
158        self.pool.install(f)
159    }
160
161    /// Resolved analysis root.
162    #[must_use]
163    pub fn root(&self) -> &Path {
164        &self.root
165    }
166
167    /// Config path supplied by the caller, if any.
168    #[must_use]
169    pub fn config_path(&self) -> &Option<PathBuf> {
170        &self.config_path
171    }
172
173    /// Whether this call permits remote config inheritance.
174    #[must_use]
175    pub const fn allow_remote_extends(&self) -> bool {
176        self.allow_remote_extends
177    }
178
179    /// Whether parser cache use is disabled for this call.
180    #[must_use]
181    pub const fn no_cache(&self) -> bool {
182        self.no_cache
183    }
184
185    /// Effective parser thread count for this call.
186    #[must_use]
187    pub const fn threads(&self) -> usize {
188        self.threads
189    }
190
191    /// Parsed explicit diff file, if supplied.
192    #[must_use]
193    pub const fn diff_index(&self) -> Option<&DiffIndex> {
194        self.diff.as_ref()
195    }
196
197    /// Explicit production override supplied by the caller.
198    #[must_use]
199    pub const fn production_override(&self) -> Option<bool> {
200        self.production_override
201    }
202
203    /// Git ref used to scope changed files.
204    #[must_use]
205    pub fn changed_since(&self) -> Option<&str> {
206        self.changed_since.as_deref()
207    }
208
209    /// Workspace filter patterns supplied by the caller.
210    #[must_use]
211    pub fn workspace(&self) -> Option<&[String]> {
212        self.workspace.as_deref()
213    }
214
215    /// Git ref used to scope changed workspaces.
216    #[must_use]
217    pub fn changed_workspaces(&self) -> Option<&str> {
218        self.changed_workspaces.as_deref()
219    }
220
221    /// Whether API JSON should include explanatory metadata.
222    #[must_use]
223    pub const fn explain_enabled(&self) -> bool {
224        self.explain
225    }
226}
227
228fn default_threads() -> usize {
229    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
230}
231
232fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
233    if path == Path::new("-") {
234        return Err(ProgrammaticError::new(
235            "`diff_file` does not support stdin; pass a file path",
236            2,
237        )
238        .with_code("FALLOW_INVALID_DIFF_FILE")
239        .with_context("analysis.diffFile"));
240    }
241    let abs = if is_absolute_path_any_platform(path) {
242        path.to_path_buf()
243    } else {
244        root.join(path)
245    };
246    let meta = std::fs::metadata(&abs).map_err(|err| {
247        ProgrammaticError::new(
248            format!(
249                "diff file does not exist or cannot be read: {} ({err})",
250                abs.display()
251            ),
252            2,
253        )
254        .with_code("FALLOW_INVALID_DIFF_FILE")
255        .with_context("analysis.diffFile")
256    })?;
257    if !meta.is_file() {
258        return Err(ProgrammaticError::new(
259            format!("diff path is not a file: {}", abs.display()),
260            2,
261        )
262        .with_code("FALLOW_INVALID_DIFF_FILE")
263        .with_context("analysis.diffFile"));
264    }
265    if meta.len() > MAX_DIFF_BYTES {
266        return Err(ProgrammaticError::new(
267            format!(
268                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
269                meta.len(),
270                abs.display()
271            ),
272            2,
273        )
274        .with_code("FALLOW_INVALID_DIFF_FILE")
275        .with_context("analysis.diffFile"));
276    }
277    let text = std::fs::read_to_string(&abs).map_err(|err| {
278        ProgrammaticError::new(
279            format!("failed to read diff file {}: {err}", abs.display()),
280            2,
281        )
282        .with_code("FALLOW_INVALID_DIFF_FILE")
283        .with_context("analysis.diffFile")
284    })?;
285    Ok(DiffIndex::from_unified_diff(&text))
286}
287
288pub fn changed_files_for_run(
289    resolved: &ProgrammaticAnalysisContext,
290) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
291    let Some(git_ref) = resolved.changed_since.as_deref() else {
292        return Ok(None);
293    };
294    fallow_engine::changed_files::changed_files(&resolved.root, git_ref)
295        .map(Some)
296        .map_err(|err| {
297            ProgrammaticError::new(
298                format!(
299                    "failed to resolve changed files for ref `{git_ref}`: {}",
300                    err.describe()
301                ),
302                2,
303            )
304            .with_code("FALLOW_CHANGED_FILES_FAILED")
305            .with_context("analysis.changedSince")
306        })
307}
308
309pub fn workspace_roots_for_session(
310    resolved: &ProgrammaticAnalysisContext,
311    workspaces: &[WorkspaceInfo],
312) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
313    resolve_workspace_scope_from_workspaces(
314        &resolved.root,
315        resolved.workspace.as_deref(),
316        resolved.changed_workspaces.as_deref(),
317        workspaces,
318    )
319}
320
321fn resolve_workspace_scope(
322    root: &Path,
323    workspace: Option<&[String]>,
324    changed_workspaces: Option<&str>,
325) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
326    fallow_engine::workspace_scope::resolve_workspace_scope_roots_for_project(
327        root,
328        workspace,
329        changed_workspaces,
330    )
331    .map_err(map_workspace_scope_error)
332}
333
334fn resolve_workspace_scope_from_workspaces(
335    root: &Path,
336    workspace: Option<&[String]>,
337    changed_workspaces: Option<&str>,
338    workspaces: &[WorkspaceInfo],
339) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
340    fallow_engine::workspace_scope::resolve_workspace_scope_roots(
341        root,
342        workspace,
343        changed_workspaces,
344        workspaces,
345    )
346    .map_err(map_workspace_scope_error)
347}
348
349#[cfg(test)]
350pub fn resolve_workspace_filters(
351    root: &Path,
352    patterns: &[String],
353) -> ProgrammaticResult<Vec<PathBuf>> {
354    fallow_engine::workspace_scope::resolve_workspace_filter_roots_for_project(root, patterns)
355        .map_err(map_workspace_scope_error)
356}
357
358fn map_workspace_scope_error(err: WorkspaceScopeError) -> ProgrammaticError {
359    match err {
360        WorkspaceScopeError::NoWorkspaces {
361            mode,
362            patterns,
363            git_ref,
364        } => map_no_workspaces_error(mode, &patterns, git_ref.as_deref()),
365        WorkspaceScopeError::InvalidPattern { pattern, message } => ProgrammaticError::new(
366            format!("invalid `workspace` pattern '{pattern}': {message}"),
367            2,
368        )
369        .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
370        .with_context("analysis.workspace"),
371        WorkspaceScopeError::UnmatchedPatterns {
372            patterns,
373            available,
374        } => ProgrammaticError::new(
375            format!(
376                "`workspace` matched no workspace for pattern{}: {}. Available: {available}",
377                if patterns.len() == 1 { "" } else { "s" },
378                quote_owned_patterns(&patterns),
379            ),
380            2,
381        )
382        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
383        .with_context("analysis.workspace"),
384        WorkspaceScopeError::EmptyAfterExclusions { .. } => {
385            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
386                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
387                .with_context("analysis.workspace")
388        }
389        WorkspaceScopeError::ChangedWorkspacesFailed { git_ref, message } => {
390            ProgrammaticError::new(
391                format!("failed to resolve changed workspaces for ref `{git_ref}`: {message}"),
392                2,
393            )
394            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
395            .with_context("analysis.changedWorkspaces")
396        }
397        WorkspaceScopeError::MutuallyExclusive => ProgrammaticError::new(
398            "`workspace` and `changed_workspaces` are mutually exclusive",
399            2,
400        )
401        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
402        .with_context("analysis.workspace"),
403    }
404}
405
406fn map_no_workspaces_error(
407    mode: WorkspaceScopeMode,
408    patterns: &[String],
409    git_ref: Option<&str>,
410) -> ProgrammaticError {
411    match mode {
412        WorkspaceScopeMode::Workspace => ProgrammaticError::new(
413            format!(
414                "`workspace` {} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\".",
415                quote_owned_patterns(patterns)
416            ),
417            2,
418        )
419        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
420        .with_context("analysis.workspace"),
421        WorkspaceScopeMode::ChangedWorkspaces => {
422            let git_ref = git_ref.unwrap_or_default();
423            ProgrammaticError::new(
424                format!(
425                    "`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\"."
426                ),
427                2,
428            )
429            .with_code("FALLOW_WORKSPACES_NOT_FOUND")
430            .with_context("analysis.changedWorkspaces")
431        }
432    }
433}
434
435fn quote_owned_patterns(patterns: &[String]) -> String {
436    patterns
437        .iter()
438        .map(|pattern| format!("'{pattern}'"))
439        .collect::<Vec<_>>()
440        .join(", ")
441}