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    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    match (workspace, changed_workspaces) {
319        (Some(patterns), None) => resolve_workspace_filters(root, patterns).map(Some),
320        (None, Some(git_ref)) => resolve_changed_workspaces(root, git_ref).map(Some),
321        (None, None) => Ok(None),
322        (Some(_), Some(_)) => Err(ProgrammaticError::new(
323            "`workspace` and `changed_workspaces` are mutually exclusive",
324            2,
325        )
326        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
327        .with_context("analysis.workspace")),
328    }
329}
330
331fn resolve_workspace_scope_from_workspaces(
332    root: &Path,
333    workspace: Option<&[String]>,
334    changed_workspaces: Option<&str>,
335    workspaces: &[WorkspaceInfo],
336) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
337    match (workspace, changed_workspaces) {
338        (Some(patterns), None) => {
339            resolve_workspace_filters_from_workspaces(root, patterns, workspaces).map(Some)
340        }
341        (None, Some(git_ref)) => {
342            resolve_changed_workspaces_from_workspaces(root, git_ref, workspaces).map(Some)
343        }
344        (None, None) => Ok(None),
345        (Some(_), Some(_)) => Err(ProgrammaticError::new(
346            "`workspace` and `changed_workspaces` are mutually exclusive",
347            2,
348        )
349        .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
350        .with_context("analysis.workspace")),
351    }
352}
353
354pub fn resolve_workspace_filters(
355    root: &Path,
356    patterns: &[String],
357) -> ProgrammaticResult<Vec<PathBuf>> {
358    let workspaces = fallow_config::discover_workspaces(root);
359    resolve_workspace_filters_from_workspaces(root, patterns, &workspaces)
360}
361
362fn resolve_workspace_filters_from_workspaces(
363    root: &Path,
364    patterns: &[String],
365    workspaces: &[WorkspaceInfo],
366) -> ProgrammaticResult<Vec<PathBuf>> {
367    if workspaces.is_empty() {
368        let joined = patterns
369            .iter()
370            .map(|pattern| format!("'{pattern}'"))
371            .collect::<Vec<_>>()
372            .join(", ");
373        return Err(ProgrammaticError::new(
374            format!(
375                "`workspace` {joined} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
376            ),
377            2,
378        )
379        .with_code("FALLOW_WORKSPACES_NOT_FOUND")
380        .with_context("analysis.workspace"));
381    }
382
383    let rel_paths = workspaces
384        .iter()
385        .map(|workspace| relative_workspace_path(&workspace.root, root))
386        .collect::<Vec<_>>();
387    let (positive, negative) = split_workspace_patterns(patterns);
388    let mut matched = match_positive_workspace_patterns(&positive, workspaces, &rel_paths)?;
389
390    for pattern in &negative {
391        for index in find_workspace_matches(pattern, workspaces, &rel_paths)? {
392            matched.remove(&index);
393        }
394    }
395
396    if matched.is_empty() {
397        return Err(
398            ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
399                .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
400                .with_context("analysis.workspace"),
401        );
402    }
403
404    let mut roots = matched
405        .into_iter()
406        .map(|index| workspaces[index].root.clone())
407        .collect::<Vec<_>>();
408    roots.sort();
409    Ok(roots)
410}
411
412fn resolve_changed_workspaces(root: &Path, git_ref: &str) -> ProgrammaticResult<Vec<PathBuf>> {
413    let workspaces = fallow_config::discover_workspaces(root);
414    resolve_changed_workspaces_from_workspaces(root, git_ref, &workspaces)
415}
416
417fn resolve_changed_workspaces_from_workspaces(
418    root: &Path,
419    git_ref: &str,
420    workspaces: &[WorkspaceInfo],
421) -> ProgrammaticResult<Vec<PathBuf>> {
422    if workspaces.is_empty() {
423        return Err(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    let changed_files =
433        fallow_engine::changed_files::changed_files(root, git_ref).map_err(|err| {
434            ProgrammaticError::new(
435                format!(
436                    "failed to resolve changed workspaces for ref `{git_ref}`: {}",
437                    err.describe()
438                ),
439                2,
440            )
441            .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
442            .with_context("analysis.changedWorkspaces")
443        })?;
444    let mut roots = workspaces
445        .iter()
446        .filter(|workspace| {
447            changed_files
448                .iter()
449                .any(|file| file.starts_with(&workspace.root))
450        })
451        .map(|workspace| workspace.root.clone())
452        .collect::<Vec<_>>();
453    roots.sort();
454    Ok(roots)
455}
456
457fn match_positive_workspace_patterns(
458    positive: &[&str],
459    workspaces: &[WorkspaceInfo],
460    rel_paths: &[String],
461) -> ProgrammaticResult<FxHashSet<usize>> {
462    let mut matched = FxHashSet::default();
463    let mut unmatched = Vec::new();
464
465    if positive.is_empty() {
466        matched.extend(0..workspaces.len());
467    } else {
468        for pattern in positive {
469            let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
470            if hits.is_empty() {
471                unmatched.push((*pattern).to_string());
472            }
473            matched.extend(hits);
474        }
475    }
476
477    if !unmatched.is_empty() {
478        return Err(ProgrammaticError::new(
479            format!(
480                "`workspace` matched no workspace for pattern{}: {}. Available: {}",
481                if unmatched.len() == 1 { "" } else { "s" },
482                unmatched
483                    .iter()
484                    .map(|pattern| format!("'{pattern}'"))
485                    .collect::<Vec<_>>()
486                    .join(", "),
487                format_available_workspaces(workspaces),
488            ),
489            2,
490        )
491        .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
492        .with_context("analysis.workspace"));
493    }
494
495    Ok(matched)
496}
497
498fn find_workspace_matches(
499    pattern: &str,
500    workspaces: &[WorkspaceInfo],
501    rel_paths: &[String],
502) -> ProgrammaticResult<Vec<usize>> {
503    if let Some(index) = workspaces
504        .iter()
505        .position(|workspace| workspace.name == pattern)
506    {
507        return Ok(vec![index]);
508    }
509    if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
510        return Ok(vec![index]);
511    }
512
513    let glob = Glob::new(pattern).map_err(|err| {
514        ProgrammaticError::new(format!("invalid `workspace` pattern '{pattern}': {err}"), 2)
515            .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
516            .with_context("analysis.workspace")
517    })?;
518    let matcher = glob.compile_matcher();
519    let hits = workspaces
520        .iter()
521        .enumerate()
522        .filter_map(|(index, workspace)| {
523            (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
524                .then_some(index)
525        })
526        .collect();
527    Ok(hits)
528}
529
530fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
531    let mut positive = Vec::new();
532    let mut negative = Vec::new();
533    for pattern in patterns {
534        let trimmed = pattern.trim();
535        if trimmed.is_empty() {
536            continue;
537        }
538        if let Some(negative_pattern) = trimmed.strip_prefix('!') {
539            let negative_pattern = negative_pattern.trim();
540            if !negative_pattern.is_empty() {
541                negative.push(negative_pattern);
542            }
543        } else {
544            positive.push(trimmed);
545        }
546    }
547    (positive, negative)
548}
549
550fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
551    const MAX_SHOWN: usize = 10;
552    let total = workspaces.len();
553    if total <= MAX_SHOWN {
554        return workspaces
555            .iter()
556            .map(|workspace| workspace.name.as_str())
557            .collect::<Vec<_>>()
558            .join(", ");
559    }
560    let shown = workspaces
561        .iter()
562        .take(MAX_SHOWN)
563        .map(|workspace| workspace.name.as_str())
564        .collect::<Vec<_>>()
565        .join(", ");
566    format!(
567        "{shown}, ... and {} more ({total} total)",
568        total - MAX_SHOWN
569    )
570}
571
572fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
573    workspace_root
574        .strip_prefix(root)
575        .unwrap_or(workspace_root)
576        .to_string_lossy()
577        .replace('\\', "/")
578}