Add caller/reference analysis, C# imports, and de-noised search
Agent-workflow tooling and search quality improvements: - find_callers / find_references (by id or exact name), with caller attribution to the innermost enclosing symbol and import-graph narrowing - index file (single-file re-index) and workspace glob member resolution - search symbols: exact > prefix > fulltext > substring fallback, inline source and line numbers, --limit on all search paths - search text: reads live from disk (no stale FTS snapshot), collapses duplicate snippets, correct multi-word phrase matching - find dead-symbols: excludes framework-wired entry points (pytest test_*, #[test] tests, FastAPI/Flask routes, Celery tasks, click/typer commands) - find references: filters the definition line and string/comment occurrences (incl. Rust raw strings) - C# import edges via new extract_csharp (using directives) - richer MCP tool descriptions; brace-in-string tokenizer fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
a416a9fe52
commit
f1531a64d4
12 files changed
+1317
-214
No files matched your search
+26
-5
@@ -44,7 +44,7 @@ pub enum Command {
|
||||
#[command(subcommand)]
|
||||
sub: InspectCommand,
|
||||
},
|
||||
/// Find importers or dead code
|
||||
/// Find importers, callers, references, or dead code
|
||||
Find {
|
||||
#[command(subcommand)]
|
||||
sub: FindCommand,
|
||||
@@ -61,20 +61,35 @@ pub enum IndexCommand {
|
||||
Create { path: PathBuf },
|
||||
/// Re-index only changed files
|
||||
Update { path: PathBuf },
|
||||
/// Re-index a single file (relative or absolute path)
|
||||
File { file: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SearchCommand {
|
||||
/// Search symbols by name
|
||||
Symbols { query: String },
|
||||
Symbols {
|
||||
query: String,
|
||||
/// Include the symbol's source inline (skips the inspect_symbol round-trip)
|
||||
#[arg(long)]
|
||||
include_source: bool,
|
||||
/// Maximum number of results (default 200)
|
||||
#[arg(long, default_value_t = 200)]
|
||||
limit: usize,
|
||||
},
|
||||
/// Full-text search over file contents
|
||||
Text { query: String },
|
||||
Text {
|
||||
query: String,
|
||||
/// Maximum number of matching files (default 100)
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum InspectCommand {
|
||||
/// Source code of a single symbol by id
|
||||
Symbol { id: i64 },
|
||||
/// Source code of a symbol by id or exact name
|
||||
Symbol { target: String },
|
||||
/// Symbol outline of a file
|
||||
Outline { file: String },
|
||||
/// Read a file or a line range (e.g. --lines 10-40)
|
||||
@@ -104,4 +119,10 @@ pub enum FindCommand {
|
||||
Importers { file: String },
|
||||
/// Files with no importers
|
||||
Dead,
|
||||
/// Symbols never referenced by name outside their definition
|
||||
DeadSymbols,
|
||||
/// Symbols that call the given symbol (by id or exact name)
|
||||
Callers { target: String },
|
||||
/// Lines where an identifier is referenced
|
||||
References { identifier: String },
|
||||
}
|
||||
+8
-6
@@ -1,7 +1,5 @@
|
||||
//! Project configuration: paths, cache dir, ignore patterns, language mapping.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directory names skipped during discovery (own list).
|
||||
@@ -52,6 +50,7 @@ const LANGUAGE_BY_EXT: &[(&str, &str)] = &[
|
||||
("hh", "cpp"),
|
||||
("hxx", "cpp"),
|
||||
("java", "java"),
|
||||
("cs", "csharp"),
|
||||
("html", "html"),
|
||||
("htm", "html"),
|
||||
("css", "css"),
|
||||
@@ -92,10 +91,13 @@ pub fn cache_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Deterministic short key for a project path (used to name the cache DB).
|
||||
///
|
||||
/// xxh3 rather than `DefaultHasher` (SipHash): `DefaultHasher` is explicitly
|
||||
/// not guaranteed stable across Rust releases, which would silently orphan the
|
||||
/// old index on every toolchain upgrade. xxh3 is a fixed, version-independent
|
||||
/// algorithm and is already a dependency.
|
||||
fn project_key(path: &str) -> String {
|
||||
let mut h = DefaultHasher::new();
|
||||
path.hash(&mut h);
|
||||
format!("{:016x}", h.finish())
|
||||
format!("{:016x}", xxhash_rust::xxh3::xxh3_64(path.as_bytes()))
|
||||
}
|
||||
|
||||
/// Path of the SQLite index DB for a project root.
|
||||
@@ -104,7 +106,7 @@ pub fn index_db_path(project: &Path) -> PathBuf {
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| project.to_path_buf());
|
||||
let key = project_key(&canon.to_string_lossy());
|
||||
let name = project
|
||||
let name = canon
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "project".to_string());
|
||||
|
||||
@@ -12,12 +12,3 @@ pub fn hash_file(path: &Path) -> Result<String> {
|
||||
f.read_to_end(&mut buf)?;
|
||||
Ok(format!("{:016x}", xxhash_rust::xxh3::xxh3_64(&buf)))
|
||||
}
|
||||
|
||||
pub fn mtime_secs(path: &Path) -> i64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
+62
-12
@@ -26,6 +26,7 @@ pub fn extract(f: &SourceFile) -> Vec<Import> {
|
||||
"c" | "cpp" => extract_c(&content),
|
||||
"java" => extract_java(&content),
|
||||
"html" => extract_html(&content),
|
||||
"csharp" => extract_csharp(&content),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -65,11 +66,30 @@ fn extract_python(content: &str) -> Vec<Import> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// `from <module> import a, b` — record the module edge (for
|
||||
// symbols re-exported from `<module>/__init__.py`) plus a
|
||||
// best-effort edge per imported name as a potential submodule
|
||||
// (`from finir.lib import auto_code` → `finir.lib.auto_code`).
|
||||
out.push(Import {
|
||||
target: name.to_string(),
|
||||
import_type: "from".into(),
|
||||
levels,
|
||||
});
|
||||
if let Some(after) = rest.split_once("import ") {
|
||||
for n in after.1.split(',') {
|
||||
let n = n.trim().trim_end_matches('\\').trim();
|
||||
// Drop `as alias`; keep only the imported name.
|
||||
let n = n.split_whitespace().next().unwrap_or(n);
|
||||
if n.is_empty() || n == "*" {
|
||||
continue;
|
||||
}
|
||||
out.push(Import {
|
||||
target: format!("{name}.{n}"),
|
||||
import_type: "from".into(),
|
||||
levels,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +123,12 @@ fn extract_rust(content: &str) -> Vec<Import> {
|
||||
.trim_start_matches("crate::")
|
||||
.trim_start_matches("self::")
|
||||
.trim_start_matches("super::")
|
||||
.trim_start_matches("::");
|
||||
.trim_start_matches("::")
|
||||
// `use foo as bar;` — keep only the crate/module path.
|
||||
.split(" as ")
|
||||
.next()
|
||||
.unwrap_or(rest)
|
||||
.trim();
|
||||
let mut segs: Vec<&str> = path.split("::").filter(|s| !s.is_empty()).collect();
|
||||
if segs.is_empty() || matches!(segs[0], "std" | "core" | "alloc") {
|
||||
continue;
|
||||
@@ -228,6 +253,31 @@ fn extract_c(content: &str) -> Vec<Import> {
|
||||
out
|
||||
}
|
||||
|
||||
/// C# `using` directives. Namespaces are dotted and map back to paths through
|
||||
/// the resolver's dotted→slashed fallback; `using static X;` and
|
||||
/// `using Alias = X;` reduce to the namespace/type path `X`.
|
||||
fn extract_csharp(content: &str) -> Vec<Import> {
|
||||
let mut out = Vec::new();
|
||||
for line in content.lines() {
|
||||
let l = line.trim();
|
||||
let Some(rest) = l.strip_prefix("using ") else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start_matches("static ").trim();
|
||||
// `using Alias = Namespace.Type;` — keep only the right-hand path.
|
||||
let rest = rest.split('=').next().unwrap_or(rest).trim();
|
||||
let target = rest.trim_end_matches(';').trim();
|
||||
if !target.is_empty() {
|
||||
out.push(Import {
|
||||
target: target.to_string(),
|
||||
import_type: "using".into(),
|
||||
levels: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_java(content: &str) -> Vec<Import> {
|
||||
let mut out = Vec::new();
|
||||
for line in content.lines() {
|
||||
@@ -311,7 +361,7 @@ fn clean(target: &str) -> Option<String> {
|
||||
const MODULE_ENTRIES: &[&str] = &["mod.rs", "__init__.py", "index.ts", "index.js", "index.tsx"];
|
||||
|
||||
impl Resolver {
|
||||
pub fn build(files: Vec<(String, i64)>, crate_name: Option<&str>) -> Self {
|
||||
pub fn build(files: Vec<(String, i64)>, crate_aliases: &[(String, String)]) -> Self {
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_name = HashMap::new();
|
||||
let mut by_stem = HashMap::new();
|
||||
@@ -325,16 +375,16 @@ impl Resolver {
|
||||
.unwrap_or(name);
|
||||
by_stem.entry(stem).or_insert(id);
|
||||
}
|
||||
// `use <crate>::…` names the crate's own lib root, which always lives
|
||||
// at src/lib.rs — there is no `<crate>.rs` file. Register the package
|
||||
// name as an alias so bin→lib edges resolve. Workspaces (no [package]
|
||||
// name) are skipped.
|
||||
if let Some(name) = crate_name.filter(|n| !n.is_empty())
|
||||
&& let Some(id) = by_path.get("src/lib.rs").copied()
|
||||
{
|
||||
by_path.insert(name.to_string(), id);
|
||||
by_name.entry(name.to_string()).or_insert(id);
|
||||
by_stem.entry(name.to_string()).or_insert(id);
|
||||
// `use <crate>::…` names a crate's lib root, which always lives at
|
||||
// `src/lib.rs` — there is no `<crate>.rs` file. Register each package
|
||||
// name (root crate + workspace members) as an alias for its lib root so
|
||||
// cross-crate edges resolve.
|
||||
for (name, rel) in crate_aliases {
|
||||
if let Some(id) = by_path.get(rel).copied() {
|
||||
by_path.insert(name.clone(), id);
|
||||
by_name.entry(name.clone()).or_insert(id);
|
||||
by_stem.entry(name.clone()).or_insert(id);
|
||||
}
|
||||
}
|
||||
Resolver {
|
||||
by_path,
|
||||
|
||||
+333
-26
@@ -7,6 +7,7 @@ use anyhow::{Context, Result};
|
||||
use rayon::prelude::*;
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::config;
|
||||
use crate::ctags;
|
||||
use crate::discovery::{self, SourceFile};
|
||||
use crate::index::{hashing, imports, store};
|
||||
@@ -18,10 +19,138 @@ pub struct IndexStats {
|
||||
pub imports: i64,
|
||||
}
|
||||
|
||||
/// Read the `[package] name` from `Cargo.toml`, normalized to the crate
|
||||
/// identifier (`-` → `_`) used in `use <crate>::…`.
|
||||
fn read_crate_name(project: &Path) -> Option<String> {
|
||||
let toml = std::fs::read_to_string(project.join("Cargo.toml")).ok()?;
|
||||
/// Read `(crate_name, lib_rel_path)` aliases from `Cargo.toml`: the root
|
||||
/// `[package]` plus every `[workspace] members` entry. Crate names are
|
||||
/// normalized to the identifier (`-` → `_`) used in `use <crate>::…`.
|
||||
fn read_crate_aliases(project: &Path) -> Vec<(String, String)> {
|
||||
let Some(toml) = std::fs::read_to_string(project.join("Cargo.toml")).ok() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut aliases = Vec::new();
|
||||
let mut root_name = None;
|
||||
let mut members = Vec::new();
|
||||
let mut section = String::new();
|
||||
for line in toml.lines() {
|
||||
let l = line.trim();
|
||||
if l.starts_with('[') {
|
||||
section = l.trim_matches(['[', ']']).trim().to_string();
|
||||
continue;
|
||||
}
|
||||
if section == "package"
|
||||
&& root_name.is_none()
|
||||
&& let Some(v) = l.strip_prefix("name").map(str::trim)
|
||||
&& let Some(v) = v.strip_prefix('=').map(str::trim)
|
||||
{
|
||||
root_name = Some(v.trim_matches('"').replace('-', "_"));
|
||||
} else if section == "workspace"
|
||||
&& let Some(v) = l.strip_prefix("members").map(str::trim)
|
||||
&& let Some(v) = v.strip_prefix('=').map(str::trim)
|
||||
{
|
||||
members = parse_members(v);
|
||||
}
|
||||
}
|
||||
if let Some(name) = root_name
|
||||
&& !name.is_empty()
|
||||
{
|
||||
aliases.push((name, "src/lib.rs".to_string()));
|
||||
}
|
||||
for m in members {
|
||||
if m.contains('*') || m.contains('?') {
|
||||
for expanded in expand_member_globs(project, &m) {
|
||||
if let Some(name) = member_package_name(project, &expanded) {
|
||||
aliases.push((name, format!("{expanded}/src/lib.rs")));
|
||||
}
|
||||
}
|
||||
} else if let Some(name) = member_package_name(project, &m) {
|
||||
aliases.push((name, format!("{m}/src/lib.rs")));
|
||||
}
|
||||
}
|
||||
aliases
|
||||
}
|
||||
|
||||
/// Extract entries from a `members = ["a", "b"]` value, keeping globs for later
|
||||
/// expansion.
|
||||
fn parse_members(v: &str) -> Vec<String> {
|
||||
let inner = match (v.find('['), v.rfind(']')) {
|
||||
(Some(s), Some(e)) if e > s => &v[s + 1..e],
|
||||
_ => v,
|
||||
};
|
||||
inner
|
||||
.split(',')
|
||||
.map(|p| p.trim().trim_matches('"').trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Expand a `members` glob (e.g. `crates/*`) to member directories that contain
|
||||
/// a `Cargo.toml`. Only the trailing path component may contain a glob.
|
||||
fn expand_member_globs(project: &Path, pattern: &str) -> Vec<String> {
|
||||
let comps: Vec<&str> = pattern.split('/').collect();
|
||||
let Some((last, prefix_comps)) = comps.split_last() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !last.contains('*') && !last.contains('?') {
|
||||
return Vec::new();
|
||||
}
|
||||
let prefix = prefix_comps.join("/");
|
||||
let base = if prefix.is_empty() {
|
||||
project.to_path_buf()
|
||||
} else {
|
||||
project.join(&prefix)
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&base) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
if !e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if !glob_matches(last, &name) {
|
||||
continue;
|
||||
}
|
||||
let rel = if prefix.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{prefix}/{name}")
|
||||
};
|
||||
if project.join(&rel).join("Cargo.toml").is_file() {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal glob matcher for `*` (any sequence) and `?` (single char).
|
||||
fn glob_matches(pattern: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let (np, nt) = (p.len(), t.len());
|
||||
let mut dp = vec![vec![false; nt + 1]; np + 1];
|
||||
dp[0][0] = true;
|
||||
for i in 1..=np {
|
||||
if p[i - 1] == '*' {
|
||||
dp[i][0] = dp[i - 1][0];
|
||||
}
|
||||
}
|
||||
for i in 1..=np {
|
||||
for j in 1..=nt {
|
||||
dp[i][j] = match p[i - 1] {
|
||||
'*' => dp[i - 1][j] || dp[i][j - 1],
|
||||
'?' => dp[i - 1][j - 1],
|
||||
c => dp[i - 1][j - 1] && c == t[j - 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
dp[np][nt]
|
||||
}
|
||||
|
||||
/// Read a workspace member's `[package] name`, normalized to a crate identifier.
|
||||
fn member_package_name(project: &Path, member: &str) -> Option<String> {
|
||||
let toml = std::fs::read_to_string(project.join(member).join("Cargo.toml")).ok()?;
|
||||
let mut in_package = false;
|
||||
for line in toml.lines() {
|
||||
let l = line.trim();
|
||||
@@ -62,13 +191,12 @@ pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats>
|
||||
|
||||
// Hash all files in parallel. Propagate read errors instead of silently
|
||||
// treating an unreadable file as an empty (all-zero) hash.
|
||||
let hashed: Vec<(String, String, i64, &'static str)> = files
|
||||
let hashed: Vec<(String, String, &'static str)> = files
|
||||
.par_iter()
|
||||
.map(|f| {
|
||||
let h = hashing::hash_file(&f.path)
|
||||
.with_context(|| format!("cannot hash {}", f.path.display()))?;
|
||||
let m = hashing::mtime_secs(&f.path);
|
||||
Ok((f.rel.clone(), h, m, f.language))
|
||||
Ok((f.rel.clone(), h, f.language))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
@@ -80,7 +208,7 @@ pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats>
|
||||
|
||||
let changed: Vec<&SourceFile> = hashed
|
||||
.iter()
|
||||
.filter(|(rel, h, _, _)| existing.get(rel).map(|(_, oh)| oh != h).unwrap_or(true))
|
||||
.filter(|(rel, h, _)| existing.get(rel).map(|(_, oh)| oh != h).unwrap_or(true))
|
||||
.filter_map(|(rel, ..)| by_rel.get(rel.as_str()).copied())
|
||||
.collect();
|
||||
|
||||
@@ -93,8 +221,8 @@ pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats>
|
||||
conn.execute_batch("BEGIN;")?;
|
||||
|
||||
let mut file_ids: HashMap<String, i64> = HashMap::new();
|
||||
for (rel, h, m, lang) in &hashed {
|
||||
let id = store::upsert_file(&conn, rel, h, lang, *m)?;
|
||||
for (rel, h, lang) in &hashed {
|
||||
let id = store::upsert_file(&conn, rel, h, lang)?;
|
||||
file_ids.insert(rel.clone(), id);
|
||||
}
|
||||
|
||||
@@ -115,7 +243,7 @@ pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats>
|
||||
|
||||
let resolver = imports::Resolver::build(
|
||||
file_ids.iter().map(|(k, v)| (k.clone(), *v)).collect(),
|
||||
read_crate_name(project).as_deref(),
|
||||
&read_crate_aliases(project),
|
||||
);
|
||||
|
||||
// Symbols + content FTS: only files whose content changed. Skipping ctags
|
||||
@@ -137,9 +265,104 @@ pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats>
|
||||
// change whether an unchanged importer resolves, so resolution must cover
|
||||
// the whole file set, not just the changed set. (Cheap: read + regex, no
|
||||
// ctags subprocess.)
|
||||
for f in &files {
|
||||
reindex_imports(&conn, &files, &file_ids, &resolver)?;
|
||||
|
||||
conn.execute_batch("COMMIT;")?;
|
||||
|
||||
summary(&conn)
|
||||
}
|
||||
|
||||
/// Re-index a single file by path (relative to the project, or absolute under
|
||||
/// it), then re-resolve imports globally. Much faster than `run` for the
|
||||
/// edit-one-file loop agents use: it hashes and runs ctags over one file only.
|
||||
pub fn run_file(project: &Path, db_path: &Path, target: &str) -> Result<IndexStats> {
|
||||
if !db_path.exists() {
|
||||
anyhow::bail!(
|
||||
"no index for {}. Run `rustytokens index create <path>` first.",
|
||||
project.display()
|
||||
);
|
||||
}
|
||||
let conn = store::open(db_path)?;
|
||||
|
||||
let rel = resolve_rel(project, target)?;
|
||||
let abs = project.join(&rel);
|
||||
if !abs.is_file() {
|
||||
anyhow::bail!("not a file: {}", abs.display());
|
||||
}
|
||||
let Some(language) = config::language_for(&abs) else {
|
||||
anyhow::bail!("unsupported file type: {rel}");
|
||||
};
|
||||
|
||||
let hash =
|
||||
hashing::hash_file(&abs).with_context(|| format!("cannot hash {}", abs.display()))?;
|
||||
let tags = ctags::run(&[abs.as_path()])?;
|
||||
let content =
|
||||
std::fs::read_to_string(&abs).with_context(|| format!("cannot read {}", abs.display()))?;
|
||||
let symbols = tags_to_symbols(&tags, &content);
|
||||
|
||||
conn.execute_batch("BEGIN;")?;
|
||||
let fid = store::upsert_file(&conn, &rel, &hash, language)?;
|
||||
store::replace_symbols(&conn, fid, &symbols)?;
|
||||
conn.execute("DELETE FROM content_fts WHERE rowid = ?1", [fid])?;
|
||||
conn.execute(
|
||||
"INSERT INTO content_fts(rowid, path, content) VALUES (?1, ?2, ?3)",
|
||||
params![fid, rel, content],
|
||||
)?;
|
||||
|
||||
// A newly added file can make other files' imports resolve, so re-resolve
|
||||
// the whole import graph (same cheap read+regex pass as `run`).
|
||||
let files = discovery::discover(project)?;
|
||||
let file_ids: HashMap<String, i64> = store::all_files(&conn)?
|
||||
.into_iter()
|
||||
.map(|f| (f.path.clone(), f.id))
|
||||
.collect();
|
||||
let resolver = imports::Resolver::build(
|
||||
file_ids.iter().map(|(k, v)| (k.clone(), *v)).collect(),
|
||||
&read_crate_aliases(project),
|
||||
);
|
||||
reindex_imports(&conn, &files, &file_ids, &resolver)?;
|
||||
|
||||
conn.execute_batch("COMMIT;")?;
|
||||
|
||||
summary(&conn)
|
||||
}
|
||||
|
||||
fn summary(conn: &rusqlite::Connection) -> Result<IndexStats> {
|
||||
let (files_n, symbols_n, imports_n) = store::stats(conn)?;
|
||||
Ok(IndexStats {
|
||||
files: files_n,
|
||||
symbols: symbols_n,
|
||||
imports: imports_n,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied file target to a project-relative path. Absolute
|
||||
/// paths under the project are stripped; relative paths pass through unchanged.
|
||||
fn resolve_rel(project: &Path, target: &str) -> Result<String> {
|
||||
let p = Path::new(target);
|
||||
let rel = if p.is_absolute() {
|
||||
let base = project
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| project.to_path_buf());
|
||||
p.strip_prefix(&base)
|
||||
.with_context(|| format!("{target} is outside {}", project.display()))?
|
||||
.to_path_buf()
|
||||
} else {
|
||||
p.to_path_buf()
|
||||
};
|
||||
Ok(rel.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
/// Clear and re-extract imports for every file, resolving against `file_ids`.
|
||||
fn reindex_imports(
|
||||
conn: &rusqlite::Connection,
|
||||
files: &[SourceFile],
|
||||
file_ids: &HashMap<String, i64>,
|
||||
resolver: &imports::Resolver,
|
||||
) -> Result<()> {
|
||||
for f in files {
|
||||
let fid = file_ids[&f.rel];
|
||||
store::clear_imports_for_file(&conn, fid)?;
|
||||
store::clear_imports_for_file(conn, fid)?;
|
||||
let dir = f
|
||||
.rel
|
||||
.rsplit_once('/')
|
||||
@@ -150,19 +373,11 @@ pub fn run(project: &Path, db_path: &Path, recreate: bool) -> Result<IndexStats>
|
||||
if let Some(to) = resolver.resolve_with_context(&imp.target, &dir, imp.levels)
|
||||
&& to != fid
|
||||
{
|
||||
store::insert_import(&conn, fid, to, &imp.import_type)?;
|
||||
store::insert_import(conn, fid, to, &imp.import_type)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute_batch("COMMIT;")?;
|
||||
|
||||
let (files_n, symbols_n, imports_n) = store::stats(&conn)?;
|
||||
Ok(IndexStats {
|
||||
files: files_n,
|
||||
symbols: symbols_n,
|
||||
imports: imports_n,
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tags_to_symbols(tags: &[ctags::Tag], content: &str) -> Vec<store::NewSymbol> {
|
||||
@@ -206,14 +421,19 @@ fn extend_block_end(lines: &[&str], start: i64, end: i64, kind: &str) -> i64 {
|
||||
let mut depth = 0i64;
|
||||
let mut opened = false;
|
||||
let mut out = end;
|
||||
// ponytail: naive char brace count; braces in string/comment literals skew
|
||||
// depth. Swap for a tokenizer only if real spans come back wrong.
|
||||
let mut st = ScanState::default();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let n = (i as i64) + 1;
|
||||
// Track string/comment state from the top of the file so a literal
|
||||
// opened before `start` doesn't skew the brace count.
|
||||
let mask = code_mask(line, &mut st);
|
||||
if n < start {
|
||||
continue;
|
||||
}
|
||||
for c in line.chars() {
|
||||
for (c, is_code) in line.chars().zip(mask.iter()) {
|
||||
if !is_code {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
depth += 1;
|
||||
@@ -233,6 +453,93 @@ fn extend_block_end(lines: &[&str], start: i64, end: i64, kind: &str) -> i64 {
|
||||
out
|
||||
}
|
||||
|
||||
/// Lexer state carried across lines: a `/* … */` block comment or a multi-line
|
||||
/// string literal that began on an earlier line.
|
||||
#[derive(Default)]
|
||||
struct ScanState {
|
||||
in_block_comment: bool,
|
||||
/// (quote char, terminator length 1 or 3 for triple-quoted strings)
|
||||
in_string: Option<(char, u8)>,
|
||||
}
|
||||
|
||||
/// Per-char "is code" mask: string literals and comments are masked out so
|
||||
/// brace counting in [`extend_block_end`] ignores braces in literals.
|
||||
fn code_mask(line: &str, st: &mut ScanState) -> Vec<bool> {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut mask = vec![true; n];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if st.in_block_comment {
|
||||
mask[i] = false;
|
||||
if chars[i] == '*' && i + 1 < n && chars[i + 1] == '/' {
|
||||
mask[i + 1] = false;
|
||||
st.in_block_comment = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some((q, len)) = st.in_string {
|
||||
let term = len as usize;
|
||||
if i + term <= n && chars[i..i + term].iter().all(|&c| c == q) {
|
||||
for k in 0..term {
|
||||
mask[i + k] = false;
|
||||
}
|
||||
st.in_string = None;
|
||||
i += term;
|
||||
} else if term == 1 && chars[i] == '\\' && i + 1 < n {
|
||||
mask[i] = false;
|
||||
mask[i + 1] = false;
|
||||
i += 2;
|
||||
} else {
|
||||
mask[i] = false;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let c = chars[i];
|
||||
if c == '/' && i + 1 < n {
|
||||
if chars[i + 1] == '*' {
|
||||
mask[i] = false;
|
||||
mask[i + 1] = false;
|
||||
st.in_block_comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if chars[i + 1] == '/' {
|
||||
mask[i..].fill(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if c == '#' {
|
||||
let prev_ws = i == 0 || chars[i - 1].is_whitespace();
|
||||
let next = chars.get(i + 1).copied();
|
||||
if prev_ws && next != Some('[') && next != Some('!') {
|
||||
mask[i..].fill(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if c == '"' || c == '\'' || c == '`' {
|
||||
let triple = i + 2 < n && chars[i + 1] == c && chars[i + 2] == c;
|
||||
mask[i] = false;
|
||||
if triple {
|
||||
mask[i + 1] = false;
|
||||
mask[i + 2] = false;
|
||||
st.in_string = Some((c, 3));
|
||||
i += 3;
|
||||
} else {
|
||||
st.in_string = Some((c, 1));
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
fn is_block_kind(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
|
||||
+42
-21
@@ -1,19 +1,21 @@
|
||||
//! SQLite schema + minimal migration (schema_version in metadata).
|
||||
//! SQLite schema + migration (schema_version in metadata).
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
const SCHEMA_VERSION: i64 = 2;
|
||||
|
||||
/// Fresh-install schema at the current version. Everything is `IF NOT EXISTS`
|
||||
/// so it can be re-run safely (the old version re-ran it on every `version <
|
||||
/// SCHEMA_VERSION`, which broke the moment any table already existed).
|
||||
const SCHEMA: &str = r#"
|
||||
CREATE TABLE files (
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
mtime INTEGER NOT NULL
|
||||
language TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE symbols (
|
||||
CREATE TABLE IF NOT EXISTS symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
@@ -23,31 +25,40 @@ CREATE TABLE symbols (
|
||||
line_start INTEGER NOT NULL,
|
||||
line_end INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_symbols_file ON symbols(file_id);
|
||||
CREATE INDEX idx_symbols_name ON symbols(name);
|
||||
CREATE TABLE imports (
|
||||
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
|
||||
CREATE TABLE IF NOT EXISTS imports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
to_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
import_type TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_imports_from ON imports(from_file_id);
|
||||
CREATE INDEX idx_imports_to ON imports(to_file_id);
|
||||
CREATE VIRTUAL TABLE symbols_fts USING fts5(
|
||||
CREATE INDEX IF NOT EXISTS idx_imports_from ON imports(from_file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imports_to ON imports(to_file_id);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
|
||||
name, qualified_name, kind, signature,
|
||||
content='symbols', content_rowid='id'
|
||||
);
|
||||
CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
|
||||
CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
|
||||
INSERT INTO symbols_fts(rowid, name, qualified_name, kind, signature)
|
||||
VALUES (new.id, new.name, new.qualified_name, new.kind, new.signature);
|
||||
END;
|
||||
CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
|
||||
CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
|
||||
INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, kind, signature)
|
||||
VALUES ('delete', old.id, old.name, old.qualified_name, old.kind, old.signature);
|
||||
END;
|
||||
CREATE VIRTUAL TABLE content_fts USING fts5(path UNINDEXED, content);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS content_fts USING fts5(path UNINDEXED, content);
|
||||
"#;
|
||||
|
||||
/// Incremental migrations. `MIGRATIONS[i]` upgrades from schema version `i+1`
|
||||
/// to `i+2`; version 0 is a fresh database and is created via [`SCHEMA`]
|
||||
/// directly.
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
// v1 -> v2: the `mtime` column was written but never read (change detection
|
||||
// is content-hash based). Drop the dead column.
|
||||
"ALTER TABLE files DROP COLUMN mtime;",
|
||||
];
|
||||
|
||||
/// Create schema if missing, or migrate to the current version.
|
||||
pub fn init(conn: &mut Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
@@ -60,12 +71,22 @@ pub fn init(conn: &mut Connection) -> Result<()> {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if version < SCHEMA_VERSION {
|
||||
|
||||
if version == 0 {
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
|
||||
[SCHEMA_VERSION.to_string()],
|
||||
)?;
|
||||
} else {
|
||||
for (i, migration) in MIGRATIONS.iter().enumerate() {
|
||||
// i-th migration upgrades version (i+1) -> (i+2).
|
||||
if version <= (i as i64) + 1 {
|
||||
conn.execute_batch(migration)
|
||||
.with_context(|| format!("migration to schema v{} failed", i + 2))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
|
||||
[SCHEMA_VERSION.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
+51
-15
@@ -69,17 +69,11 @@ pub fn all_files(conn: &Connection) -> Result<Vec<FileRow>> {
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn upsert_file(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
hash: &str,
|
||||
language: &str,
|
||||
mtime: i64,
|
||||
) -> Result<i64> {
|
||||
pub fn upsert_file(conn: &Connection, path: &str, hash: &str, language: &str) -> Result<i64> {
|
||||
conn.execute(
|
||||
"INSERT INTO files (path, content_hash, language, mtime) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(path) DO UPDATE SET content_hash=?2, language=?3, mtime=?4",
|
||||
params![path, hash, language, mtime],
|
||||
"INSERT INTO files (path, content_hash, language) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(path) DO UPDATE SET content_hash=?2, language=?3",
|
||||
params![path, hash, language],
|
||||
)?;
|
||||
// last_insert_rowid() is unreliable after an upsert that updates; query it.
|
||||
Ok(conn.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))?)
|
||||
@@ -179,6 +173,27 @@ pub fn symbol_by_id(conn: &Connection, id: i64) -> Result<Option<SymbolRow>> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// All symbols whose name equals `name` (case-insensitive), ordered by id.
|
||||
pub fn symbols_by_name(conn: &Connection, name: &str) -> Result<Vec<SymbolRow>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_id, name, qualified_name, kind, signature, line_start, line_end
|
||||
FROM symbols WHERE name = ?1 COLLATE NOCASE ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map([name], |r| {
|
||||
Ok(SymbolRow {
|
||||
id: r.get(0)?,
|
||||
file_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
qualified_name: r.get(3)?,
|
||||
kind: r.get(4)?,
|
||||
signature: r.get(5)?,
|
||||
line_start: r.get(6)?,
|
||||
line_end: r.get(7)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn symbols_for_file(conn: &Connection, file_id: i64) -> Result<Vec<SymbolRow>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_id, name, qualified_name, kind, signature, line_start, line_end
|
||||
@@ -199,6 +214,27 @@ pub fn symbols_for_file(conn: &Connection, file_id: i64) -> Result<Vec<SymbolRow
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
/// Every symbol in the index, ordered by id.
|
||||
pub fn all_symbols(conn: &Connection) -> Result<Vec<SymbolRow>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_id, name, qualified_name, kind, signature, line_start, line_end
|
||||
FROM symbols ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(SymbolRow {
|
||||
id: r.get(0)?,
|
||||
file_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
qualified_name: r.get(3)?,
|
||||
kind: r.get(4)?,
|
||||
signature: r.get(5)?,
|
||||
line_start: r.get(6)?,
|
||||
line_end: r.get(7)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn importers_of(conn: &Connection, file_id: i64) -> Result<Vec<i64>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT from_file_id FROM imports WHERE to_file_id = ?1 ORDER BY from_file_id",
|
||||
@@ -236,8 +272,8 @@ mod tests {
|
||||
#[test]
|
||||
fn file_insert_query_and_upsert() {
|
||||
let conn = mem();
|
||||
let id1 = upsert_file(&conn, "src/lib.rs", "hash1", "rust", 100).unwrap();
|
||||
let id2 = upsert_file(&conn, "src/lib.rs", "hash2", "rust", 200).unwrap();
|
||||
let id1 = upsert_file(&conn, "src/lib.rs", "hash1", "rust").unwrap();
|
||||
let id2 = upsert_file(&conn, "src/lib.rs", "hash2", "rust").unwrap();
|
||||
assert_eq!(id1, id2, "upsert reuses the same row");
|
||||
let row = file_by_id(&conn, id1).unwrap().unwrap();
|
||||
assert_eq!(row.content_hash, "hash2");
|
||||
@@ -248,7 +284,7 @@ mod tests {
|
||||
#[test]
|
||||
fn symbols_replace_and_query() {
|
||||
let conn = mem();
|
||||
let fid = upsert_file(&conn, "src/lib.rs", "h", "rust", 0).unwrap();
|
||||
let fid = upsert_file(&conn, "src/lib.rs", "h", "rust").unwrap();
|
||||
replace_symbols(
|
||||
&conn,
|
||||
fid,
|
||||
@@ -301,8 +337,8 @@ mod tests {
|
||||
#[test]
|
||||
fn imports_roundtrip_and_importers() {
|
||||
let conn = mem();
|
||||
let a = upsert_file(&conn, "a.ts", "h", "typescript", 0).unwrap();
|
||||
let b = upsert_file(&conn, "b.ts", "h", "typescript", 0).unwrap();
|
||||
let a = upsert_file(&conn, "a.ts", "h", "typescript").unwrap();
|
||||
let b = upsert_file(&conn, "b.ts", "h", "typescript").unwrap();
|
||||
insert_import(&conn, a, b, "import").unwrap();
|
||||
let imports = all_imports(&conn).unwrap();
|
||||
assert_eq!(imports.len(), 1);
|
||||
|
||||
+21
-4
@@ -33,15 +33,25 @@ fn run() -> Result<()> {
|
||||
Command::Index { sub } => match sub {
|
||||
IndexCommand::Create { path } => emit(&ops::index_create(path)?, &cli)?,
|
||||
IndexCommand::Update { path } => emit(&ops::index_update(path)?, &cli)?,
|
||||
IndexCommand::File { file } => emit(&ops::index_file(&cli.project, file)?, &cli)?,
|
||||
},
|
||||
Command::Search { sub } => match sub {
|
||||
SearchCommand::Symbols { query } => {
|
||||
emit(&ops::search_symbols(&cli.project, query)?, &cli)?
|
||||
SearchCommand::Symbols {
|
||||
query,
|
||||
include_source,
|
||||
limit,
|
||||
} => emit(
|
||||
&ops::search_symbols(&cli.project, query, *include_source, *limit)?,
|
||||
&cli,
|
||||
)?,
|
||||
SearchCommand::Text { query, limit } => {
|
||||
emit(&ops::search_text(&cli.project, query, *limit)?, &cli)?
|
||||
}
|
||||
SearchCommand::Text { query } => emit(&ops::search_text(&cli.project, query)?, &cli)?,
|
||||
},
|
||||
Command::Inspect { sub } => match sub {
|
||||
InspectCommand::Symbol { id } => emit(&ops::inspect_symbol(&cli.project, *id)?, &cli)?,
|
||||
InspectCommand::Symbol { target } => {
|
||||
emit(&ops::inspect_symbol(&cli.project, target)?, &cli)?
|
||||
}
|
||||
InspectCommand::Outline { file } => {
|
||||
emit(&ops::inspect_outline(&cli.project, file)?, &cli)?
|
||||
}
|
||||
@@ -63,6 +73,13 @@ fn run() -> Result<()> {
|
||||
emit(&ops::find_importers(&cli.project, file)?, &cli)?
|
||||
}
|
||||
FindCommand::Dead => emit(&ops::find_dead(&cli.project)?, &cli)?,
|
||||
FindCommand::DeadSymbols => emit(&ops::find_dead_symbols(&cli.project)?, &cli)?,
|
||||
FindCommand::Callers { target } => {
|
||||
emit(&ops::find_callers(&cli.project, target)?, &cli)?
|
||||
}
|
||||
FindCommand::References { identifier } => {
|
||||
emit(&ops::find_references(&cli.project, identifier)?, &cli)?
|
||||
}
|
||||
},
|
||||
Command::Serve => mcp::serve(&cli.project)?,
|
||||
Command::Stats => emit(&ops::stats(&cli.project)?, &cli)?,
|
||||
|
||||
+107
-33
@@ -123,20 +123,39 @@ fn dispatch(name: &str, args: &Value, project: &Path) -> Result<Value> {
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("{tool}: missing or invalid argument '{key}'"))
|
||||
}
|
||||
fn req_i64(args: &Value, key: &str, tool: &str) -> Result<i64> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("{tool}: missing or invalid argument '{key}'"))
|
||||
}
|
||||
let opt_str = |k: &str| args.get(k).and_then(|v| v.as_str());
|
||||
let opt_depth = |k: &str| args.get(k).and_then(|v| v.as_u64()).map(|d| d as usize);
|
||||
|
||||
match name {
|
||||
"index_create" => ops::index_create(Path::new(req_str(args, "path", name)?)),
|
||||
"index_update" => ops::index_update(Path::new(req_str(args, "path", name)?)),
|
||||
"search_symbols" => ops::search_symbols(project, req_str(args, "query", name)?),
|
||||
"search_text" => ops::search_text(project, req_str(args, "query", name)?),
|
||||
"inspect_symbol" => ops::inspect_symbol(project, req_i64(args, "id", name)?),
|
||||
"index_file" => ops::index_file(project, req_str(args, "path", name)?),
|
||||
"search_symbols" => ops::search_symbols(
|
||||
project,
|
||||
req_str(args, "query", name)?,
|
||||
args.get("include_source")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
args.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(200),
|
||||
),
|
||||
"search_text" => ops::search_text(
|
||||
project,
|
||||
req_str(args, "query", name)?,
|
||||
args.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(100),
|
||||
),
|
||||
"inspect_symbol" => {
|
||||
if let Some(id) = args.get("id").and_then(|v| v.as_i64()) {
|
||||
ops::inspect_symbol(project, &id.to_string())
|
||||
} else {
|
||||
ops::inspect_symbol(project, req_str(args, "name", name)?)
|
||||
}
|
||||
}
|
||||
"inspect_outline" => ops::inspect_outline(project, req_str(args, "file", name)?),
|
||||
"inspect_file" => {
|
||||
ops::inspect_file(project, req_str(args, "file", name)?, opt_str("lines"))
|
||||
@@ -144,6 +163,15 @@ fn dispatch(name: &str, args: &Value, project: &Path) -> Result<Value> {
|
||||
"inspect_tree" => ops::inspect_tree(project, opt_depth("depth")),
|
||||
"find_importers" => ops::find_importers(project, req_str(args, "file", name)?),
|
||||
"find_dead" => ops::find_dead(project),
|
||||
"find_dead_symbols" => ops::find_dead_symbols(project),
|
||||
"find_callers" => {
|
||||
if let Some(id) = args.get("id").and_then(|v| v.as_i64()) {
|
||||
ops::find_callers(project, &id.to_string())
|
||||
} else {
|
||||
ops::find_callers(project, req_str(args, "name", name)?)
|
||||
}
|
||||
}
|
||||
"find_references" => ops::find_references(project, req_str(args, "identifier", name)?),
|
||||
"inspect_cycles" => ops::inspect_cycles(project),
|
||||
"inspect_blast_radius" => {
|
||||
ops::inspect_blast_radius(project, req_str(args, "file", name)?, opt_depth("depth"))
|
||||
@@ -157,7 +185,7 @@ fn tools() -> Value {
|
||||
json!([
|
||||
tool(
|
||||
"index_create",
|
||||
"Index a project from scratch",
|
||||
"Index a project from scratch: scan source files, extract symbols via ctags and imports, and write a SQLite index. Run before any other tool on a new project, or to rebuild a stale index.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string", "description": "Project directory to index" } },
|
||||
@@ -166,103 +194,149 @@ fn tools() -> Value {
|
||||
),
|
||||
tool(
|
||||
"index_update",
|
||||
"Re-index only changed files",
|
||||
"Re-index only files whose content hash changed since the last index. Faster than index_create for incremental refreshes after edits.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string" } },
|
||||
"properties": { "path": { "type": "string", "description": "Project directory to refresh" } },
|
||||
"required": ["path"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"index_file",
|
||||
"Re-index a single file (path relative to the indexed project, or absolute). Use after editing one file to refresh its symbols and imports without a full index_update.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string", "description": "File to re-index, relative to the indexed project (or absolute)" } },
|
||||
"required": ["path"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"search_symbols",
|
||||
"Search symbols by name",
|
||||
"Search symbols (functions, classes, methods, structs, etc.) by name. Matches exact, prefix, then full-text; each hit carries id, kind, file, and line range. Set include_source to fetch the symbol's source inline, avoiding a separate inspect_symbol call.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } },
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Symbol name or partial name to search" },
|
||||
"include_source": {
|
||||
"type": "boolean",
|
||||
"description": "Include each symbol's source inline (default false)"
|
||||
},
|
||||
"limit": { "type": "integer", "description": "Maximum results (default 200)" }
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"search_text",
|
||||
"Full-text search over file contents",
|
||||
"Full-text search over file contents. Returns file, line number, and a snippet for every matching line. Use to find string literals, comments, or code fragments that are not indexed as symbols.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } },
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Text or phrase to search for" },
|
||||
"limit": { "type": "integer", "description": "Maximum matching files (default 100)" }
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"inspect_symbol",
|
||||
"Get the source code of a single symbol by id",
|
||||
"Get the full source code of a symbol by id (from search_symbols) or exact name. Names may be ambiguous; an id always resolves to one symbol.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "id": { "type": "integer" } },
|
||||
"required": ["id"]
|
||||
"properties": {
|
||||
"id": { "type": "integer", "description": "Symbol id from search_symbols (unambiguous)" },
|
||||
"name": { "type": "string", "description": "Exact symbol name (alternative to id; may match multiple)" }
|
||||
}
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"inspect_outline",
|
||||
"Get the symbol outline of a file",
|
||||
"Get the symbol outline of a file: names, kinds, signatures, and line ranges for every top-level symbol. Use to orient yourself in a file without reading it whole.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "file": { "type": "string" } },
|
||||
"properties": { "file": { "type": "string", "description": "Path relative to the indexed project" } },
|
||||
"required": ["file"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"inspect_file",
|
||||
"Read a file or a line range",
|
||||
"Read a file or a line range from the indexed project. Prefer this over reading whole files.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": { "type": "string" },
|
||||
"lines": { "type": "string", "description": "Optional range, e.g. 10-40" }
|
||||
"file": { "type": "string", "description": "Path relative to the indexed project" },
|
||||
"lines": { "type": "string", "description": "Optional 1-based inclusive range, e.g. 10-40" }
|
||||
},
|
||||
"required": ["file"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"inspect_tree",
|
||||
"Show the indexed file tree",
|
||||
"Show the indexed file tree (directories and files). Use to understand project layout.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "depth": { "type": "integer" } }
|
||||
"properties": { "depth": { "type": "integer", "description": "Maximum tree depth" } }
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"find_importers",
|
||||
"List files that import the given file",
|
||||
"List files that import the given file. Use to see who depends on a module before changing it.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "file": { "type": "string" } },
|
||||
"properties": { "file": { "type": "string", "description": "Path relative to the indexed project" } },
|
||||
"required": ["file"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"find_dead",
|
||||
"List files with no importers",
|
||||
"List files with no importers (entry-point files like main/lib are excluded). Use to spot orphaned or unreferenced modules.",
|
||||
json!({ "type": "object" })
|
||||
),
|
||||
tool(
|
||||
"find_dead_symbols",
|
||||
"List symbols never referenced by name outside their own definition. Heuristic: framework-wired symbols (routes, test functions) may appear here because they are invoked by convention, not by name. Use to find genuinely unused functions and methods.",
|
||||
json!({ "type": "object" })
|
||||
),
|
||||
tool(
|
||||
"find_callers",
|
||||
"Find symbols that call the given symbol (by id or exact name). Searches the symbol's own file plus transitively dependent files.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer", "description": "Symbol id from search_symbols" },
|
||||
"name": { "type": "string", "description": "Exact symbol name (alternative to id)" }
|
||||
}
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"find_references",
|
||||
"Find every line where an identifier occurs as a whole word, across indexed files. Use for rename impact analysis.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "identifier": { "type": "string", "description": "Identifier or module name to search for" } },
|
||||
"required": ["identifier"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"inspect_cycles",
|
||||
"Find circular imports",
|
||||
"Find circular imports (dependency cycles) between files. Use to diagnose build or import problems.",
|
||||
json!({ "type": "object" })
|
||||
),
|
||||
tool(
|
||||
"inspect_blast_radius",
|
||||
"Files transitively affected by a change",
|
||||
"List files transitively affected by a change to a given file (reverse dependency graph). Use before refactoring to gauge impact.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": { "type": "string" },
|
||||
"depth": { "type": "integer" }
|
||||
"file": { "type": "string", "description": "Path relative to the indexed project" },
|
||||
"depth": { "type": "integer", "description": "Maximum traversal depth" }
|
||||
},
|
||||
"required": ["file"]
|
||||
})
|
||||
),
|
||||
tool(
|
||||
"stats",
|
||||
"Show index statistics",
|
||||
"Show index statistics: file, symbol, and import counts, plus language breakdown.",
|
||||
json!({ "type": "object" })
|
||||
),
|
||||
])
|
||||
|
||||
+556
-45
@@ -1,6 +1,7 @@
|
||||
//! High-level operations shared by the CLI and the MCP server.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::BufRead;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
@@ -27,24 +28,113 @@ pub fn index_update(project: &Path) -> Result<Value> {
|
||||
Ok(serde_json::to_value(stats)?)
|
||||
}
|
||||
|
||||
// ---- search ----
|
||||
|
||||
pub fn search_symbols(project: &Path, query: &str) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
Ok(serde_json::to_value(search::search_symbols(&conn, query)?)?)
|
||||
pub fn index_file(project: &Path, file: &str) -> Result<Value> {
|
||||
let db = config::index_db_path(project);
|
||||
let stats = pipeline::run_file(project, &db, file)?;
|
||||
Ok(serde_json::to_value(stats)?)
|
||||
}
|
||||
|
||||
pub fn search_text(project: &Path, query: &str) -> Result<Value> {
|
||||
// ---- search ----
|
||||
|
||||
pub fn search_symbols(
|
||||
project: &Path,
|
||||
query: &str,
|
||||
include_source: bool,
|
||||
limit: usize,
|
||||
) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
Ok(serde_json::to_value(search::search_text(&conn, query)?)?)
|
||||
let hits = search::search_symbols(&conn, query, limit)?;
|
||||
if !include_source {
|
||||
return Ok(serde_json::to_value(hits)?);
|
||||
}
|
||||
let values: Vec<Value> = hits
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let source = read_lines(project, &hit.file, hit.line_start, hit.line_end)?;
|
||||
Ok(serde_json::json!({
|
||||
"id": hit.id,
|
||||
"name": hit.name,
|
||||
"qualified_name": hit.qualified_name,
|
||||
"kind": hit.kind,
|
||||
"signature": hit.signature,
|
||||
"file": hit.file,
|
||||
"line_start": hit.line_start,
|
||||
"line_end": hit.line_end,
|
||||
"match_type": hit.match_type,
|
||||
"source": source,
|
||||
}))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
Ok(serde_json::json!(values))
|
||||
}
|
||||
|
||||
/// Full-text search over the live contents of indexed files.
|
||||
///
|
||||
/// Reads files from disk (not the `content_fts` snapshot) so edits are visible
|
||||
/// immediately, consistent with `inspect file`/`inspect symbol`. `limit` caps
|
||||
/// the number of files that contribute matches; within a file, identical
|
||||
/// snippets are collapsed to the first occurrence to reduce noise.
|
||||
pub fn search_text(project: &Path, query: &str, limit: usize) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
let files = store::all_files(&conn)?;
|
||||
let mut out: Vec<search::TextHit> = Vec::new();
|
||||
let mut files_hit = 0usize;
|
||||
for f in files {
|
||||
if files_hit >= limit {
|
||||
break;
|
||||
}
|
||||
let path = project.join(&f.path);
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
// Collapse identical snippets within a file to the first occurrence.
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut contributed = false;
|
||||
for (line, snippet) in search::lines_matching(&content, q) {
|
||||
if !seen.insert(snippet.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(search::TextHit {
|
||||
file: f.path.clone(),
|
||||
line,
|
||||
snippet,
|
||||
});
|
||||
contributed = true;
|
||||
}
|
||||
if contributed {
|
||||
files_hit += 1;
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_value(out)?)
|
||||
}
|
||||
|
||||
// ---- inspect ----
|
||||
|
||||
pub fn inspect_symbol(project: &Path, id: i64) -> Result<Value> {
|
||||
/// `target` is either a numeric id or a symbol name (exact, case-insensitive).
|
||||
/// Id → single object; name → array of all exact-name matches (full source).
|
||||
pub fn inspect_symbol(project: &Path, target: &str) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let sym = store::symbol_by_id(&conn, id)?.with_context(|| format!("no symbol with id {id}"))?;
|
||||
let file = store::file_by_id(&conn, sym.file_id)?
|
||||
let syms = resolve_symbols(&conn, target)?;
|
||||
let objects: Vec<Value> = syms
|
||||
.iter()
|
||||
.map(|sym| symbol_object(project, &conn, sym))
|
||||
.collect::<Result<_>>()?;
|
||||
if target.trim().parse::<i64>().is_ok() {
|
||||
Ok(objects
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("resolve_symbols is non-empty"))
|
||||
} else {
|
||||
Ok(serde_json::json!(objects))
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_object(project: &Path, conn: &Connection, sym: &store::SymbolRow) -> Result<Value> {
|
||||
let file = store::file_by_id(conn, sym.file_id)?
|
||||
.with_context(|| format!("no file with id {}", sym.file_id))?;
|
||||
let source = read_lines(project, &file.path, sym.line_start, sym.line_end)?;
|
||||
Ok(serde_json::json!({
|
||||
@@ -60,6 +150,23 @@ pub fn inspect_symbol(project: &Path, id: i64) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve a CLI/MCP `target` to one or more symbols: numeric → id lookup,
|
||||
/// otherwise exact (case-insensitive) name match.
|
||||
fn resolve_symbols(conn: &Connection, target: &str) -> Result<Vec<store::SymbolRow>> {
|
||||
if let Ok(id) = target.trim().parse::<i64>() {
|
||||
let sym =
|
||||
store::symbol_by_id(conn, id)?.with_context(|| format!("no symbol with id {id}"))?;
|
||||
Ok(vec![sym])
|
||||
} else {
|
||||
let name = target.trim();
|
||||
let syms = store::symbols_by_name(conn, name)?;
|
||||
if syms.is_empty() {
|
||||
bail!("no symbol named {name:?}");
|
||||
}
|
||||
Ok(syms)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inspect_outline(project: &Path, file: &str) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let fid = file_id(&conn, file)?;
|
||||
@@ -70,28 +177,20 @@ pub fn inspect_outline(project: &Path, file: &str) -> Result<Value> {
|
||||
pub fn inspect_file(project: &Path, file: &str, lines: Option<&str>) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let fid = file_id(&conn, file)?;
|
||||
let frow = store::file_by_id(&conn, fid)?.unwrap();
|
||||
let (start, end) = parse_range(lines);
|
||||
let all = read_all_lines(project, &frow.path)?;
|
||||
let total = all.len() as i64;
|
||||
let (s, e) = (start.unwrap_or(1), end.unwrap_or(total));
|
||||
let slice = all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| {
|
||||
let n = (*i as i64) + 1;
|
||||
n >= s && n <= e
|
||||
})
|
||||
.map(|(_, l)| l.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let frow =
|
||||
store::file_by_id(&conn, fid)?.with_context(|| format!("file not indexed: {file}"))?;
|
||||
let range = parse_range(lines)?;
|
||||
let (s, e) = range.unwrap_or((1, None));
|
||||
// Stream the file once: count total lines, keep only the requested slice.
|
||||
let (total, content) = read_lines_slice(project, &frow.path, s, e.unwrap_or(i64::MAX))?;
|
||||
let line_end = e.unwrap_or(total).min(total);
|
||||
Ok(serde_json::json!({
|
||||
"file": frow.path,
|
||||
"language": frow.language,
|
||||
"line_start": s,
|
||||
"line_end": e.min(total),
|
||||
"line_end": line_end,
|
||||
"total_lines": total,
|
||||
"content": slice,
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -143,11 +242,131 @@ pub fn find_dead(project: &Path) -> Result<Value> {
|
||||
Ok(serde_json::json!({"count": out.len(), "files": out}))
|
||||
}
|
||||
|
||||
/// Symbols whose name never appears outside their own definition line.
|
||||
///
|
||||
/// A symbol is "dead" if, excluding import/declaration lines and its own
|
||||
/// definition, no indexed file contains its name as a whole word. Names are
|
||||
/// matched case-insensitively; same-name symbols are all kept live if any one
|
||||
/// reference exists, so this errs toward under-reporting rather than false
|
||||
/// positives. Includes `kind` so callers can filter out structural kinds
|
||||
/// (fields, variants) whose names legitimately appear only via qualified paths.
|
||||
pub fn find_dead_symbols(project: &Path) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let symbols = store::all_symbols(&conn)?;
|
||||
let files = store::all_files(&conn)?;
|
||||
let path_of: HashMap<i64, &str> = files.iter().map(|f| (f.id, f.path.as_str())).collect();
|
||||
|
||||
let mut by_name: BTreeMap<String, Vec<&store::SymbolRow>> = BTreeMap::new();
|
||||
for s in &symbols {
|
||||
by_name.entry(s.name.to_lowercase()).or_default().push(s);
|
||||
}
|
||||
|
||||
let mut line_cache: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut dead: Vec<Value> = Vec::new();
|
||||
|
||||
for (name_lc, syms) in &by_name {
|
||||
// Reference sites for this name across every file that FTS says
|
||||
// contains it (a superset), narrowed by whole-word match.
|
||||
let mut sites: Vec<(String, i64)> = Vec::new();
|
||||
for rel in search::files_containing(&conn, name_lc, 100_000)? {
|
||||
let lines = match line_cache.entry(rel.clone()) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.get().clone(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => e
|
||||
.insert(read_all_lines(project, &rel).unwrap_or_default())
|
||||
.clone(),
|
||||
};
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if word_matches_ci(line, name_lc) && !is_import_line(line) {
|
||||
sites.push((rel.clone(), (i as i64) + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in syms {
|
||||
let own = path_of.get(&s.file_id).copied().unwrap_or("");
|
||||
let referenced = sites
|
||||
.iter()
|
||||
.any(|(rel, line)| rel != own || *line != s.line_start);
|
||||
if !referenced && !is_wired_entry_point(project, own, s) {
|
||||
dead.push(serde_json::json!({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"qualified_name": s.qualified_name,
|
||||
"kind": s.kind,
|
||||
"file": own,
|
||||
"line": s.line_start,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
dead.sort_by(|a, b| a["kind"].as_str().cmp(&b["kind"].as_str()));
|
||||
Ok(serde_json::json!({"count": dead.len(), "symbols": dead}))
|
||||
}
|
||||
|
||||
/// Whole-word, case-insensitive match (Python/Rust names may vary in case).
|
||||
fn word_matches_ci(line: &str, word: &str) -> bool {
|
||||
line.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.any(|tok| tok.eq_ignore_ascii_case(word))
|
||||
}
|
||||
|
||||
/// Decorator keywords that wire a symbol to an external framework. Such symbols
|
||||
/// are referenced by convention/framework, not by a name call-site, so a pure
|
||||
/// name-scan wrongly flags them dead.
|
||||
const WIRED_DECORATORS: &[&str] = &[
|
||||
"pytest",
|
||||
"fixture",
|
||||
"route",
|
||||
"router.",
|
||||
"@app.",
|
||||
"@bot.",
|
||||
"@commands.",
|
||||
"@cog.",
|
||||
"celery",
|
||||
"click.",
|
||||
"typer",
|
||||
"shared_task",
|
||||
"receiver",
|
||||
"signal",
|
||||
"listener",
|
||||
];
|
||||
|
||||
/// A symbol is externally wired if it is a pytest `test_*` function or its
|
||||
/// definition is preceded by a framework decorator (Flask/FastAPI route,
|
||||
/// pytest fixture, Celery task, click/typer command, discord.py command, …).
|
||||
fn is_wired_entry_point(project: &Path, own: &str, s: &store::SymbolRow) -> bool {
|
||||
if matches!(s.kind.as_str(), "function" | "method") && s.name.starts_with("test_") {
|
||||
return true;
|
||||
}
|
||||
let Ok(lines) = read_all_lines(project, own) else {
|
||||
return false;
|
||||
};
|
||||
let start = (s.line_start as usize).saturating_sub(1);
|
||||
let lo = start.saturating_sub(8);
|
||||
for line in lines[lo.min(lines.len())..start.min(lines.len())].iter() {
|
||||
let l = line.trim();
|
||||
let low = l.to_ascii_lowercase();
|
||||
if l.starts_with('@') && WIRED_DECORATORS.iter().any(|k| low.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
// Rust attribute-wired tests: `#[test]`, `#[tokio::test]`, `#[rstest]`, …
|
||||
if l.starts_with("#[") && low.contains("test") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Entry-point files are legitimately never imported by anything else.
|
||||
///
|
||||
/// Only treat `main`/`lib` stems as entry points for languages where that is
|
||||
/// an actual convention — previously a `lib.py`, `lib.c` or `main.ts` would be
|
||||
/// silently hidden from `find dead`. `lib` is a Rust-only convention; `main` is
|
||||
/// meaningful for Rust, C/C++ and Python.
|
||||
fn is_entry_point(path: &str, language: &str) -> bool {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let stem = name.split('.').next().unwrap_or(name);
|
||||
if matches!(stem, "main" | "lib") {
|
||||
let is_main = stem == "main" && matches!(language, "rust" | "c" | "cpp" | "python");
|
||||
let is_lib = stem == "lib" && language == "rust";
|
||||
if is_main || is_lib {
|
||||
return true;
|
||||
}
|
||||
// Cargo compiles every file in these dirs as its own binary/crate.
|
||||
@@ -160,6 +379,225 @@ fn is_entry_point(path: &str, language: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Symbols that reference the given symbol: own file plus the import blast
|
||||
/// radius, narrowed by whole-word occurrence of the symbol name, attributed to
|
||||
/// the innermost enclosing symbol. `target` is a numeric id or an exact
|
||||
/// (case-insensitive) name; a name matches all symbols that share it.
|
||||
pub fn find_callers(project: &Path, target: &str) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let syms = resolve_symbols(&conn, target)?;
|
||||
let name = syms[0].name.clone();
|
||||
let callers = callers_of(project, &conn, &syms)?;
|
||||
let mut resp = serde_json::json!({
|
||||
"symbol": name,
|
||||
"count": callers.len(),
|
||||
"callers": callers,
|
||||
});
|
||||
if target.trim().parse::<i64>().is_err() && syms.len() > 1 {
|
||||
resp["matched"] = serde_json::json!(syms.len());
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Core caller scan over a set of symbols (same-name overloads share work).
|
||||
fn callers_of(project: &Path, conn: &Connection, syms: &[store::SymbolRow]) -> Result<Vec<Value>> {
|
||||
let files = store::all_files(conn)?;
|
||||
let imports = store::all_imports(conn)?;
|
||||
let graph = Graph::from_imports(&imports);
|
||||
|
||||
let mut seen: HashSet<(String, Option<i64>)> = HashSet::new();
|
||||
let mut callers: Vec<Value> = Vec::new();
|
||||
for sym in syms {
|
||||
// Candidates: the symbol's own file (intra-file calls) plus files that
|
||||
// (transitively) import it.
|
||||
let mut candidates = vec![sym.file_id];
|
||||
candidates.extend(
|
||||
graph
|
||||
.blast_radius(sym.file_id, 3)
|
||||
.into_iter()
|
||||
.map(|(id, _)| id),
|
||||
);
|
||||
for fid in candidates {
|
||||
let Some(f) = files.iter().find(|f| f.id == fid) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(lines) = read_all_lines(project, &f.path) else {
|
||||
continue;
|
||||
};
|
||||
let file_syms = store::symbols_for_file(conn, fid)?;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !search::word_matches(line, &sym.name) {
|
||||
continue;
|
||||
}
|
||||
// Imports/declarations reference the symbol but are not call sites.
|
||||
if is_import_line(line) {
|
||||
continue;
|
||||
}
|
||||
let lineno = (i as i64) + 1;
|
||||
let enclosing = enclosing_symbol(&file_syms, lineno);
|
||||
// Skip the symbol's own definition (and any self-recursion).
|
||||
if enclosing.map(|s| s.id) == Some(sym.id) {
|
||||
continue;
|
||||
}
|
||||
if !seen.insert((f.path.clone(), enclosing.map(|s| s.id))) {
|
||||
continue;
|
||||
}
|
||||
callers.push(serde_json::json!({
|
||||
"file": f.path,
|
||||
"line": lineno,
|
||||
"name": enclosing.map(|s| s.name.clone()),
|
||||
"kind": enclosing.map(|s| s.kind.clone()),
|
||||
"id": enclosing.map(|s| s.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(callers)
|
||||
}
|
||||
|
||||
/// Every line where `identifier` occurs as a whole word, across indexed files.
|
||||
/// FTS narrows candidates (a superset — tokenization may over-include); the
|
||||
/// per-line word match then yields exact line numbers.
|
||||
pub fn find_references(project: &Path, identifier: &str) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let id = identifier.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(serde_json::json!({"count": 0, "references": []}));
|
||||
}
|
||||
// Definition sites (file, line) for any symbol sharing this name — these are
|
||||
// declarations, not references, and would otherwise show up as noise.
|
||||
let mut defs: HashSet<(String, i64)> = HashSet::new();
|
||||
for s in store::symbols_by_name(&conn, id)? {
|
||||
if let Some(f) = store::file_by_id(&conn, s.file_id)? {
|
||||
defs.insert((f.path, s.line_start));
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
for rel in search::files_containing(&conn, id, 100_000)? {
|
||||
let Ok(lines) = read_all_lines(project, &rel) else {
|
||||
continue;
|
||||
};
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let line_no = (i as i64) + 1;
|
||||
if defs.contains(&(rel.clone(), line_no)) {
|
||||
continue;
|
||||
}
|
||||
let code = code_only_line(line);
|
||||
if search::word_matches(&code, id) {
|
||||
out.push(serde_json::json!({
|
||||
"file": rel,
|
||||
"line": line_no,
|
||||
"text": line.trim().to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({"count": out.len(), "references": out}))
|
||||
}
|
||||
|
||||
/// Line with string literals and comments blanked so a subsequent whole-word
|
||||
/// match only hits real code references. Multi-line docstrings/block comments
|
||||
/// are handled line-by-line as a heuristic.
|
||||
fn code_only_line(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//")
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with("--")
|
||||
|| trimmed.starts_with("/*")
|
||||
|| trimmed.starts_with("'''")
|
||||
|| trimmed.starts_with("\"\"\"")
|
||||
{
|
||||
return String::new();
|
||||
}
|
||||
let bytes = line.as_bytes();
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i] as char;
|
||||
if c == '/' && bytes.get(i + 1) == Some(&b'/') {
|
||||
break; // line comment
|
||||
}
|
||||
if c == '/' && bytes.get(i + 1) == Some(&b'*') {
|
||||
match line[i + 2..].find("*/") {
|
||||
Some(end) => {
|
||||
i += 2 + end + 2;
|
||||
out.push(' ');
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if c == '"' || c == '\'' || c == '`' {
|
||||
let ch = c;
|
||||
// Rust raw string `r###"..."###`: the `#` run before the quote is part
|
||||
// of the delimiter, so the closing marker is `"###`.
|
||||
let mut hashes = 0usize;
|
||||
let mut j = i;
|
||||
while j > 0 && bytes[j - 1] == b'#' {
|
||||
hashes += 1;
|
||||
j -= 1;
|
||||
}
|
||||
if ch == '"' && hashes > 0 {
|
||||
let delim = format!("\"{}", "#".repeat(hashes));
|
||||
match line[i + 1..].find(&delim) {
|
||||
Some(end) => {
|
||||
i += 1 + end + delim.len();
|
||||
out.push(' ');
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let triple = line[i..].starts_with(&format!("{ch}{ch}{ch}"));
|
||||
let close = if triple {
|
||||
line[i + 3..]
|
||||
.find(&format!("{ch}{ch}{ch}"))
|
||||
.map(|p| i + 3 + p + 3)
|
||||
} else {
|
||||
line[i + 1..].find(ch).map(|p| i + 1 + p + 1)
|
||||
};
|
||||
match close {
|
||||
Some(end) => {
|
||||
i = end;
|
||||
out.push(' ');
|
||||
continue;
|
||||
}
|
||||
None => break, // unterminated string: rest is string
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True if `line` is an import/declaration (a reference, not a call site).
|
||||
fn is_import_line(line: &str) -> bool {
|
||||
let l = line.trim_start();
|
||||
[
|
||||
"use ",
|
||||
"pub use ",
|
||||
"pub(crate) use ",
|
||||
"pub(super) use ",
|
||||
"extern crate ",
|
||||
"import ",
|
||||
"from ",
|
||||
"#include",
|
||||
"using ",
|
||||
"mod ",
|
||||
"type ",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| l.starts_with(p))
|
||||
}
|
||||
|
||||
/// Innermost symbol (largest line_start) whose span contains `line`.
|
||||
fn enclosing_symbol(syms: &[store::SymbolRow], line: i64) -> Option<&store::SymbolRow> {
|
||||
syms.iter()
|
||||
.filter(|s| s.line_start <= line && line <= s.line_end)
|
||||
.max_by_key(|s| s.line_start)
|
||||
}
|
||||
|
||||
pub fn inspect_cycles(project: &Path) -> Result<Value> {
|
||||
let conn = open_read(project)?;
|
||||
let files = store::all_files(&conn)?;
|
||||
@@ -232,17 +670,7 @@ fn file_id(conn: &Connection, path: &str) -> Result<i64> {
|
||||
}
|
||||
|
||||
fn read_lines(project: &Path, rel: &str, start: i64, end: i64) -> Result<String> {
|
||||
let all = read_all_lines(project, rel)?;
|
||||
Ok(all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| {
|
||||
let n = (*i as i64) + 1;
|
||||
n >= start && n <= end
|
||||
})
|
||||
.map(|(_, l)| l.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"))
|
||||
Ok(read_lines_slice(project, rel, start, end)?.1)
|
||||
}
|
||||
|
||||
fn read_all_lines(project: &Path, rel: &str) -> Result<Vec<String>> {
|
||||
@@ -252,12 +680,70 @@ fn read_all_lines(project: &Path, rel: &str) -> Result<Vec<String>> {
|
||||
Ok(content.lines().map(|l| l.to_string()).collect())
|
||||
}
|
||||
|
||||
fn parse_range(lines: Option<&str>) -> (Option<i64>, Option<i64>) {
|
||||
let Some(s) = lines else { return (None, None) };
|
||||
/// Stream `rel`, returning the total line count and lines `[start, end]`
|
||||
/// (1-based, inclusive) joined. Reads the file once without materializing every
|
||||
/// line.
|
||||
fn read_lines_slice(project: &Path, rel: &str, start: i64, end: i64) -> Result<(i64, String)> {
|
||||
let path = project.join(rel);
|
||||
let file =
|
||||
std::fs::File::open(&path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut collected: Vec<String> = Vec::new();
|
||||
let mut total = 0i64;
|
||||
for line in reader.lines() {
|
||||
let line = line.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
total += 1;
|
||||
if total >= start && total <= end {
|
||||
collected.push(line);
|
||||
}
|
||||
}
|
||||
Ok((total, collected.join("\n")))
|
||||
}
|
||||
|
||||
/// Parse and validate a `--lines` range. `None` → whole file. Accepts `a`
|
||||
/// (line a to end) or `a-b`. Rejects non-numeric or reversed ranges.
|
||||
fn parse_range(lines: Option<&str>) -> Result<Option<(i64, Option<i64>)>> {
|
||||
let Some(s) = lines else { return Ok(None) };
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
bail!("empty line range");
|
||||
}
|
||||
let parts: Vec<&str> = s.split('-').collect();
|
||||
let start = parts.first().and_then(|p| p.trim().parse::<i64>().ok());
|
||||
let end = parts.get(1).and_then(|p| p.trim().parse::<i64>().ok());
|
||||
(start, end)
|
||||
let (start, end) = match parts.as_slice() {
|
||||
[a] => {
|
||||
let start = a
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.with_context(|| format!("invalid line: {a}"))?;
|
||||
(start, None)
|
||||
}
|
||||
[a, b] => {
|
||||
let start = a
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.with_context(|| format!("invalid range start: {a}"))?;
|
||||
let end = if b.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
b.trim()
|
||||
.parse::<i64>()
|
||||
.with_context(|| format!("invalid range end: {b}"))?,
|
||||
)
|
||||
};
|
||||
(start, end)
|
||||
}
|
||||
_ => bail!("invalid line range: {s} (expected 'start-end')"),
|
||||
};
|
||||
if start < 1 {
|
||||
bail!("line range start must be >= 1: {s}");
|
||||
}
|
||||
if let Some(end) = end
|
||||
&& end < start
|
||||
{
|
||||
bail!("line range end must be >= start: {s}");
|
||||
}
|
||||
Ok(Some((start, end)))
|
||||
}
|
||||
|
||||
// ---- tree ----
|
||||
@@ -339,3 +825,28 @@ fn raw_to_nodes(node: &RawNode, base: &str, depth: Option<usize>) -> Vec<TreeNod
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_entry_point;
|
||||
|
||||
#[test]
|
||||
fn entry_point_is_language_scoped() {
|
||||
// Rust: main.rs and lib.rs are entry points.
|
||||
assert!(is_entry_point("src/main.rs", "rust"));
|
||||
assert!(is_entry_point("src/lib.rs", "rust"));
|
||||
// C/C++ and Python: `main` is a convention, `lib` is not.
|
||||
assert!(is_entry_point("main.c", "c"));
|
||||
assert!(is_entry_point("main.cpp", "cpp"));
|
||||
assert!(is_entry_point("main.py", "python"));
|
||||
// `lib` outside Rust is just a module/file named lib — reportable dead.
|
||||
assert!(!is_entry_point("lib.py", "python"));
|
||||
assert!(!is_entry_point("lib.c", "c"));
|
||||
// `main` is not a convention for JS/TS — reportable dead.
|
||||
assert!(!is_entry_point("main.ts", "typescript"));
|
||||
assert!(!is_entry_point("lib.ts", "typescript"));
|
||||
// Rust integration-test / example files are entry points regardless of name.
|
||||
assert!(is_entry_point("tests/integration.rs", "rust"));
|
||||
assert!(is_entry_point("src/bin/tool.rs", "rust"));
|
||||
}
|
||||
}
|
||||
+82
-26
@@ -23,13 +23,14 @@ pub struct SymbolHit {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TextHit {
|
||||
pub file: String,
|
||||
pub line: i64,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
const SYMBOL_COLS: &str =
|
||||
"s.id, s.name, s.qualified_name, s.kind, s.signature, f.path, s.line_start, s.line_end";
|
||||
|
||||
pub fn search_symbols(conn: &Connection, query: &str) -> Result<Vec<SymbolHit>> {
|
||||
pub fn search_symbols(conn: &Connection, query: &str, limit: usize) -> Result<Vec<SymbolHit>> {
|
||||
let q = query.trim();
|
||||
let mut seen: HashSet<i64> = HashSet::new();
|
||||
let mut out: Vec<SymbolHit> = Vec::new();
|
||||
@@ -41,10 +42,10 @@ pub fn search_symbols(conn: &Connection, query: &str) -> Result<Vec<SymbolHit>>
|
||||
{
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \
|
||||
WHERE s.name = ?1 COLLATE NOCASE ORDER BY s.id LIMIT 200"
|
||||
WHERE s.name = ?1 COLLATE NOCASE ORDER BY s.id LIMIT ?2"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![q], |r| row_to_hit(r, "exact"))?;
|
||||
push_hits(&mut out, &mut seen, rows)?;
|
||||
let rows = stmt.query_map(params![q, limit as i64], |r| row_to_hit(r, "exact"))?;
|
||||
push_hits(&mut out, &mut seen, rows, limit)?;
|
||||
}
|
||||
|
||||
// 2) prefix
|
||||
@@ -52,10 +53,10 @@ pub fn search_symbols(conn: &Connection, query: &str) -> Result<Vec<SymbolHit>>
|
||||
let pat = format!("{}%", q);
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \
|
||||
WHERE s.name LIKE ?1 COLLATE NOCASE ORDER BY s.id LIMIT 200"
|
||||
WHERE s.name LIKE ?1 COLLATE NOCASE ORDER BY s.id LIMIT ?2"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![pat], |r| row_to_hit(r, "prefix"))?;
|
||||
push_hits(&mut out, &mut seen, rows)?;
|
||||
let rows = stmt.query_map(params![pat, limit as i64], |r| row_to_hit(r, "prefix"))?;
|
||||
push_hits(&mut out, &mut seen, rows, limit)?;
|
||||
}
|
||||
|
||||
// 3) fulltext over name/qualified_name/kind/signature
|
||||
@@ -65,32 +66,49 @@ pub fn search_symbols(conn: &Connection, query: &str) -> Result<Vec<SymbolHit>>
|
||||
"SELECT {SYMBOL_COLS} FROM symbols_fts \
|
||||
JOIN symbols s ON s.id = symbols_fts.rowid \
|
||||
JOIN files f ON f.id = s.file_id \
|
||||
WHERE symbols_fts MATCH ?1 ORDER BY rank LIMIT 200"
|
||||
WHERE symbols_fts MATCH ?1 ORDER BY rank LIMIT ?2"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![phrase], |r| row_to_hit(r, "fulltext"))?;
|
||||
push_hits(&mut out, &mut seen, rows)?;
|
||||
let rows = stmt.query_map(params![phrase, limit as i64], |r| row_to_hit(r, "fulltext"))?;
|
||||
push_hits(&mut out, &mut seen, rows, limit)?;
|
||||
}
|
||||
|
||||
// 4) substring fallback — catches typos and mid-word queries
|
||||
{
|
||||
let pat = format!("%{}%", q);
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \
|
||||
WHERE s.name LIKE ?1 COLLATE NOCASE ORDER BY s.id LIMIT ?2"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![pat, limit as i64], |r| row_to_hit(r, "substring"))?;
|
||||
push_hits(&mut out, &mut seen, rows, limit)?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn search_text(conn: &Connection, query: &str) -> Result<Vec<TextHit>> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
/// Report `(line_number, trimmed_line)` for every line containing the query.
|
||||
/// A line matches if it contains the whole query (case-insensitive) or, for a
|
||||
/// multi-word query, all of its terms.
|
||||
pub fn lines_matching(content: &str, q: &str) -> Vec<(i64, String)> {
|
||||
let needle = q.to_lowercase();
|
||||
let terms: Vec<&str> = needle.split_whitespace().collect();
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let l = line.to_lowercase();
|
||||
if l.contains(&needle) || terms.iter().all(|t| l.contains(t)) {
|
||||
out.push(((i + 1) as i64, clip_line(line.trim())));
|
||||
}
|
||||
}
|
||||
let phrase = fts_phrase(q);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT path, snippet(content_fts, 1, '«', '»', '…', 24) \
|
||||
FROM content_fts WHERE content_fts MATCH ?1 ORDER BY rank LIMIT 100",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![phrase], |r| {
|
||||
Ok(TextHit {
|
||||
file: r.get(0)?,
|
||||
snippet: r.get(1)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
out
|
||||
}
|
||||
|
||||
/// Trim a snippet line to a readable length.
|
||||
fn clip_line(s: &str) -> String {
|
||||
let mut out: String = s.chars().take(200).collect();
|
||||
if s.chars().count() > 200 {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn row_to_hit(
|
||||
@@ -114,8 +132,12 @@ fn push_hits(
|
||||
out: &mut Vec<SymbolHit>,
|
||||
seen: &mut HashSet<i64>,
|
||||
rows: impl Iterator<Item = std::result::Result<SymbolHit, rusqlite::Error>>,
|
||||
max: usize,
|
||||
) -> Result<()> {
|
||||
for r in rows {
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
let hit = r?;
|
||||
if seen.insert(hit.id) {
|
||||
out.push(hit);
|
||||
@@ -128,3 +150,37 @@ fn push_hits(
|
||||
fn fts_phrase(q: &str) -> String {
|
||||
format!("\"{}\"", q.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// Whole-word (identifier) match. `_` is treated as part of an identifier, so
|
||||
/// `authenticate` matches `authenticate` and `auth::authenticate`, but not
|
||||
/// `authenticate_user`.
|
||||
pub fn word_matches(line: &str, word: &str) -> bool {
|
||||
line.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.any(|tok| tok == word)
|
||||
}
|
||||
|
||||
/// Relative paths of files whose content contains the phrase (FTS superset).
|
||||
/// Tokenization may over-include (e.g. `authenticate_user` contains an
|
||||
/// `authenticate` token); callers refine with [`word_matches`] for exactness.
|
||||
pub fn files_containing(conn: &Connection, query: &str, limit: usize) -> Result<Vec<String>> {
|
||||
let phrase = fts_phrase(query);
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT DISTINCT path FROM content_fts WHERE content_fts MATCH ?1 LIMIT ?2")?;
|
||||
let rows = stmt.query_map(params![phrase, limit as i64], |r| r.get(0))?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::word_matches;
|
||||
|
||||
#[test]
|
||||
fn word_matches_is_identifier_aware() {
|
||||
assert!(word_matches("auth::authenticate(token)", "authenticate"));
|
||||
assert!(word_matches("self.authenticate()", "authenticate"));
|
||||
assert!(word_matches("let authenticate = 1;", "authenticate"));
|
||||
assert!(!word_matches("authenticate_user()", "authenticate"));
|
||||
assert!(!word_matches("reauthenticate()", "authenticate"));
|
||||
assert!(!word_matches("", "authenticate"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user