Skip to main content

weavatrix_rust/analyzer/
mod.rs

1//! Repository scan, extraction, resolution, and snapshot orchestration.
2
3mod imports;
4mod mounts;
5mod pipeline;
6mod references;
7mod state;
8mod support;
9
10use crate::language::LanguageRegistry;
11use crate::model::{Result, Snapshot};
12use pipeline::parse_parallel;
13use state::{AnalysisState, parse_source};
14use std::path::Path;
15use std::sync::Arc;
16use support::{canonical_repository, capabilities};
17
18#[derive(Debug, Clone)]
19pub struct AnalyzerConfig {
20    pub max_file_bytes: u64,
21    /// n8n JSON and Dify YAML exports can exceed the general file cap; oversized non-export files
22    /// still stops at `max_file_bytes`.
23    pub n8n_max_file_bytes: u64,
24    /// Foundry/solc build-info can exceed the n8n/Dify cap; other JSON still stops earlier.
25    pub web3_max_file_bytes: u64,
26}
27
28impl Default for AnalyzerConfig {
29    fn default() -> Self {
30        Self {
31            max_file_bytes: crate::language::N8N_DEFAULT_FILE_BYTES,
32            n8n_max_file_bytes: 16 * 1024 * 1024,
33            web3_max_file_bytes: crate::language::WEB3_MAX_BUILD_INFO_BYTES,
34        }
35    }
36}
37
38pub struct Analyzer {
39    config: AnalyzerConfig,
40    languages: Arc<LanguageRegistry>,
41}
42
43#[derive(Debug, Clone)]
44pub struct SourceInput {
45    pub path: String,
46    pub bytes: Vec<u8>,
47    pub content_hash: Option<String>,
48}
49
50impl Default for Analyzer {
51    fn default() -> Self {
52        Self::new(AnalyzerConfig::default())
53    }
54}
55
56impl Analyzer {
57    #[must_use]
58    pub fn new(config: AnalyzerConfig) -> Self {
59        Self {
60            config,
61            languages: Arc::new(LanguageRegistry::default()),
62        }
63    }
64
65    #[must_use]
66    pub fn supports_path(&self, path: &str) -> bool {
67        Path::new(path)
68            .extension()
69            .and_then(|value| value.to_str())
70            .map(str::to_ascii_lowercase)
71            .is_some_and(|extension| self.languages.adapter_for_extension(&extension).is_some())
72    }
73
74    #[must_use]
75    pub const fn max_file_bytes(&self) -> u64 {
76        self.config.max_file_bytes
77    }
78
79    /// Analyzes a repository into a deterministic, evidence-carrying snapshot.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error when the repository cannot be read, an adapter cannot
84    /// initialize, or normalized facts violate graph integrity.
85    pub fn analyze(&self, repository: impl AsRef<Path>) -> Result<Snapshot> {
86        self.analyze_with_report(repository)
87            .map(|(snapshot, _)| snapshot)
88    }
89
90    /// Analyzes an immutable set of source blobs without materializing a tree.
91    ///
92    /// This is the bridge used by the Git module for revision-aware graph
93    /// comparisons. The repository path only supplies stable repository
94    /// identity; every analyzed byte comes from `sources`.
95    ///
96    /// # Errors
97    ///
98    /// Returns parser or graph validation failures.
99    pub fn analyze_sources(
100        &self,
101        repository: impl AsRef<Path>,
102        revision: impl Into<String>,
103        sources: impl IntoIterator<Item = SourceInput>,
104    ) -> Result<Snapshot> {
105        let repository = canonical_repository(repository.as_ref())?;
106        let sources = sources
107            .into_iter()
108            .filter(|source| admits_source(&source.path, &source.bytes, &self.config))
109            .collect::<Vec<_>>();
110        let mut parsed = parse_parallel(sources.len(), |index| {
111            let source = &sources[index];
112            parse_source(
113                &source.path,
114                &source.bytes,
115                source.content_hash.as_deref(),
116                self.languages.as_ref(),
117            )
118        })?;
119        mounts::apply(&mut parsed);
120        let (node_hint, edge_hint) = AnalysisState::expected(&parsed);
121        let mut state = AnalysisState::with_capacity(&repository, node_hint, edge_hint)?;
122        for item in parsed {
123            state.integrate(item)?;
124        }
125        state.resolve_references()?;
126        state.into_snapshot(
127            &repository,
128            revision.into(),
129            capabilities(self.languages.as_ref()),
130        )
131    }
132
133    /// Analyzes a repository and serializes the snapshot as JSON.
134    ///
135    /// # Errors
136    ///
137    /// Returns any analysis error or a JSON serialization error.
138    pub fn analyze_json(&self, repository: impl AsRef<Path>, pretty: bool) -> Result<String> {
139        let snapshot = self.analyze(repository)?;
140        if pretty {
141            Ok(blazingly_json::to_string_pretty(&snapshot)?)
142        } else {
143            Ok(blazingly_json::to_string(&snapshot)?)
144        }
145    }
146
147    /// Analyzes a repository and serializes a JS Weavatrix-compatible graph.
148    ///
149    /// # Errors
150    ///
151    /// Returns any analysis error or a JSON serialization error.
152    pub fn analyze_legacy_json(
153        &self,
154        repository: impl AsRef<Path>,
155        pretty: bool,
156    ) -> Result<String> {
157        Ok(self.analyze(repository)?.legacy_json(pretty)?)
158    }
159}
160
161fn admits_source(path: &str, bytes: &[u8], config: &AnalyzerConfig) -> bool {
162    let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
163    if size <= config.max_file_bytes {
164        return true;
165    }
166    if size > config.n8n_max_file_bytes.max(config.web3_max_file_bytes) {
167        return false;
168    }
169    let Ok(text) = std::str::from_utf8(bytes) else {
170        return false;
171    };
172    let extension = Path::new(path)
173        .extension()
174        .and_then(|value| value.to_str())
175        .unwrap_or("");
176    if extension.eq_ignore_ascii_case("json") {
177        crate::language::n8n_looks_promising(text)
178            || crate::language::agent_looks_promising(path, text)
179            || (crate::language::web3_looks_promising(text)
180                && crate::language::web3_admitted_size(text, size)
181                && size <= config.web3_max_file_bytes)
182    } else if extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") {
183        crate::language::dify_looks_promising(text)
184    } else if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("mdx") {
185        crate::language::agent_looks_promising(path, text)
186            || crate::language::mermaid_looks_promising(path, text)
187    } else if extension.eq_ignore_ascii_case("mmd") || extension.eq_ignore_ascii_case("mermaid") {
188        crate::language::mermaid_looks_promising(path, text)
189    } else {
190        false
191    }
192}