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_output::{DiffIndex, MAX_DIFF_BYTES};
7use fallow_types::path_util::is_absolute_path_any_platform;
8use globset::Glob;
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    validate_analysis_option_shape(options)?;
45    let root = resolve_analysis_root(options.root.as_deref())?;
46    validate_analysis_config_path(options.config_path.as_deref())?;
47    let threads = options.threads.unwrap_or_else(default_threads);
48    let pool = rayon::ThreadPoolBuilder::new()
49        .num_threads(threads)
50        .build()
51        .map_err(|err| {
52            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
53                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
54                .with_context("analysis.threads")
55        })?;
56    let diff = options
57        .diff_file
58        .as_deref()
59        .map(|path| load_explicit_diff_file(path, &root))
60        .transpose()?;
61    let workspace_roots = resolve_workspace_scope(
62        &root,
63        options.workspace.as_deref(),
64        options.changed_workspaces.as_deref(),
65    )?;
66    Ok(ProgrammaticAnalysisContext {
67        root,
68        config_path: options.config_path.clone(),
69        no_cache: options.no_cache,
70        threads,
71        pool,
72        diff,
73        production_override: options
74            .production_override
75            .or_else(|| options.production.then_some(true)),
76        changed_since: options.changed_since.clone(),
77        workspace: options.workspace.clone(),
78        changed_workspaces: options.changed_workspaces.clone(),
79        workspace_roots,
80        explain: options.explain,
81    })
82}
83
84fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
85    if options.threads == Some(0) {
86        return Err(
87            ProgrammaticError::new("`threads` must be greater than 0", 2)
88                .with_code("FALLOW_INVALID_THREADS")
89                .with_context("analysis.threads"),
90        );
91    }
92    if options.workspace.is_some() && options.changed_workspaces.is_some() {
93        return Err(ProgrammaticError::new(
94            "`workspace` and `changed_workspaces` are mutually exclusive",
95            2,
96        )
97        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
98        .with_context("analysis.workspace"));
99    }
100    Ok(())
101}
102
103fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
104    let root = match root {
105        Some(root) => root.to_path_buf(),
106        None => std::env::current_dir().map_err(|err| {
107            ProgrammaticError::new(
108                format!("failed to resolve current working directory: {err}"),
109                2,
110            )
111            .with_code("FALLOW_CWD_UNAVAILABLE")
112            .with_context("analysis.root")
113        })?,
114    };
115    fallow_engine::validate::validate_root(&root).map_err(|err| {
116        ProgrammaticError::new(err, 2)
117            .with_code("FALLOW_INVALID_ROOT")
118            .with_context("analysis.root")
119    })
120}
121
122fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
123    if let Some(config_path) = config_path
124        && !config_path.exists()
125    {
126        return Err(ProgrammaticError::new(
127            format!("config file does not exist: {}", config_path.display()),
128            2,
129        )
130        .with_code("FALLOW_INVALID_CONFIG_PATH")
131        .with_context("analysis.configPath"));
132    }
133    Ok(())
134}
135
136impl ProgrammaticAnalysisContext {
137    /// Run work inside the per-call Rayon pool.
138    pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
139        self.pool.install(f)
140    }
141
142    /// Resolved analysis root.
143    #[must_use]
144    pub fn root(&self) -> &Path {
145        &self.root
146    }
147
148    /// Config path supplied by the caller, if any.
149    #[must_use]
150    pub fn config_path(&self) -> &Option<PathBuf> {
151        &self.config_path
152    }
153
154    /// Whether parser cache use is disabled for this call.
155    #[must_use]
156    pub const fn no_cache(&self) -> bool {
157        self.no_cache
158    }
159
160    /// Effective parser thread count for this call.
161    #[must_use]
162    pub const fn threads(&self) -> usize {
163        self.threads
164    }
165
166    /// Parsed explicit diff file, if supplied.
167    #[must_use]
168    pub const fn diff_index(&self) -> Option<&DiffIndex> {
169        self.diff.as_ref()
170    }
171
172    /// Explicit production override supplied by the caller.
173    #[must_use]
174    pub const fn production_override(&self) -> Option<bool> {
175        self.production_override
176    }
177
178    /// Git ref used to scope changed files.
179    #[must_use]
180    pub fn changed_since(&self) -> Option<&str> {
181        self.changed_since.as_deref()
182    }
183
184    /// Workspace filter patterns supplied by the caller.
185    #[must_use]
186    pub fn workspace(&self) -> Option<&[String]> {
187        self.workspace.as_deref()
188    }
189
190    /// Git ref used to scope changed workspaces.
191    #[must_use]
192    pub fn changed_workspaces(&self) -> Option<&str> {
193        self.changed_workspaces.as_deref()
194    }
195
196    /// Whether API JSON should include explanatory metadata.
197    #[must_use]
198    pub const fn explain_enabled(&self) -> bool {
199        self.explain
200    }
201}
202
203fn default_threads() -> usize {
204    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
205}
206
207fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
208    if path == Path::new("-") {
209        return Err(ProgrammaticError::new(
210            "`diff_file` does not support stdin; pass a file path",
211            2,
212        )
213        .with_code("FALLOW_INVALID_DIFF_FILE")
214        .with_context("analysis.diffFile"));
215    }
216    let abs = if is_absolute_path_any_platform(path) {
217        path.to_path_buf()
218    } else {
219        root.join(path)
220    };
221    let meta = std::fs::metadata(&abs).map_err(|err| {
222        ProgrammaticError::new(
223            format!(
224                "diff file does not exist or cannot be read: {} ({err})",
225                abs.display()
226            ),
227            2,
228        )
229        .with_code("FALLOW_INVALID_DIFF_FILE")
230        .with_context("analysis.diffFile")
231    })?;
232    if !meta.is_file() {
233        return Err(ProgrammaticError::new(
234            format!("diff path is not a file: {}", abs.display()),
235            2,
236        )
237        .with_code("FALLOW_INVALID_DIFF_FILE")
238        .with_context("analysis.diffFile"));
239    }
240    if meta.len() > MAX_DIFF_BYTES {
241        return Err(ProgrammaticError::new(
242            format!(
243                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
244                meta.len(),
245                abs.display()
246            ),
247            2,
248        )
249        .with_code("FALLOW_INVALID_DIFF_FILE")
250        .with_context("analysis.diffFile"));
251    }
252    let text = std::fs::read_to_string(&abs).map_err(|err| {
253        ProgrammaticError::new(
254            format!("failed to read diff file {}: {err}", abs.display()),
255            2,
256        )
257        .with_code("FALLOW_INVALID_DIFF_FILE")
258        .with_context("analysis.diffFile")
259    })?;
260    Ok(DiffIndex::from_unified_diff(&text))
261}
262
263pub fn changed_files_for_run(
264    resolved: &ProgrammaticAnalysisContext,
265) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
266    let Some(git_ref) = resolved.changed_since.as_deref() else {
267        return Ok(None);
268    };
269    fallow_engine::changed_files(&resolved.root, git_ref)
270        .map(Some)
271        .map_err(|err| {
272            ProgrammaticError::new(
273                format!(
274                    "failed to resolve changed files for ref `{git_ref}`: {}",
275                    err.describe()
276                ),
277                2,
278            )
279            .with_code("FALLOW_CHANGED_FILES_FAILED")
280            .with_context("analysis.changedSince")
281        })
282}
283
284fn resolve_workspace_scope(
285    root: &Path,
286    workspace: Option<&[String]>,
287    changed_workspaces: Option<&str>,
288) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
289    match (workspace, changed_workspaces) {
290        (Some(patterns), None) => resolve_workspace_filters(root, patterns).map(Some),
291        (None, Some(git_ref)) => resolve_changed_workspaces(root, git_ref).map(Some),
292        (None, None) => Ok(None),
293        (Some(_), Some(_)) => Err(ProgrammaticError::new(
294            "`workspace` and `changed_workspaces` are mutually exclusive",
295            2,
296        )
297        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
298        .with_context("analysis.workspace")),
299    }
300}
301
302pub fn resolve_workspace_filters(
303    root: &Path,
304    patterns: &[String],
305) -> ProgrammaticResult<Vec<PathBuf>> {
306    let workspaces = fallow_config::discover_workspaces(root);
307    if workspaces.is_empty() {
308        let joined = patterns
309            .iter()
310            .map(|pattern| format!("'{pattern}'"))
311            .collect::<Vec<_>>()
312            .join(", ");
313        return Err(ProgrammaticError::new(
314            format!(
315                "`workspace` {joined} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
316            ),
317            2,
318        )
319        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
320        .with_context("analysis.workspace"));
321    }
322
323    let rel_paths = workspaces
324        .iter()
325        .map(|workspace| relative_workspace_path(&workspace.root, root))
326        .collect::<Vec<_>>();
327    let (positive, negative) = split_workspace_patterns(patterns);
328    let mut matched = match_positive_workspace_patterns(&positive, &workspaces, &rel_paths)?;
329
330    for pattern in &negative {
331        for index in find_workspace_matches(pattern, &workspaces, &rel_paths)? {
332            matched.remove(&index);
333        }
334    }
335
336    if matched.is_empty() {
337        return Err(
338            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
339                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
340                .with_context("analysis.workspace"),
341        );
342    }
343
344    let mut roots = matched
345        .into_iter()
346        .map(|index| workspaces[index].root.clone())
347        .collect::<Vec<_>>();
348    roots.sort();
349    Ok(roots)
350}
351
352fn resolve_changed_workspaces(root: &Path, git_ref: &str) -> ProgrammaticResult<Vec<PathBuf>> {
353    let workspaces = fallow_config::discover_workspaces(root);
354    if workspaces.is_empty() {
355        return Err(ProgrammaticError::new(
356            format!(
357                "`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\"."
358            ),
359            2,
360        )
361        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
362        .with_context("analysis.changedWorkspaces"));
363    }
364    let changed_files = fallow_engine::changed_files(root, git_ref).map_err(|err| {
365        ProgrammaticError::new(
366            format!(
367                "failed to resolve changed workspaces for ref `{git_ref}`: {}",
368                err.describe()
369            ),
370            2,
371        )
372        .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
373        .with_context("analysis.changedWorkspaces")
374    })?;
375    let mut roots = workspaces
376        .into_iter()
377        .filter(|workspace| {
378            changed_files
379                .iter()
380                .any(|file| file.starts_with(&workspace.root))
381        })
382        .map(|workspace| workspace.root)
383        .collect::<Vec<_>>();
384    roots.sort();
385    Ok(roots)
386}
387
388fn match_positive_workspace_patterns(
389    positive: &[&str],
390    workspaces: &[WorkspaceInfo],
391    rel_paths: &[String],
392) -> ProgrammaticResult<FxHashSet<usize>> {
393    let mut matched = FxHashSet::default();
394    let mut unmatched = Vec::new();
395
396    if positive.is_empty() {
397        matched.extend(0..workspaces.len());
398    } else {
399        for pattern in positive {
400            let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
401            if hits.is_empty() {
402                unmatched.push((*pattern).to_string());
403            }
404            matched.extend(hits);
405        }
406    }
407
408    if !unmatched.is_empty() {
409        return Err(ProgrammaticError::new(
410            format!(
411                "`workspace` matched no workspace for pattern{}: {}. Available: {}",
412                if unmatched.len() == 1 { "" } else { "s" },
413                unmatched
414                    .iter()
415                    .map(|pattern| format!("'{pattern}'"))
416                    .collect::<Vec<_>>()
417                    .join(", "),
418                format_available_workspaces(workspaces),
419            ),
420            2,
421        )
422        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
423        .with_context("analysis.workspace"));
424    }
425
426    Ok(matched)
427}
428
429fn find_workspace_matches(
430    pattern: &str,
431    workspaces: &[WorkspaceInfo],
432    rel_paths: &[String],
433) -> ProgrammaticResult<Vec<usize>> {
434    if let Some(index) = workspaces
435        .iter()
436        .position(|workspace| workspace.name == pattern)
437    {
438        return Ok(vec![index]);
439    }
440    if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
441        return Ok(vec![index]);
442    }
443
444    let glob = Glob::new(pattern).map_err(|err| {
445        ProgrammaticError::new(format!("invalid `workspace` pattern '{pattern}': {err}"), 2)
446            .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
447            .with_context("analysis.workspace")
448    })?;
449    let matcher = glob.compile_matcher();
450    let hits = workspaces
451        .iter()
452        .enumerate()
453        .filter_map(|(index, workspace)| {
454            (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
455                .then_some(index)
456        })
457        .collect();
458    Ok(hits)
459}
460
461fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
462    let mut positive = Vec::new();
463    let mut negative = Vec::new();
464    for pattern in patterns {
465        let trimmed = pattern.trim();
466        if trimmed.is_empty() {
467            continue;
468        }
469        if let Some(negative_pattern) = trimmed.strip_prefix('!') {
470            let negative_pattern = negative_pattern.trim();
471            if !negative_pattern.is_empty() {
472                negative.push(negative_pattern);
473            }
474        } else {
475            positive.push(trimmed);
476        }
477    }
478    (positive, negative)
479}
480
481fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
482    const MAX_SHOWN: usize = 10;
483    let total = workspaces.len();
484    if total <= MAX_SHOWN {
485        return workspaces
486            .iter()
487            .map(|workspace| workspace.name.as_str())
488            .collect::<Vec<_>>()
489            .join(", ");
490    }
491    let shown = workspaces
492        .iter()
493        .take(MAX_SHOWN)
494        .map(|workspace| workspace.name.as_str())
495        .collect::<Vec<_>>()
496        .join(", ");
497    format!(
498        "{shown}, ... and {} more ({total} total)",
499        total - MAX_SHOWN
500    )
501}
502
503fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
504    workspace_root
505        .strip_prefix(root)
506        .unwrap_or(workspace_root)
507        .to_string_lossy()
508        .replace('\\', "/")
509}