pi-lens 3.6.6 → 3.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +39 -2
- package/clients/dispatch/runners/similarity.ts +100 -1
- package/clients/installer/index.ts +26 -20
- package/clients/lsp/client.ts +249 -110
- package/clients/native-rust-client.ts +531 -0
- package/commands/booboo.ts +2 -2
- package/package.json +14 -2
- package/rust/Cargo.toml +34 -0
- package/rust/src/cache.rs +127 -0
- package/rust/src/index.rs +407 -0
- package/rust/src/lib.rs +209 -0
- package/rust/src/main.rs +24 -0
- package/rust/src/scan.rs +116 -0
- package/rust/src/similarity.rs +387 -0
- package/skills/ast-grep/SKILL.md +16 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
//! Cache utilities: xxHash fingerprints + disk index persistence
|
|
2
|
+
|
|
3
|
+
use std::io::{BufReader, BufWriter};
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
|
|
6
|
+
use xxhash_rust::xxh3::xxh3_64;
|
|
7
|
+
|
|
8
|
+
use crate::index::CachedIndex;
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Hashing
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/// Compute a fast xxHash-based cache key from file path + mtime + size.
|
|
15
|
+
/// Used to detect stale cache entries without re-reading file contents.
|
|
16
|
+
pub fn compute_file_hash(path: &Path, mtime: u64, size: u64) -> String {
|
|
17
|
+
let input = format!("{}:{}:{}", path.display(), mtime, size);
|
|
18
|
+
format!("{:016x}", xxh3_64(input.as_bytes()))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Compute a cache key for a list of file paths (e.g. for rules sets).
|
|
22
|
+
pub fn compute_rules_hash(files: &[String]) -> String {
|
|
23
|
+
let input = files.join("|");
|
|
24
|
+
format!("{:016x}", xxh3_64(input.as_bytes()))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Index persistence
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/// Path to the Rust similarity index for a project.
|
|
32
|
+
/// Stored alongside the TypeScript index in `.pi-lens/`.
|
|
33
|
+
pub fn get_index_cache_path(project_root: &str) -> PathBuf {
|
|
34
|
+
Path::new(project_root)
|
|
35
|
+
.join(".pi-lens")
|
|
36
|
+
.join("rust-index.json")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Persist a `CachedIndex` to `{project_root}/.pi-lens/rust-index.json`.
|
|
40
|
+
/// Creates the `.pi-lens/` directory if it doesn't exist.
|
|
41
|
+
pub fn save_index(project_root: &str, index: &CachedIndex) -> anyhow::Result<()> {
|
|
42
|
+
let cache_path = get_index_cache_path(project_root);
|
|
43
|
+
|
|
44
|
+
if let Some(parent) = cache_path.parent() {
|
|
45
|
+
std::fs::create_dir_all(parent)?;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let file = std::fs::File::create(&cache_path)?;
|
|
49
|
+
let writer = BufWriter::new(file);
|
|
50
|
+
serde_json::to_writer(writer, index)?;
|
|
51
|
+
|
|
52
|
+
Ok(())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Load a `CachedIndex` from disk, returning `None` if the file doesn't
|
|
56
|
+
/// exist, can't be read, or has an incompatible version.
|
|
57
|
+
pub fn load_index(project_root: &str) -> Option<CachedIndex> {
|
|
58
|
+
let cache_path = get_index_cache_path(project_root);
|
|
59
|
+
let file = std::fs::File::open(&cache_path).ok()?;
|
|
60
|
+
let reader = BufReader::new(file);
|
|
61
|
+
let index: CachedIndex = serde_json::from_reader(reader).ok()?;
|
|
62
|
+
|
|
63
|
+
// Reject stale cache versions
|
|
64
|
+
if index.version != CachedIndex::CURRENT_VERSION {
|
|
65
|
+
return None;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Some(index)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Tests
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
#[cfg(test)]
|
|
76
|
+
mod tests {
|
|
77
|
+
use super::*;
|
|
78
|
+
use tempfile::TempDir;
|
|
79
|
+
|
|
80
|
+
#[test]
|
|
81
|
+
fn test_compute_file_hash_is_deterministic() {
|
|
82
|
+
let path = Path::new("/some/file.ts");
|
|
83
|
+
let h1 = compute_file_hash(path, 1234567890, 4096);
|
|
84
|
+
let h2 = compute_file_hash(path, 1234567890, 4096);
|
|
85
|
+
assert_eq!(h1, h2);
|
|
86
|
+
assert_eq!(h1.len(), 16); // 64-bit hex
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
fn test_compute_file_hash_changes_with_mtime() {
|
|
91
|
+
let path = Path::new("/some/file.ts");
|
|
92
|
+
let h1 = compute_file_hash(path, 100, 512);
|
|
93
|
+
let h2 = compute_file_hash(path, 200, 512);
|
|
94
|
+
assert_ne!(h1, h2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn test_save_and_load_index_roundtrip() {
|
|
99
|
+
use crate::index::{CachedFunctionEntry, CachedIndex};
|
|
100
|
+
use crate::FunctionEntry;
|
|
101
|
+
|
|
102
|
+
let temp = TempDir::new().unwrap();
|
|
103
|
+
let root = temp.path().to_str().unwrap();
|
|
104
|
+
|
|
105
|
+
let index = CachedIndex {
|
|
106
|
+
version: CachedIndex::CURRENT_VERSION,
|
|
107
|
+
project_root: root.to_string(),
|
|
108
|
+
functions: vec![CachedFunctionEntry {
|
|
109
|
+
entry: FunctionEntry {
|
|
110
|
+
id: "src/lib.ts::foo@1".to_string(),
|
|
111
|
+
file_path: "src/lib.ts".to_string(),
|
|
112
|
+
name: "foo".to_string(),
|
|
113
|
+
line: 1,
|
|
114
|
+
signature: "()".to_string(),
|
|
115
|
+
matrix_hash: "deadbeef".to_string(),
|
|
116
|
+
},
|
|
117
|
+
matrix_rows: vec![vec![0u8; 72]; 57],
|
|
118
|
+
}],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
save_index(root, &index).unwrap();
|
|
122
|
+
|
|
123
|
+
let loaded = load_index(root).unwrap();
|
|
124
|
+
assert_eq!(loaded.functions.len(), 1);
|
|
125
|
+
assert_eq!(loaded.functions[0].entry.name, "foo");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
//! Project index: Parallel function indexing with state matrices
|
|
2
|
+
//!
|
|
3
|
+
//! The index is built once per project and persisted to
|
|
4
|
+
//! `{project_root}/.pi-lens/rust-index.json` so that subsequent
|
|
5
|
+
//! `Similarity` commands can load it without re-parsing all files.
|
|
6
|
+
|
|
7
|
+
use std::fs;
|
|
8
|
+
use std::path::Path;
|
|
9
|
+
|
|
10
|
+
use rayon::prelude::*;
|
|
11
|
+
use serde::{Deserialize, Serialize};
|
|
12
|
+
|
|
13
|
+
use crate::similarity::build_state_matrix;
|
|
14
|
+
use crate::{cache, FunctionEntry, IndexData, SimilarityMatch};
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// On-disk format
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/// Versioned index stored on disk.
|
|
21
|
+
#[derive(Serialize, Deserialize)]
|
|
22
|
+
pub struct CachedIndex {
|
|
23
|
+
pub version: u32,
|
|
24
|
+
pub project_root: String,
|
|
25
|
+
pub functions: Vec<CachedFunctionEntry>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl CachedIndex {
|
|
29
|
+
/// Bump this when the serialization format changes incompatibly.
|
|
30
|
+
pub const CURRENT_VERSION: u32 = 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// One function entry with its state matrix serialized as a 2-D Vec.
|
|
34
|
+
#[derive(Serialize, Deserialize)]
|
|
35
|
+
pub struct CachedFunctionEntry {
|
|
36
|
+
pub entry: FunctionEntry,
|
|
37
|
+
/// Row-major matrix: `matrix_rows[row][col]`; always NUM_SYNTAX × NUM_STATES.
|
|
38
|
+
pub matrix_rows: Vec<Vec<u8>>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl CachedFunctionEntry {
|
|
42
|
+
fn from_function_info(info: &FunctionInfo) -> Self {
|
|
43
|
+
let matrix_rows = info
|
|
44
|
+
.matrix
|
|
45
|
+
.rows()
|
|
46
|
+
.into_iter()
|
|
47
|
+
.map(|row| row.to_vec())
|
|
48
|
+
.collect();
|
|
49
|
+
CachedFunctionEntry {
|
|
50
|
+
entry: info.entry.clone(),
|
|
51
|
+
matrix_rows,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Reconstruct an `Array2<u8>` from the stored rows.
|
|
56
|
+
pub fn to_matrix(&self) -> ndarray::Array2<u8> {
|
|
57
|
+
let nrows = self.matrix_rows.len();
|
|
58
|
+
let ncols = self.matrix_rows.first().map(|r| r.len()).unwrap_or(0);
|
|
59
|
+
if nrows == 0 || ncols == 0 {
|
|
60
|
+
return ndarray::Array2::zeros((57, 72));
|
|
61
|
+
}
|
|
62
|
+
let flat: Vec<u8> = self.matrix_rows.iter().flatten().cloned().collect();
|
|
63
|
+
ndarray::Array2::from_shape_vec((nrows, ncols), flat)
|
|
64
|
+
.unwrap_or_else(|_| ndarray::Array2::zeros((57, 72)))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// In-memory representation (only alive during a single process invocation)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/// Function metadata with its precomputed state matrix.
|
|
73
|
+
#[derive(Clone)]
|
|
74
|
+
pub struct FunctionInfo {
|
|
75
|
+
pub entry: FunctionEntry,
|
|
76
|
+
pub matrix: ndarray::Array2<u8>,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Public API
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/// Build a project index from a list of relative file paths.
|
|
84
|
+
///
|
|
85
|
+
/// Saves the result to `{project_root}/.pi-lens/rust-index.json` so that
|
|
86
|
+
/// subsequent `find_similar_to` calls in *separate* process invocations can
|
|
87
|
+
/// load it without rebuilding.
|
|
88
|
+
pub fn build_project_index(project_root: &str, files: &[String]) -> anyhow::Result<IndexData> {
|
|
89
|
+
// Parse all TypeScript/JavaScript files in parallel.
|
|
90
|
+
let results: Vec<FunctionInfo> = files
|
|
91
|
+
.par_iter()
|
|
92
|
+
.filter(|f| {
|
|
93
|
+
f.ends_with(".ts")
|
|
94
|
+
|| f.ends_with(".tsx")
|
|
95
|
+
|| f.ends_with(".js")
|
|
96
|
+
|| f.ends_with(".jsx")
|
|
97
|
+
})
|
|
98
|
+
.flat_map(|file_path| {
|
|
99
|
+
let full_path = Path::new(project_root).join(file_path);
|
|
100
|
+
extract_functions(&full_path, file_path).unwrap_or_default()
|
|
101
|
+
})
|
|
102
|
+
.collect();
|
|
103
|
+
|
|
104
|
+
let function_entries: Vec<FunctionEntry> = results.iter().map(|r| r.entry.clone()).collect();
|
|
105
|
+
let entry_count = function_entries.len();
|
|
106
|
+
|
|
107
|
+
// Persist to disk so `find_similar_to` can read it later.
|
|
108
|
+
let cached = CachedIndex {
|
|
109
|
+
version: CachedIndex::CURRENT_VERSION,
|
|
110
|
+
project_root: project_root.to_string(),
|
|
111
|
+
functions: results.iter().map(CachedFunctionEntry::from_function_info).collect(),
|
|
112
|
+
};
|
|
113
|
+
if let Err(e) = cache::save_index(project_root, &cached) {
|
|
114
|
+
eprintln!("[pi-lens-core] Warning: could not save index cache: {}", e);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Ok(IndexData {
|
|
118
|
+
entry_count,
|
|
119
|
+
functions: function_entries,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Find functions in the index that are similar to any function defined in
|
|
124
|
+
/// `file_path`.
|
|
125
|
+
///
|
|
126
|
+
/// `file_path` may be absolute or relative; it is normalised before lookup.
|
|
127
|
+
/// Requires `build_project_index` to have been called at least once
|
|
128
|
+
/// (it persists the index to disk).
|
|
129
|
+
pub fn find_similar_to(project_root: &str, file_path: &str, threshold: f32) -> Vec<SimilarityMatch> {
|
|
130
|
+
let cached = match cache::load_index(project_root) {
|
|
131
|
+
Some(c) => c,
|
|
132
|
+
None => return vec![],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Normalise to a forward-slash relative path for consistent matching.
|
|
136
|
+
let target_rel = normalize_path(project_root, file_path);
|
|
137
|
+
|
|
138
|
+
// Reconstruct all matrices (only deserialise once).
|
|
139
|
+
let all: Vec<(FunctionEntry, ndarray::Array2<u8>)> = cached
|
|
140
|
+
.functions
|
|
141
|
+
.iter()
|
|
142
|
+
.map(|cf| (cf.entry.clone(), cf.to_matrix()))
|
|
143
|
+
.collect();
|
|
144
|
+
|
|
145
|
+
// Targets = every function whose file matches the requested path.
|
|
146
|
+
let targets: Vec<&(FunctionEntry, ndarray::Array2<u8>)> = all
|
|
147
|
+
.iter()
|
|
148
|
+
.filter(|(e, _)| normalize_path(project_root, &e.file_path) == target_rel)
|
|
149
|
+
.collect();
|
|
150
|
+
|
|
151
|
+
if targets.is_empty() {
|
|
152
|
+
return vec![];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let mut matches = Vec::new();
|
|
156
|
+
|
|
157
|
+
for (target_entry, target_matrix) in &targets {
|
|
158
|
+
for (other_entry, other_matrix) in &all {
|
|
159
|
+
// Skip self-matches.
|
|
160
|
+
if other_entry.id == target_entry.id {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let sim = crate::similarity::calculate_similarity(target_matrix, other_matrix);
|
|
165
|
+
if sim >= threshold {
|
|
166
|
+
matches.push(SimilarityMatch {
|
|
167
|
+
source_id: target_entry.id.clone(),
|
|
168
|
+
target_id: other_entry.id.clone(),
|
|
169
|
+
similarity: sim,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Highest similarity first.
|
|
176
|
+
matches.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap_or(std::cmp::Ordering::Equal));
|
|
177
|
+
matches
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// File parsing helpers
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
fn extract_functions(full_path: &Path, relative_path: &str) -> anyhow::Result<Vec<FunctionInfo>> {
|
|
185
|
+
let source = fs::read_to_string(full_path)?;
|
|
186
|
+
|
|
187
|
+
let mut parser = tree_sitter::Parser::new();
|
|
188
|
+
let language = tree_sitter_typescript::language_typescript();
|
|
189
|
+
parser
|
|
190
|
+
.set_language(&language)
|
|
191
|
+
.map_err(|_| anyhow::anyhow!("Failed to load TypeScript grammar"))?;
|
|
192
|
+
|
|
193
|
+
let tree = match parser.parse(&source, None) {
|
|
194
|
+
Some(t) => t,
|
|
195
|
+
None => return Ok(vec![]),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
let mut results = Vec::new();
|
|
199
|
+
collect_functions(&tree.root_node(), &source, relative_path, &mut results);
|
|
200
|
+
Ok(results)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Recursively descend the AST to collect function nodes.
|
|
204
|
+
/// Transparent wrappers like `export_statement` are traversed without
|
|
205
|
+
/// emitting an entry; function nodes stop the descent (no nested tracking).
|
|
206
|
+
fn collect_functions(
|
|
207
|
+
node: &tree_sitter::Node,
|
|
208
|
+
source: &str,
|
|
209
|
+
relative_path: &str,
|
|
210
|
+
results: &mut Vec<FunctionInfo>,
|
|
211
|
+
) {
|
|
212
|
+
match node.kind() {
|
|
213
|
+
"function_declaration" | "function" | "method_definition" => {
|
|
214
|
+
if let Some((name, line, signature)) = extract_function_info(node, source) {
|
|
215
|
+
let func_source = &source[node.byte_range()];
|
|
216
|
+
let matrix = build_state_matrix(func_source);
|
|
217
|
+
results.push(FunctionInfo {
|
|
218
|
+
entry: FunctionEntry {
|
|
219
|
+
id: format!("{}::{}@{}", relative_path, name, line),
|
|
220
|
+
file_path: relative_path.to_string(),
|
|
221
|
+
name,
|
|
222
|
+
line,
|
|
223
|
+
signature,
|
|
224
|
+
matrix_hash: format!(
|
|
225
|
+
"{:x}",
|
|
226
|
+
matrix.iter().map(|&v| v as u64).sum::<u64>()
|
|
227
|
+
),
|
|
228
|
+
},
|
|
229
|
+
matrix,
|
|
230
|
+
});
|
|
231
|
+
// Don't recurse further — nested functions tracked separately.
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Transparent wrappers: walk into them.
|
|
236
|
+
"export_statement"
|
|
237
|
+
| "ambient_declaration"
|
|
238
|
+
| "class_declaration"
|
|
239
|
+
| "class_body"
|
|
240
|
+
| "statement_block"
|
|
241
|
+
| "program" => {}
|
|
242
|
+
// Skip everything else (literals, identifiers, expressions, …).
|
|
243
|
+
_ => return,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let mut cursor = node.walk();
|
|
247
|
+
for child in node.children(&mut cursor) {
|
|
248
|
+
collect_functions(&child, source, relative_path, results);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fn extract_function_info(
|
|
253
|
+
node: &tree_sitter::Node,
|
|
254
|
+
source: &str,
|
|
255
|
+
) -> Option<(String, usize, String)> {
|
|
256
|
+
let line = node.start_position().row + 1; // 1-based
|
|
257
|
+
|
|
258
|
+
// Name comes from the first `identifier` child.
|
|
259
|
+
let mut cursor = node.walk();
|
|
260
|
+
let name = node
|
|
261
|
+
.children(&mut cursor)
|
|
262
|
+
.find(|c| c.kind() == "identifier")
|
|
263
|
+
.and_then(|c| c.utf8_text(source.as_bytes()).ok())
|
|
264
|
+
.map(|s| s.to_string())?;
|
|
265
|
+
|
|
266
|
+
// Signature = everything before the body block.
|
|
267
|
+
let signature = if let Some(body) = node.child_by_field_name("body") {
|
|
268
|
+
source[node.start_byte()..body.start_byte()]
|
|
269
|
+
.trim()
|
|
270
|
+
.to_string()
|
|
271
|
+
} else {
|
|
272
|
+
format!("function {}", name)
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
Some((name, line, signature))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Path utilities
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/// Strip `project_root` prefix (if present) and normalise separators to `/`.
|
|
283
|
+
fn normalize_path(project_root: &str, file_path: &str) -> String {
|
|
284
|
+
let stripped = if file_path.starts_with(project_root) {
|
|
285
|
+
&file_path[project_root.len()..]
|
|
286
|
+
} else {
|
|
287
|
+
file_path
|
|
288
|
+
};
|
|
289
|
+
stripped
|
|
290
|
+
.trim_start_matches(['/', '\\'])
|
|
291
|
+
.replace('\\', "/")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Tests
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
#[cfg(test)]
|
|
299
|
+
mod tests {
|
|
300
|
+
use super::*;
|
|
301
|
+
use std::io::Write;
|
|
302
|
+
use tempfile::TempDir;
|
|
303
|
+
|
|
304
|
+
#[test]
|
|
305
|
+
fn test_extract_functions_simple() {
|
|
306
|
+
let temp = TempDir::new().unwrap();
|
|
307
|
+
let file_path = temp.path().join("test.ts");
|
|
308
|
+
let source = r#"
|
|
309
|
+
function add(a: number, b: number): number {
|
|
310
|
+
return a + b;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function subtract(x: number, y: number): number {
|
|
314
|
+
return x - y;
|
|
315
|
+
}
|
|
316
|
+
"#;
|
|
317
|
+
let mut file = fs::File::create(&file_path).unwrap();
|
|
318
|
+
file.write_all(source.as_bytes()).unwrap();
|
|
319
|
+
|
|
320
|
+
let results = extract_functions(&file_path, "test.ts").unwrap();
|
|
321
|
+
assert_eq!(results.len(), 2);
|
|
322
|
+
assert_eq!(results[0].entry.name, "add");
|
|
323
|
+
assert_eq!(results[1].entry.name, "subtract");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#[test]
|
|
327
|
+
fn test_build_index() {
|
|
328
|
+
let temp = TempDir::new().unwrap();
|
|
329
|
+
let root = temp.path();
|
|
330
|
+
|
|
331
|
+
fs::write(
|
|
332
|
+
root.join("main.ts"),
|
|
333
|
+
"function main() { console.log('Hello'); }\n",
|
|
334
|
+
)
|
|
335
|
+
.unwrap();
|
|
336
|
+
fs::write(
|
|
337
|
+
root.join("lib.ts"),
|
|
338
|
+
"export function helper(): number { return 42; }\n",
|
|
339
|
+
)
|
|
340
|
+
.unwrap();
|
|
341
|
+
|
|
342
|
+
let files = vec!["main.ts".to_string(), "lib.ts".to_string()];
|
|
343
|
+
let index = build_project_index(root.to_str().unwrap(), &files).unwrap();
|
|
344
|
+
|
|
345
|
+
assert_eq!(index.entry_count, 2);
|
|
346
|
+
assert_eq!(index.functions.len(), 2);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#[test]
|
|
350
|
+
fn test_index_persisted_to_disk() {
|
|
351
|
+
let temp = TempDir::new().unwrap();
|
|
352
|
+
let root = temp.path();
|
|
353
|
+
|
|
354
|
+
fs::write(
|
|
355
|
+
root.join("utils.ts"),
|
|
356
|
+
"export function formatDate(d: Date): string { return d.toISOString(); }\n",
|
|
357
|
+
)
|
|
358
|
+
.unwrap();
|
|
359
|
+
|
|
360
|
+
build_project_index(root.to_str().unwrap(), &["utils.ts".to_string()]).unwrap();
|
|
361
|
+
|
|
362
|
+
// Cache file must exist after building.
|
|
363
|
+
let cache_path = cache::get_index_cache_path(root.to_str().unwrap());
|
|
364
|
+
assert!(cache_path.exists(), "rust-index.json should have been created");
|
|
365
|
+
|
|
366
|
+
let loaded = cache::load_index(root.to_str().unwrap()).unwrap();
|
|
367
|
+
assert_eq!(loaded.functions.len(), 1);
|
|
368
|
+
assert_eq!(loaded.functions[0].entry.name, "formatDate");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn test_find_similar_to_reads_disk() {
|
|
373
|
+
let temp = TempDir::new().unwrap();
|
|
374
|
+
let root = temp.path();
|
|
375
|
+
let root_str = root.to_str().unwrap();
|
|
376
|
+
|
|
377
|
+
// Two structurally similar functions in different files.
|
|
378
|
+
fs::write(
|
|
379
|
+
root.join("a.ts"),
|
|
380
|
+
"function sum(a: number, b: number): number { return a + b; }\n",
|
|
381
|
+
)
|
|
382
|
+
.unwrap();
|
|
383
|
+
fs::write(
|
|
384
|
+
root.join("b.ts"),
|
|
385
|
+
"function add(x: number, y: number): number { return x + y; }\n",
|
|
386
|
+
)
|
|
387
|
+
.unwrap();
|
|
388
|
+
|
|
389
|
+
build_project_index(root_str, &["a.ts".to_string(), "b.ts".to_string()]).unwrap();
|
|
390
|
+
|
|
391
|
+
let matches = find_similar_to(root_str, "a.ts", 0.5);
|
|
392
|
+
// Should find b.ts::add as similar to a.ts::sum.
|
|
393
|
+
assert!(
|
|
394
|
+
!matches.is_empty(),
|
|
395
|
+
"expected at least one similarity match across files"
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#[test]
|
|
400
|
+
fn test_normalize_path_strips_root() {
|
|
401
|
+
assert_eq!(
|
|
402
|
+
normalize_path("/proj", "/proj/src/utils.ts"),
|
|
403
|
+
"src/utils.ts"
|
|
404
|
+
);
|
|
405
|
+
assert_eq!(normalize_path("/proj", "src/utils.ts"), "src/utils.ts");
|
|
406
|
+
}
|
|
407
|
+
}
|