Skip to main content

hearth_graph/
analyze.rs

1use std::path::Path;
2
3use compact_str::CompactString;
4use tree_sitter::Tree;
5
6use crate::imports::js;
7use crate::symbols::{MAX_SYMBOLS_PER_FILE, extract_symbols_from_tree};
8use crate::{ImportKind, ImportSpec, ParserPool, RawImport, Symbol};
9
10/// Symbols and imports extracted from one source file.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FileAnalysis {
13    /// Repository-relative source path.
14    pub path: CompactString,
15    /// Content hash supplied by the caller.
16    pub content_hash: u64,
17    /// Resolved language name, or `None` when the path is unsupported.
18    pub language: Option<CompactString>,
19    /// Symbols in source order.
20    pub symbols: Vec<Symbol>,
21    /// Literal imports in source order.
22    pub imports: Vec<RawImport>,
23    /// Whether at least one dynamic or CommonJS import had a non-literal argument.
24    pub has_opaque_imports: bool,
25}
26
27#[derive(Clone, Copy)]
28enum ImportExtractor {
29    Query(fn(&str) -> ImportKind),
30    Custom(fn(&str, &Tree) -> Vec<RawImport>),
31}
32
33/// Parses and analyzes one source file.
34///
35/// Symbol and import extraction share syntax trees. Registered injection
36/// queries parse embedded source with another language from the same registry
37/// while preserving locations in the containing file. Unsupported paths,
38/// parser failures, and sources whose byte offsets cannot fit in `u32` produce
39/// empty results.
40#[must_use]
41pub fn analyze_source(
42    source: &str,
43    path: &str,
44    content_hash: u64,
45    pool: &mut ParserPool<'_>,
46) -> FileAnalysis {
47    analyze(source, path, content_hash, pool, true)
48}
49
50pub(crate) fn extract_symbols_only(
51    source: &str,
52    path: &str,
53    pool: &mut ParserPool<'_>,
54) -> Vec<Symbol> {
55    analyze(source, path, 0, pool, false).symbols
56}
57
58fn analyze(
59    source: &str,
60    path: &str,
61    content_hash: u64,
62    pool: &mut ParserPool<'_>,
63    include_imports: bool,
64) -> FileAnalysis {
65    let mut analysis = FileAnalysis {
66        path: CompactString::from(path),
67        content_hash,
68        language: None,
69        symbols: Vec::new(),
70        imports: Vec::new(),
71        has_opaque_imports: false,
72    };
73
74    let Some(id) = pool.registry().for_path(Path::new(path)) else {
75        return analysis;
76    };
77    let Some(spec) = pool.registry().get(id) else {
78        return analysis;
79    };
80    analysis.language = Some(spec.name.clone());
81
82    if u32::try_from(source.len()).is_err() {
83        return analysis;
84    }
85
86    let has_direct_symbols = spec.tags_query.is_some();
87    let has_injections = spec.injections_query.is_some();
88    let import_extractor = include_imports
89        .then(|| spec.imports.as_ref().map(import_extractor))
90        .flatten();
91
92    let tree = {
93        let Some(parser) = pool.parser(id) else {
94            return analysis;
95        };
96        let Some(tree) = parser.parse(source, None) else {
97            return analysis;
98        };
99        tree
100    };
101
102    if has_direct_symbols {
103        analysis.symbols = extract_symbols_from_tree(source, &tree, id, pool);
104    }
105    if let Some(extractor) = import_extractor {
106        extract_imports(source, &tree, id, extractor, pool, &mut analysis);
107    }
108
109    if has_injections {
110        analyze_injections(source, &tree, id, include_imports, pool, &mut analysis);
111    }
112
113    analysis.symbols.sort_unstable_by(|a, b| {
114        a.def_start
115            .cmp(&b.def_start)
116            .then(b.def_end.cmp(&a.def_end))
117            .then(a.line.cmp(&b.line))
118            .then(a.name_start.cmp(&b.name_start))
119    });
120    analysis.symbols.truncate(MAX_SYMBOLS_PER_FILE);
121    analysis.imports.sort_by_key(|import| import.span.0);
122    analysis
123}
124
125fn import_extractor(imports: &ImportSpec) -> ImportExtractor {
126    match imports {
127        ImportSpec::Query { kind_map, .. } => ImportExtractor::Query(*kind_map),
128        ImportSpec::Custom(extract) => ImportExtractor::Custom(*extract),
129    }
130}
131
132fn extract_imports(
133    source: &str,
134    tree: &Tree,
135    id: crate::LanguageId,
136    extractor: ImportExtractor,
137    pool: &mut ParserPool<'_>,
138    analysis: &mut FileAnalysis,
139) {
140    match extractor {
141        ImportExtractor::Query(kind_map) => {
142            if let Some(query) = pool.imports_query(id) {
143                let extracted = js::extract(source, tree, query, kind_map);
144                analysis.imports.extend(extracted.imports);
145                analysis.has_opaque_imports |= extracted.opaque_count != 0;
146            }
147        }
148        ImportExtractor::Custom(extract) => {
149            analysis.imports.extend(extract(source, tree));
150        }
151    }
152}
153
154fn analyze_injections(
155    source: &str,
156    tree: &Tree,
157    parent_id: crate::LanguageId,
158    include_imports: bool,
159    pool: &mut ParserPool<'_>,
160    analysis: &mut FileAnalysis,
161) {
162    let injections = match pool.injections_query(parent_id) {
163        Some(query) => crate::injections::extract(tree, source, query),
164        None => return,
165    };
166    for injection in injections {
167        let Some(id) = pool.registry().for_name(&injection.language) else {
168            continue;
169        };
170        let Some(spec) = pool.registry().get(id) else {
171            continue;
172        };
173        let has_symbols = spec.tags_query.is_some();
174        let import_extractor = include_imports
175            .then(|| spec.imports.as_ref().map(import_extractor))
176            .flatten();
177        if !has_symbols && import_extractor.is_none() {
178            continue;
179        }
180
181        // Included ranges preserve the containing file's byte and point
182        // coordinates, so symbol and import extractors need no manual rebasing.
183        let injected_tree = {
184            let Some(parser) = pool.parser(id) else {
185                continue;
186            };
187            if parser
188                .set_included_ranges(std::slice::from_ref(&injection.range))
189                .is_err()
190            {
191                continue;
192            }
193            let tree = parser.parse(source, None);
194            if parser.set_included_ranges(&[]).is_err() {
195                continue;
196            }
197            let Some(tree) = tree else {
198                continue;
199            };
200            tree
201        };
202
203        if has_symbols {
204            analysis
205                .symbols
206                .extend(extract_symbols_from_tree(source, &injected_tree, id, pool));
207        }
208
209        if let Some(extractor) = import_extractor {
210            extract_imports(source, &injected_tree, id, extractor, pool, analysis);
211        }
212    }
213}