Skip to main content

pelicode_core/
extract.rs

1//! Function extraction and feature computation for a single source file.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7use sha2::{Digest, Sha256};
8use tree_sitter::{Node, Parser};
9
10use crate::Error;
11use crate::config::{Config, Level};
12use crate::hash::{self, Fingerprint, Hash128};
13use crate::language::{Language, LanguageSpec};
14use crate::normalize::normalize;
15
16/// Features of one source file.
17#[derive(Debug, Clone, Serialize)]
18pub struct FileFeatures {
19    /// Path of the file as recorded, with `/` as the separator.
20    pub path: PathBuf,
21    /// Language of the file.
22    pub language: Language,
23    /// SHA-256 of the file content (hex).
24    pub content_sha256: String,
25    /// Functions of the file, in the order they start; a function precedes the functions nested in it.
26    pub functions: Vec<FunctionFeatures>,
27}
28
29/// Features of one function unit.
30#[derive(Debug, Clone, Serialize)]
31pub struct FunctionFeatures {
32    /// Function name for display; `<anonymous>` for lambdas and blocks. Never used for matching.
33    pub name: String,
34    /// Tree-sitter node kind of the function.
35    pub kind: String,
36    /// 1-based, inclusive.
37    pub start_line: usize,
38    /// Last line of the function (1-based, inclusive).
39    pub end_line: usize,
40    /// 1-based byte columns of the first and last character.
41    pub start_column: usize,
42    /// 1-based byte column of the last character.
43    pub end_column: usize,
44    /// Byte range in the file (0-based, end exclusive).
45    pub start_byte: usize,
46    /// End of the byte range in the file (exclusive).
47    pub end_byte: usize,
48    /// Index of the enclosing function in the same file.
49    pub parent: Option<usize>,
50    /// Number of N0 tokens.
51    pub token_count: usize,
52    /// Number of named AST nodes (comments excluded).
53    pub node_count: usize,
54    /// Depth of the syntax tree of the function.
55    pub depth: usize,
56    /// Hash of the whole function at every normalization level.
57    pub exact_hashes: BTreeMap<Level, Hash128>,
58    /// Present only for functions with at least `min_tokens` tokens.
59    pub features: BTreeMap<Level, LevelFeatures>,
60    /// Normalized tokens at every level, kept only with [`Config::keep_tokens`].
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub tokens: Option<BTreeMap<Level, Vec<String>>>,
63}
64
65impl FunctionFeatures {
66    /// Whether this function is large enough to carry fingerprints.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use pelicode_core::{Config, Language, extract_source};
72    ///
73    /// let source = b"def area(w, h):\n    return w * h\n";
74    /// let small = Config { min_tokens: 100, ..Config::default() };
75    /// assert!(!extract_source(Language::Python, source, &small).unwrap()[0].is_indexed());
76    /// let all = Config { min_tokens: 0, ..Config::default() };
77    /// assert!(extract_source(Language::Python, source, &all).unwrap()[0].is_indexed());
78    /// ```
79    pub fn is_indexed(&self) -> bool {
80        !self.features.is_empty()
81    }
82}
83
84/// Partial-match features of one normalization level.
85#[derive(Debug, Clone, Serialize)]
86pub struct LevelFeatures {
87    /// Fingerprints selected by winnowing, in token order.
88    pub fingerprints: Vec<Fingerprint>,
89    /// MinHash signature of the fingerprint set.
90    #[serde(serialize_with = "hash::serialize_u64_hex_vec")]
91    pub minhash: Vec<u64>,
92    /// LSH bucket of each band of the MinHash signature.
93    #[serde(serialize_with = "hash::serialize_u64_hex_vec")]
94    pub lsh_buckets: Vec<u64>,
95}
96
97/// SHA-256 of `content` as 64 lower-case hexadecimal digits, the identity of a file.
98///
99/// # Examples
100///
101/// ```
102/// use pelicode_core::extract::sha256_hex;
103///
104/// assert_eq!(
105///     sha256_hex(b"abc"),
106///     "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
107/// );
108/// ```
109pub fn sha256_hex(content: &[u8]) -> String {
110    hash::to_hex(&Sha256::digest(content))
111}
112
113/// Reads and extracts a file; `display_path` is the path recorded in the result.
114///
115/// # Errors
116///
117/// [`Error::Io`] when the file cannot be read, and the errors of [`extract_source`].
118///
119/// # Examples
120///
121/// ```
122/// use std::path::Path;
123/// use pelicode_core::{Config, Language, extract_file};
124///
125/// let dir = tempfile::tempdir().unwrap();
126/// let path = dir.path().join("greet.rb");
127/// std::fs::write(&path, "def greet(name)\n  \"Hello, #{name}\"\nend\n").unwrap();
128///
129/// let file = extract_file(&path, Path::new("lib/greet.rb"), Language::Ruby, &Config::default()).unwrap();
130/// assert_eq!(file.path, Path::new("lib/greet.rb"));
131/// assert_eq!(file.functions[0].name, "greet");
132/// ```
133pub fn extract_file(
134    path: &Path,
135    display_path: &Path,
136    language: Language,
137    config: &Config,
138) -> Result<FileFeatures, Error> {
139    let content = std::fs::read(path).map_err(|e| Error::Io(path.to_path_buf(), e))?;
140    extract_bytes(display_path, language, &content, config)
141}
142
143/// Extracts a file whose content is already in memory (e.g. read from a git revision).
144///
145/// # Errors
146///
147/// The errors of [`extract_source`].
148///
149/// # Examples
150///
151/// ```
152/// use std::path::Path;
153/// use pelicode_core::{Config, Language, extract_bytes};
154///
155/// let source = b"package main\nfunc Add(a, b int) int { return a + b }\n";
156/// let file = extract_bytes(Path::new("add.go"), Language::Go, source, &Config::default()).unwrap();
157/// assert_eq!(file.functions[0].name, "Add");
158/// assert_eq!(file.content_sha256.len(), 64);
159/// ```
160pub fn extract_bytes(
161    path: &Path,
162    language: Language,
163    content: &[u8],
164    config: &Config,
165) -> Result<FileFeatures, Error> {
166    Ok(FileFeatures {
167        path: path.to_path_buf(),
168        language,
169        content_sha256: sha256_hex(content),
170        functions: extract_source(language, content, config)?,
171    })
172}
173
174/// Extracts the features of every function in `source`.
175///
176/// Every function, method, constructor, lambda or block of the language is a
177/// function unit, nested ones included. Functions with syntax errors are skipped.
178/// Fingerprints, MinHash signatures and LSH buckets are computed only for
179/// functions with at least [`Config::min_tokens`] tokens.
180///
181/// # Errors
182///
183/// [`Error::InvalidConfig`] when `config` is invalid, and [`Error::Parse`] when
184/// the source cannot be parsed at all.
185///
186/// # Examples
187///
188/// Renaming identifiers changes the N0 hash but not the N1 hash:
189///
190/// ```
191/// use pelicode_core::{Config, Language, Level, extract_source};
192///
193/// let a = extract_source(Language::Java, b"class A { int twice(int x) { return x * 2; } }", &Config::default()).unwrap();
194/// let b = extract_source(Language::Java, b"class B { int doubled(int v) { return v * 2; } }", &Config::default()).unwrap();
195/// assert_eq!(a[0].name, "twice");
196/// assert_ne!(a[0].exact_hashes[&Level::N0], b[0].exact_hashes[&Level::N0]);
197/// assert_eq!(a[0].exact_hashes[&Level::N1], b[0].exact_hashes[&Level::N1]);
198/// ```
199pub fn extract_source(
200    language: Language,
201    source: &[u8],
202    config: &Config,
203) -> Result<Vec<FunctionFeatures>, Error> {
204    config.validate()?;
205    let mut parser = Parser::new();
206    parser
207        .set_language(&language.ts_language())
208        .map_err(|e| Error::Parse(e.to_string()))?;
209    let tree = parser
210        .parse(source, None)
211        .ok_or_else(|| Error::Parse("parser returned no tree".into()))?;
212
213    let spec = language.spec();
214    let mut functions = Vec::new();
215    collect(
216        tree.root_node(),
217        None,
218        None,
219        source,
220        spec,
221        config,
222        &mut functions,
223    );
224    Ok(functions)
225}
226
227fn collect(
228    node: Node<'_>,
229    parent_kind: Option<&str>,
230    parent_index: Option<usize>,
231    source: &[u8],
232    spec: &'static LanguageSpec,
233    config: &Config,
234    out: &mut Vec<FunctionFeatures>,
235) {
236    let kind = node.kind();
237    let mut current = parent_index;
238    // A function directly wrapping another (e.g. Ruby `-> { }` with its block) is one unit.
239    let wrapped = parent_kind.is_some_and(|p| spec.functions.contains(&p));
240    if spec.functions.contains(&kind) && !wrapped && !node.has_error() {
241        out.push(features(node, parent_index, source, spec, config));
242        current = Some(out.len() - 1);
243    }
244    let mut cursor = node.walk();
245    for child in node.children(&mut cursor) {
246        collect(child, Some(kind), current, source, spec, config, out);
247    }
248}
249
250fn features(
251    node: Node<'_>,
252    parent: Option<usize>,
253    source: &[u8],
254    spec: &'static LanguageSpec,
255    config: &Config,
256) -> FunctionFeatures {
257    let normalized = normalize(node, source, spec, config.ident_mode);
258    let token_count = normalized.tokens[&Level::N0].len();
259    let exact_hashes = normalized
260        .tokens
261        .iter()
262        .map(|(level, tokens)| (*level, Hash128::of_tokens(tokens)))
263        .collect();
264
265    let mut features = BTreeMap::new();
266    if token_count >= config.min_tokens {
267        for level in &config.fingerprint_levels {
268            let tokens = &normalized.tokens[level];
269            let rows = &normalized.rows[level];
270            let k = config.kgram.min(tokens.len());
271            let fingerprints: Vec<Fingerprint> =
272                hash::winnow(&hash::kgram_hashes(tokens, config.kgram), config.window)
273                    .into_iter()
274                    .map(|(position, hash)| Fingerprint {
275                        hash,
276                        position: position as u32,
277                        start_line: rows[position].0 as u32 + 1,
278                        end_line: rows[position + k - 1].1 as u32 + 1,
279                    })
280                    .collect();
281            let Some(minhash) =
282                hash::minhash(fingerprints.iter().map(|f| f.hash), config.minhash_size)
283            else {
284                continue;
285            };
286            let lsh_buckets = hash::lsh_buckets(&minhash, config.bands);
287            features.insert(
288                *level,
289                LevelFeatures {
290                    fingerprints,
291                    minhash,
292                    lsh_buckets,
293                },
294            );
295        }
296    }
297
298    FunctionFeatures {
299        name: function_name(node, source),
300        kind: node.kind().to_string(),
301        start_line: node.start_position().row + 1,
302        end_line: node.end_position().row + 1,
303        start_column: node.start_position().column + 1,
304        // The end position points just past the last character.
305        end_column: node.end_position().column.max(1),
306        start_byte: node.start_byte(),
307        end_byte: node.end_byte(),
308        parent,
309        token_count,
310        node_count: normalized.node_count,
311        depth: normalized.depth,
312        exact_hashes,
313        features,
314        tokens: config.keep_tokens.then_some(normalized.tokens),
315    }
316}
317
318fn function_name(node: Node<'_>, source: &[u8]) -> String {
319    if let Some(name) = node.child_by_field_name("name")
320        && let Ok(text) = name.utf8_text(source)
321    {
322        return text.to_string();
323    }
324    match node.kind() {
325        "getter" => "get".into(),
326        "setter" => "set".into(),
327        "secondary_constructor" => "constructor".into(),
328        _ => "<anonymous>".into(),
329    }
330}