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