From 5ae8de978e1c4e6c0dfce2c83e3c0c3b9d13f2a9 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Mon, 3 Aug 2026 10:54:58 +0700 Subject: [PATCH] fix(code-intel): FTS5 search, dead-code false positives, index skip files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues: #451 #452 #453 #453 — Fix should_skip_file glob handling & sticky skip patterns - Rewrite should_skip_file() with 4 glob pattern types - Add schema v6 migration: index_config_hash column on projects - Add index_project_with_skip() with config hash invalidation - Wire config loading into cora index command #451 — Fix FTS5 returning 0 results for camelCase queries - Recreate FTS5 virtual table in v6 migration to add file column - Add split_camel_case() for identifier decomposition - Enhance sanitize_fts_query() with OR expansion of sub-words - Update FTS5 insert triggers to include file path - 12 new tests for camelCase splitting and FTS query expansion #452 — Reduce dead-code detection false positives - Add FRAMEWORK_ENTRY_PREFIXES (30+ framework lifecycle names) - Add FRAMEWORK_ENTRY_SUFFIXES (12 common suffixes) - Add SQL exclusion for // cora: keep suppression markers - Add AnalysisConfig with entry_point_patterns config knob - Wire analysis config into cora dead-code CLI command - Post-filter respects user-configured patterns --- src/config/schema.rs | 25 +++++++ src/engine/index_scanner.rs | 142 +++++++++++++++++++++++++++++++----- src/index/graph.rs | 94 +++++++++++++++++++++++- src/index/mod.rs | 73 ++++++++++++++++-- src/index/schema.rs | 120 +++++++++++++++++++++++++++++- src/index/symbols.rs | 132 ++++++++++++++++++++++++++++++--- src/main.rs | 21 +++++- src/mcp/tools.rs | 1 + 8 files changed, 565 insertions(+), 43 deletions(-) diff --git a/src/config/schema.rs b/src/config/schema.rs index a536f92..bedbe4d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -53,6 +53,9 @@ pub struct Config { pub debt: crate::engine::debt_tracker::DebtConfig, /// Active quality profile (resolved from .cora.yaml). pub profile: Option, + /// Analysis configuration — dead-code detection, entry-point patterns. + #[serde(default, skip_serializing_if = "is_default")] + pub analysis: AnalysisConfig, } /// Provider configuration. @@ -137,6 +140,7 @@ impl Default for Config { bundling: BundlingConfig::default(), debt: crate::engine::debt_tracker::DebtConfig::default(), profile: None, + analysis: AnalysisConfig::default(), } } } @@ -300,6 +304,8 @@ pub struct CoraFile { pub debt: Option, #[serde(default)] pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub analysis: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -398,6 +404,16 @@ pub struct StaticAnalysisConfig { pub clippy_output_file: Option, } +/// Analysis configuration — controls dead-code detection and symbol search. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct AnalysisConfig { + /// Custom entry-point method patterns to exclude from dead-code detection. + /// Supports glob-like patterns: `prefix*`, `*suffix`, `*substring*`. + /// Example: `["*Handler", "resolve_*", "*Middleware"]` + #[serde(default, skip_serializing_if = "is_default")] + pub entry_point_patterns: Vec, +} + fn is_default(val: &T) -> bool { *val == T::default() } @@ -635,6 +651,15 @@ impl CoraFile { } } } + // Merge analysis config + if let Some(analysis) = &self.analysis { + if !analysis.entry_point_patterns.is_empty() { + config + .analysis + .entry_point_patterns + .clone_from(&analysis.entry_point_patterns); + } + } Ok(()) } } diff --git a/src/engine/index_scanner.rs b/src/engine/index_scanner.rs index 9649e54..f051391 100644 --- a/src/engine/index_scanner.rs +++ b/src/engine/index_scanner.rs @@ -13,8 +13,14 @@ use crate::engine::rules::types::RuleFinding; use crate::index::graph; /// Check if a file path matches any of the skip patterns. -/// Uses simple glob matching (* and **) against both the full path and the basename. -/// Pattern "src/main.ts" matches exactly, "*.config.ts" matches any filename ending in .config.ts. +/// Supports simple glob patterns: +/// - Exact: `"src/main.ts"` → full path match +/// - Suffix: `"*.config.ts"` → basename ends with `.config.ts` +/// - Prefix: `"vite.config.*"` → basename starts with `vite.config.` +/// - Any-dir name: `"**/something"` → basename or path suffix match +/// - Any-dir wildcard: `"**/phaser/**"` → any path component equals `phaser` +/// - Prefix-dir: `"src/engine/**"` → file under `src/engine/` +/// - Double wildcard ext: `"**/*.test.ts"` → basename ends with `.test.ts` pub fn should_skip_file(file_path: &str, skip_patterns: &[String]) -> bool { if skip_patterns.is_empty() { return false; @@ -34,27 +40,60 @@ pub fn should_skip_file(file_path: &str, skip_patterns: &[String]) -> bool { if basename == *pattern { return true; } - // Simple glob: pattern contains * - if pattern.contains('*') { - // Convert simple glob to a basic match: - // "*.config.ts" → check if basename ends with ".config.ts" - // "vite.config.*" → check if basename starts with "vite.config." - if pattern.starts_with("*.") { - let suffix = &pattern[1..]; // ".config.ts" - if basename.ends_with(suffix) { - return true; - } - } else if pattern.ends_with(".*") { - let prefix = &pattern[..pattern.len() - 2]; // "vite.config" - if basename.starts_with(prefix) { + if !pattern.contains('*') { + continue; + } + + // "**/*.ext" → suffix match on basename (any directory) + if let Some(rest) = pattern.strip_prefix("**/") { + if let Some(ext) = rest.strip_prefix("*.") { + if basename.ends_with(ext) { return true; } } - // "**/something" → match basename - if let Some(rest) = pattern.strip_prefix("**/") { - if basename == rest || file_path.ends_with(&format!("/{}", rest)) { - return true; - } + } + + // "*.ext" → suffix match on basename + if pattern.starts_with("*.") { + let suffix = &pattern[1..]; // ".config.ts" + if basename.ends_with(suffix) { + return true; + } + } + + // "name.*" → prefix match on basename + if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 2]; // "vite.config" + if basename.starts_with(prefix) { + return true; + } + } + + // "**/name/**" → any path component equals "name" + if let Some(dir) = pattern + .strip_prefix("**/") + .and_then(|s| s.strip_suffix("/**")) + { + let components: Vec<&str> = file_path.split('/').collect(); + if components.contains(&dir) { + return true; + } + } + + // "dir/**" → file under dir/ + if let Some(dir) = pattern.strip_suffix("/**") { + if file_path.starts_with(&format!("{dir}/")) || file_path == dir { + return true; + } + } + + // "**/name" → match basename or path suffix + if let Some(rest) = pattern.strip_prefix("**/") { + if rest.contains('*') { + continue; + } + if basename == rest || file_path.ends_with(&format!("/{rest}")) { + return true; } } } @@ -614,4 +653,67 @@ mod tests { assert!(!should_skip_file("src/utils.ts", &defaults)); assert!(!should_skip_file("src/components/Button.tsx", &defaults)); } + + // --- New glob pattern tests (#453) --- + + #[test] + fn skip_doublestar_dir_doublestar() { + // "**/phaser/**" → match any path containing "phaser" as a component + let patterns = vec!["**/phaser/**".into()]; + assert!(should_skip_file( + "src/scenes/phaser/GameScene.ts", + &patterns + )); + assert!(should_skip_file("phaser/GameScene.ts", &patterns)); + assert!(should_skip_file("src/phaser/utils.ts", &patterns)); + assert!(!should_skip_file("src/utils/phaserHelper.ts", &patterns)); + assert!(!should_skip_file("src/app.ts", &patterns)); + } + + #[test] + fn skip_prefix_dir_doublestar() { + // "src/engine/**" → files under src/engine/ + let patterns = vec!["src/engine/**".into()]; + assert!(should_skip_file("src/engine/physics.rs", &patterns)); + assert!(should_skip_file("src/engine/core/mod.rs", &patterns)); + assert!(!should_skip_file("src/app/engine.rs", &patterns)); + } + + #[test] + fn skip_doublestar_wildcard_ext() { + // "**/*.test.ts" → any .test.ts file anywhere + let patterns = vec!["**/*.test.ts".into()]; + assert!(should_skip_file("src/utils/helpers.test.ts", &patterns)); + assert!(should_skip_file("tests/integration.test.ts", &patterns)); + assert!(!should_skip_file("src/utils/helpers.ts", &patterns)); + assert!(!should_skip_file("src/utils/helpers.test.js", &patterns)); + } + + #[test] + fn skip_nested_doublestar_dir() { + // "**/node_modules/**" → match nested node_modules + let patterns = vec!["**/node_modules/**".into()]; + assert!(should_skip_file("node_modules/lodash/index.js", &patterns)); + assert!(should_skip_file( + "packages/app/node_modules/lodash/index.js", + &patterns + )); + assert!(!should_skip_file("src/node_modulesHelper.ts", &patterns)); + } + + #[test] + fn skip_multiple_patterns_any_match() { + let patterns = vec![ + "**/phaser/**".into(), + "*.config.ts".into(), + "src/main.ts".into(), + ]; + assert!(should_skip_file( + "src/scenes/phaser/GameScene.ts", + &patterns + )); + assert!(should_skip_file("vite.config.ts", &patterns)); + assert!(should_skip_file("src/main.ts", &patterns)); + assert!(!should_skip_file("src/lib.rs", &patterns)); + } } diff --git a/src/index/graph.rs b/src/index/graph.rs index e70f769..baa33a3 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -599,6 +599,10 @@ pub struct DeadCodeOptions { pub include_tests: bool, /// If set, only report symbols whose span is at least this many lines long. pub min_lines: Option, + /// Additional entry-point method name patterns to exclude from dead-code. + /// Each entry is a SQL LIKE pattern (e.g. `%Handler`, `%Listener`, `%Route`). + /// These are checked against symbol names in addition to WELL_KNOWN_NAMES. + pub entry_point_patterns: Vec, } /// Well-known symbol names that should not be flagged as dead code even when @@ -706,6 +710,56 @@ const WELL_KNOWN_NAMES: &[&str] = &[ "duration_since", ]; +/// Framework lifecycle / entry-point method name prefixes. +/// +/// Methods starting with these prefixes are typically called by frameworks +/// (Axum route handlers, SvelteKit page loads, Phaser scene lifecycle, etc.) +/// and may not have direct callers in the codebase. +const FRAMEWORK_ENTRY_PREFIXES: &[&str] = &[ + // Axum / Actix / Tower handler patterns + "handle_", + // SvelteKit page/layout exports + "load", + // Phaser scene lifecycle + "create", + "preload", + "update", + // React / Svelte lifecycle + "render", + "component_did_mount", + "on_mount", + "on_destroy", + // General async patterns + "handle", + "process", + "execute", + "serve", + "start", + "stop", + "run", + // GraphQL resolvers + "resolve_", +]; + +/// Patterns that indicate a method is a framework callback (suffix-based). +const FRAMEWORK_ENTRY_SUFFIXES: &[&str] = &[ + // Axum handler trait + "Handler", + // Event listeners + "Listener", + "Callback", + // Middleware + "Middleware", + // Route + "Route", + // Hook + "Hook", + // Plugin + "Plugin", + // Provider + "Provider", +]; + /// Find dead code: symbols that have no recorded callers and are not /// well-known names or (by default) test functions. /// @@ -744,6 +798,22 @@ pub fn find_dead_code( sql.push_str(" AND s.name NOT LIKE 'test_%' AND s.name NOT LIKE '%_test'"); } + // Exclude symbols with `// cora: keep` suppression marker in their signature. + sql.push_str(" AND (s.signature IS NULL OR s.signature NOT LIKE '%cora: keep%')"); + + // Exclude framework entry-point methods by name prefix. + for prefix in FRAMEWORK_ENTRY_PREFIXES { + sql.push_str(&format!(" AND LOWER(s.name) NOT LIKE '{prefix}%'")); + } + + // Exclude framework entry-point methods by name suffix. + for suffix in FRAMEWORK_ENTRY_SUFFIXES { + sql.push_str(&format!(" AND s.name NOT LIKE '%{suffix}'")); + } + + // Exclude user-configured entry-point patterns via post-filter below. + let _ = &opts.entry_point_patterns; // patterns applied in post-filter + // Optional min_lines filter — applied post-query below when span metadata // becomes available. Kept for API compatibility. if opts.min_lines.is_some() { @@ -782,7 +852,28 @@ pub fn find_dead_code( }) })?; - Ok(rows.filter_map(|r| r.ok()).collect()) + // Post-filter by user-configured entry_point_patterns (safe LIKE matching). + let results: Vec = rows + .filter_map(|r| r.ok()) + .filter(|r| { + // Skip if any user pattern matches + let name_lower = r.name.to_lowercase(); + !opts.entry_point_patterns.iter().any(|p| { + // Simple glob-like matching: prefix*, *suffix, or exact + if p.starts_with('*') && p.ends_with('*') && p.len() > 2 { + name_lower.contains(&p[1..p.len() - 1].to_lowercase()) + } else if let Some(suffix) = p.strip_prefix('*') { + name_lower.ends_with(&suffix.to_lowercase()) + } else if let Some(prefix) = p.strip_suffix('*') { + name_lower.starts_with(&prefix.to_lowercase()) + } else { + name_lower == p.to_lowercase() + } + }) + }) + .collect(); + + Ok(results) } /// Result of unused import analysis for a single file. @@ -1249,6 +1340,7 @@ mod tests { &DeadCodeOptions { include_tests: true, min_lines: None, + entry_point_patterns: vec![], }, ) .unwrap(); diff --git a/src/index/mod.rs b/src/index/mod.rs index a8fa0f8..8b08428 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -249,7 +249,64 @@ fn load_all_fingerprints( /// /// Returns summary stats. pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::Result { + index_project_with_skip(conn, root, verbose, None) +} + +/// Index a project directory, with config hash invalidation. +/// +/// If `skip_patterns` is provided, the config hash is compared against +/// the stored hash in the DB. If they differ, all fingerprints are +/// cleared, forcing a full re-index. +pub fn index_project_with_skip( + conn: &Connection, + root: &Path, + verbose: bool, + skip_patterns: Option<&[String]>, +) -> anyhow::Result { let project_id = ensure_project(conn, root)?; + + // Config hash invalidation: if skip_patterns changed, clear fingerprints + if let Some(patterns) = skip_patterns { + let new_hash = schema::compute_index_config_hash(patterns); + let stored_hash: Option = conn + .query_row( + "SELECT index_config_hash FROM projects WHERE id = ?1", + rusqlite::params![project_id], + |row| row.get(0), + ) + .ok() + .flatten(); + + if stored_hash.as_deref() != Some(&new_hash) { + if verbose { + eprintln!(" ↻ Config changed — invalidating fingerprints for full re-index"); + } + // Clear all fingerprints for this project to force re-index + conn.execute( + "DELETE FROM files WHERE project_id = ?1", + rusqlite::params![project_id], + )?; + // Also clear symbols/edges so they're rebuilt from scratch + conn.execute( + "DELETE FROM symbols WHERE project_id = ?1", + rusqlite::params![project_id], + )?; + conn.execute( + "DELETE FROM edges WHERE project_id = ?1", + rusqlite::params![project_id], + )?; + conn.execute( + "DELETE FROM call_graph WHERE project_id = ?1", + rusqlite::params![project_id], + )?; + // Store the new config hash + conn.execute( + "UPDATE projects SET index_config_hash = ?1 WHERE id = ?2", + rusqlite::params![new_hash, project_id], + )?; + } + } + index_project_with_id(conn, project_id, root, verbose) } @@ -382,24 +439,24 @@ fn index_project_with_id( "CREATE TRIGGER IF NOT EXISTS symbols_fts_insert AFTER INSERT ON symbols BEGIN - INSERT INTO symbols_fts(rowid, name, signature) - VALUES (new.id, new.name, new.signature); + INSERT INTO symbols_fts(rowid, name, signature, file) + VALUES (new.id, new.name, new.signature, new.file); END; CREATE TRIGGER IF NOT EXISTS symbols_fts_delete AFTER DELETE ON symbols BEGIN - INSERT INTO symbols_fts(symbols_fts, rowid, name, signature) - VALUES ('delete', old.id, old.name, old.signature); + INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, file) + VALUES ('delete', old.id, old.name, old.signature, old.file); END; CREATE TRIGGER IF NOT EXISTS symbols_fts_update AFTER UPDATE ON symbols BEGIN - INSERT INTO symbols_fts(symbols_fts, rowid, name, signature) - VALUES ('delete', old.id, old.name, old.signature); - INSERT INTO symbols_fts(rowid, name, signature) - VALUES (new.id, new.name, new.signature); + INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, file) + VALUES ('delete', old.id, old.name, old.signature, old.file); + INSERT INTO symbols_fts(rowid, name, signature, file) + VALUES (new.id, new.name, new.signature, new.file); END;", )?; diff --git a/src/index/schema.rs b/src/index/schema.rs index 2722dec..b7b716e 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -4,7 +4,7 @@ use rusqlite::Connection; /// Current schema version. #[allow(dead_code)] -const SCHEMA_VERSION: i32 = 5; +const SCHEMA_VERSION: i32 = 6; /// Run database migrations (creates tables if not exist). pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -37,6 +37,9 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { if current < 5 { migrate_v5(conn)?; } + if current < 6 { + migrate_v6(conn)?; + } Ok(()) } @@ -286,6 +289,76 @@ fn migrate_v5(conn: &Connection) -> anyhow::Result<()> { Ok(()) } +/// Migration v6: Index config hash for fingerprint invalidation. +/// +/// Adds `index_config_hash` to `projects`. When config-relevant settings +/// (e.g. `index_skip_files`, language includes) change between runs, the +/// stored hash won't match and all file fingerprints are cleared, forcing +/// a full re-index on the next `cora index` run. +fn migrate_v6(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + ALTER TABLE projects ADD COLUMN index_config_hash TEXT; + ", + )?; + + // Recreate FTS5 with file column for better search coverage. + // Drop old triggers first, then virtual table, then recreate with file column. + conn.execute_batch( + " + DROP TRIGGER IF EXISTS symbols_fts_insert; + DROP TRIGGER IF EXISTS symbols_fts_delete; + DROP TRIGGER IF EXISTS symbols_fts_update; + DROP TABLE IF EXISTS symbols_fts; + CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5( + name, + signature, + file, + content='symbols', + content_rowid='id', + tokenize='unicode61 remove_diacritics 1' + ); + CREATE TRIGGER IF NOT EXISTS symbols_fts_insert + AFTER INSERT ON symbols + BEGIN + INSERT INTO symbols_fts(rowid, name, signature, file) + VALUES (new.id, new.name, new.signature, new.file); + END; + CREATE TRIGGER IF NOT EXISTS symbols_fts_delete + AFTER DELETE ON symbols + BEGIN + INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, file) + VALUES ('delete', old.id, old.name, old.signature, old.file); + END; + CREATE TRIGGER IF NOT EXISTS symbols_fts_update + AFTER UPDATE ON symbols + BEGIN + INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, file) + VALUES ('delete', old.id, old.name, old.signature, old.file); + INSERT INTO symbols_fts(rowid, name, signature, file) + VALUES (new.id, new.name, new.signature, new.file); + END; + ", + )?; + + conn.execute("INSERT INTO schema_version (version) VALUES (6)", [])?; + + Ok(()) +} + +/// Compute a stable hash of the indexing-relevant config. +/// +/// Any change to these fields will invalidate all stored fingerprints, +/// forcing a full re-index on the next run. +pub fn compute_index_config_hash(skip_patterns: &[String]) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + skip_patterns.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + /// Get or create a project entry by root path. /// /// Returns the project ID. @@ -571,7 +644,7 @@ mod tests { }) .unwrap(); assert_eq!(version, SCHEMA_VERSION); - assert_eq!(version, 5); + assert_eq!(version, 6); } #[test] @@ -629,4 +702,47 @@ mod tests { .unwrap(); assert_eq!(event_count, 1); } + + #[test] + fn test_v6_migration_adds_config_hash_column() { + let conn = mem_conn(); + + // Verify index_config_hash column exists on projects + let hash: Option = conn + .query_row( + "SELECT index_config_hash FROM projects WHERE 1=0", + [], + |row| row.get(0), + ) + .ok() + .flatten(); + // No rows → None, but the column should exist without error + assert!(hash.is_none()); + } + + #[test] + fn test_compute_index_config_hash_deterministic() { + let patterns1 = vec!["**/vendor/**".to_string(), "*.min.js".to_string()]; + let patterns2 = vec!["**/vendor/**".to_string(), "*.min.js".to_string()]; + let patterns3 = vec!["**/node_modules/**".to_string()]; + + assert_eq!( + compute_index_config_hash(&patterns1), + compute_index_config_hash(&patterns2), + "same patterns → same hash" + ); + assert_ne!( + compute_index_config_hash(&patterns1), + compute_index_config_hash(&patterns3), + "different patterns → different hash" + ); + } + + #[test] + fn test_compute_index_config_hash_empty() { + let h1 = compute_index_config_hash(&[]); + let h2 = compute_index_config_hash(&[]); + assert_eq!(h1, h2, "empty patterns should be deterministic"); + assert!(!h1.is_empty(), "hash should not be empty"); + } } diff --git a/src/index/symbols.rs b/src/index/symbols.rs index de537aa..841ce23 100644 --- a/src/index/symbols.rs +++ b/src/index/symbols.rs @@ -308,24 +308,103 @@ fn filter_search( } /// Sanitize a user query for FTS5 MATCH syntax. -/// Wraps each token in quotes to prevent FTS5 syntax errors. +/// +/// Enhances search quality by: +/// - Splitting camelCase/PascalCase identifiers into sub-words +/// (e.g. "camelCase" → "camel" OR "Case") +/// - Lowercasing tokens (unicode61 is case-insensitive, but explicit is safer) +/// - Quoting tokens to prevent FTS5 syntax errors +/// +/// Output format: "camelCase" OR "camel" OR "Case" "otherToken" fn sanitize_fts_query(text: &str) -> String { - // Split on whitespace, quote each token, join with AND - text.split_whitespace() + let tokens: Vec = text + .split_whitespace() .map(|token| { let clean: String = token .chars() .filter(|c| c.is_alphanumeric() || *c == '_' || *c == ':') .collect(); - if clean.is_empty() { - String::new() - } else { - format!("\"{clean}\"") - } + clean }) .filter(|s| !s.is_empty()) - .collect::>() - .join(" ") + .collect(); + + let mut parts = Vec::new(); + + for token in &tokens { + // Try camelCase/PascalCase split + let subwords = split_camel_case(token); + + if subwords.len() > 1 { + // Original + subwords joined with OR for broader matching + let expanded: Vec = subwords.iter().map(|w| format!("\"{w}\"")).collect(); + // Also include the original token for exact matching + let all: Vec = std::iter::once(format!("\"{token}\"")) + .chain(expanded) + .collect(); + parts.push(all.join(" OR ")); + } else { + parts.push(format!("\"{token}\"")); + } + } + + parts.join(" ") +} + +/// Split a camelCase or PascalCase identifier into sub-words. +/// +/// "camelCase" → ["camel", "Case"] +/// "PascalCase" → ["Pascal", "Case"] +/// "XMLParser" → ["XML", "Parser"] +/// "simple" → ["simple"] +/// "snake_case" → ["snake_case"] (no split) +/// "HTTPResponse" → ["HTTP", "Response"] +fn split_camel_case(s: &str) -> Vec { + let mut words = Vec::new(); + let mut current = String::new(); + let chars: Vec = s.chars().collect(); + + if chars.is_empty() { + return words; + } + + for (i, &c) in chars.iter().enumerate() { + if c == '_' { + // Underscore boundary — keep as part of current word + current.push(c); + continue; + } + + if c.is_uppercase() { + // Check if we should split before this uppercase + let prev_is_lower = current + .chars() + .last() + .map(|p| p.is_lowercase()) + .unwrap_or(false); + let next_is_lower = chars.get(i + 1).map(|n| n.is_lowercase()).unwrap_or(false); + + if prev_is_lower || (next_is_lower && !current.is_empty()) { + // Split: lowercase→uppercase or "A" in "XMLParser" + if !current.is_empty() { + words.push(current.clone()); + current.clear(); + } + } + } + current.push(c); + } + + if !current.is_empty() { + words.push(current); + } + + // If no split happened, return the original + if words.len() <= 1 { + vec![s.to_string()] + } else { + words + } } #[cfg(test)] @@ -363,6 +442,23 @@ mod tests { "\"auth\" \"DROP\" \"TABLE\"" ); assert_eq!(sanitize_fts_query(""), ""); + // camelCase expansion + let q = sanitize_fts_query("camelCase"); + assert!(q.contains("\"camelCase\"")); + assert!(q.contains("\"camel\"")); + assert!(q.contains("\"Case\"")); + assert!(q.contains(" OR ")); + // PascalCase + let q = sanitize_fts_query("PascalCase"); + assert!(q.contains("\"PascalCase\"")); + assert!(q.contains("\"Pascal\"")); + assert!(q.contains("\"Case\"")); + // All caps (no split) + assert_eq!(sanitize_fts_query("HTTP"), "\"HTTP\""); + // Mixed: camelCase + plain token + let q = sanitize_fts_query("camelCase helper"); + assert!(q.contains("\"camel\"")); + assert!(q.contains("\"helper\"")); } #[test] @@ -378,4 +474,20 @@ mod tests { assert_eq!(q.kind, Some(SymbolKind::Function)); assert_eq!(q.limit, 50); } + + #[test] + fn test_split_camel_case() { + use super::split_camel_case; + + assert_eq!(split_camel_case("camelCase"), vec!["camel", "Case"]); + assert_eq!(split_camel_case("PascalCase"), vec!["Pascal", "Case"]); + assert_eq!(split_camel_case("XMLParser"), vec!["XML", "Parser"]); + assert_eq!(split_camel_case("simple"), vec!["simple"]); + assert_eq!(split_camel_case("snake_case"), vec!["snake_case"]); + assert_eq!(split_camel_case("HTTPResponse"), vec!["HTTP", "Response"]); + assert_eq!(split_camel_case("getURL"), vec!["get", "URL"]); + assert_eq!(split_camel_case("a"), vec!["a"]); + assert_eq!(split_camel_case(""), Vec::::new()); + assert_eq!(split_camel_case("ABTest"), vec!["AB", "Test"]); + } } diff --git a/src/main.rs b/src/main.rs index b6b9d62..cd33186 100644 --- a/src/main.rs +++ b/src/main.rs @@ -720,9 +720,19 @@ async fn main() -> Result<()> { } } } else { + // Load config for config-hash invalidation + let skip_patterns = + crate::config::loader::load_config(None, None, None, None, None, false) + .ok() + .map(|c| c.rules_config.index_skip_files); + eprintln!("{}", "🔍 Indexing project...".cyan()); - let stats = - index::index_project(&conn, &project_root, verbose || cli.global.verbose)?; + let stats = index::index_project_with_skip( + &conn, + &project_root, + verbose || cli.global.verbose, + skip_patterns.as_deref(), + )?; eprintln!( "{}", format!( @@ -1497,9 +1507,16 @@ async fn main() -> Result<()> { &conn, cwd.to_str().with_context(|| "invalid UTF-8 in cwd path")?, )?; + + // Load config for entry_point_patterns + let config = crate::config::loader::load_config(None, None, None, None, None, false) + .unwrap_or_default(); + let entry_point_patterns = config.analysis.entry_point_patterns.clone(); + let opts = index::graph::DeadCodeOptions { include_tests, min_lines, + entry_point_patterns, }; let results = index::graph::find_dead_code(&conn, project_id, &opts)?; if json { diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index cfc8df9..f4a527f 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1025,6 +1025,7 @@ fn handle_dead_code(params: &serde_json::Value) -> ToolResult { let opts = crate::index::graph::DeadCodeOptions { include_tests, min_lines, + entry_point_patterns: vec![], }; match crate::index::graph::find_dead_code(&conn, project_id, &opts) {