Skip to main content

krait/daemon/
server.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::Instant;
5
6use serde_json::json;
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tokio::net::{UnixListener, UnixStream};
9use tokio::sync::{watch, Mutex};
10use tracing::{debug, error, info};
11
12use crate::commands::{check, edit, find, fix, format as fmt, hover, list, read, rename};
13use crate::config::{self, ConfigSource};
14use crate::detect::{self, language_for_file, Language};
15use crate::index::builder;
16use crate::index::cache_query;
17use crate::index::store::IndexStore;
18use crate::index::watcher::{self, DirtyFiles};
19use crate::lsp::client::path_to_uri;
20use crate::lsp::diagnostics::DiagnosticStore;
21use crate::lsp::pool::LspMultiplexer;
22use crate::lsp::registry::{find_server, get_entry};
23use crate::lsp::router;
24use crate::protocol::{Request, Response};
25
26/// Grace period after daemon start during which "still indexing" errors are expected.
27const INDEXING_GRACE_PERIOD_SECS: u64 = 60;
28
29/// Shared daemon state accessible from all connection handlers.
30pub struct DaemonState {
31    pub start_time: Instant,
32    pub last_activity: Arc<Mutex<Instant>>,
33    pub project_root: PathBuf,
34    pub languages: Vec<Language>,
35    pub pool: Arc<LspMultiplexer>,
36    pub config_source: ConfigSource,
37    /// All discovered workspace roots (for workspace registry).
38    pub package_roots: Vec<(Language, PathBuf)>,
39    /// Persistent symbol index for cache-first queries.
40    pub index: std::sync::Mutex<Option<IndexStore>>,
41    /// In-memory set of files changed since last index (fed by file watcher).
42    pub dirty_files: DirtyFiles,
43    /// Whether the file watcher is running (determines BLAKE3 fallback behavior).
44    pub watcher_active: bool,
45    /// File watcher handle — kept alive by ownership; dropped on shutdown.
46    _watcher: Option<
47        notify_debouncer_full::Debouncer<
48            notify_debouncer_full::notify::RecommendedWatcher,
49            notify_debouncer_full::FileIdMap,
50        >,
51    >,
52    shutdown_tx: watch::Sender<bool>,
53    /// LSP diagnostic store — fed by `textDocument/publishDiagnostics` notifications.
54    pub diagnostic_store: Arc<DiagnosticStore>,
55}
56
57impl DaemonState {
58    fn new(
59        shutdown_tx: watch::Sender<bool>,
60        project_root: PathBuf,
61        languages: Vec<Language>,
62        package_roots: Vec<(Language, PathBuf)>,
63        config_source: ConfigSource,
64    ) -> Self {
65        let now = Instant::now();
66        let index = Self::open_index(&project_root);
67        let dirty_files = DirtyFiles::new();
68
69        let diagnostic_store = Arc::new(DiagnosticStore::new());
70        let pool = Arc::new(LspMultiplexer::new(
71            project_root.clone(),
72            package_roots.clone(),
73        ));
74        pool.set_diagnostic_store(Arc::clone(&diagnostic_store));
75
76        // Start file watcher — clears stale diagnostics when files change on disk
77        let extensions = language_extensions(&languages);
78        let watcher_result = watcher::start_watcher(
79            &project_root,
80            &extensions,
81            dirty_files.clone(),
82            Some(Arc::clone(&diagnostic_store)),
83        );
84        let (watcher_handle, watcher_active) = match watcher_result {
85            Ok(w) => (Some(w), true),
86            Err(e) => {
87                debug!("file watcher unavailable, using BLAKE3 fallback: {e}");
88                (None, false)
89            }
90        };
91
92        Self {
93            start_time: now,
94            last_activity: Arc::new(Mutex::new(now)),
95            pool,
96            project_root,
97            languages,
98            config_source,
99            package_roots,
100            index: std::sync::Mutex::new(index),
101            dirty_files,
102            watcher_active,
103            _watcher: watcher_handle,
104            shutdown_tx,
105            diagnostic_store,
106        }
107    }
108
109    /// Try to open the index DB if it exists. Auto-deletes corrupted databases.
110    fn open_index(project_root: &Path) -> Option<IndexStore> {
111        let db_path = project_root.join(".krait/index.db");
112        if !db_path.exists() {
113            return None;
114        }
115        match IndexStore::open(&db_path) {
116            Ok(store) => Some(store),
117            Err(e) => {
118                info!("index DB corrupted ({e}), deleting for fresh rebuild");
119                let _ = std::fs::remove_file(&db_path);
120                None
121            }
122        }
123    }
124
125    /// Re-open the index store (called after `handle_init` completes).
126    fn refresh_index(&self) {
127        if let Ok(mut guard) = self.index.lock() {
128            *guard = Self::open_index(&self.project_root);
129        }
130    }
131
132    /// Get the dirty files reference for cache queries.
133    ///
134    /// Returns `Some` only if the file watcher is active. When `None`,
135    /// cache queries fall back to per-file BLAKE3 hashing.
136    fn dirty_files_ref(&self) -> Option<&DirtyFiles> {
137        if self.watcher_active {
138            Some(&self.dirty_files)
139        } else {
140            None
141        }
142    }
143
144    async fn touch(&self) {
145        *self.last_activity.lock().await = Instant::now();
146    }
147
148    fn request_shutdown(&self) {
149        let _ = self.shutdown_tx.send(true);
150    }
151}
152
153/// Run the daemon's accept loop until shutdown is requested or idle timeout fires.
154///
155/// # Errors
156/// Returns an error if the UDS listener fails to bind.
157pub async fn run_server(
158    socket_path: &Path,
159    idle_timeout: std::time::Duration,
160    project_root: &Path,
161) -> anyhow::Result<()> {
162    let _ = std::fs::remove_file(socket_path);
163
164    let listener = UnixListener::bind(socket_path)?;
165    info!("daemon listening on {}", socket_path.display());
166
167    let languages = detect::detect_languages(project_root);
168
169    // Load config: krait.toml → .krait/config.toml → auto-detection
170    let loaded = config::load(project_root);
171    let config_source = loaded.source.clone();
172    let package_roots = if let Some(ref cfg) = loaded.config {
173        let roots = config::config_to_package_roots(cfg, project_root);
174        info!(
175            "config: {} ({} workspaces)",
176            loaded.source.label(),
177            roots.len()
178        );
179        roots
180    } else {
181        detect::find_package_roots(project_root)
182    };
183
184    if package_roots.len() > 1 {
185        if loaded.config.is_none() {
186            info!("monorepo detected: {} workspace roots", package_roots.len());
187        }
188        for (lang, root) in &package_roots {
189            debug!("  workspace: {lang}:{}", root.display());
190        }
191    } else if !package_roots.is_empty() {
192        info!("project: {} workspace root(s)", package_roots.len());
193    }
194
195    let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
196    let state = DaemonState::new(
197        shutdown_tx,
198        project_root.to_path_buf(),
199        languages,
200        package_roots,
201        config_source,
202    );
203    apply_pool_config(&state, &loaded, project_root);
204    let state = Arc::new(state);
205
206    // When config exists, boot one server per language in background.
207    if matches!(
208        state.config_source,
209        ConfigSource::KraitToml | ConfigSource::LegacyKraitToml
210    ) {
211        spawn_background_boot(Arc::clone(&state));
212    }
213
214    // Install SIGTERM handler for graceful shutdown
215    let state_for_sigterm = Arc::clone(&state);
216    tokio::spawn(async move {
217        if let Ok(mut sigterm) =
218            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
219        {
220            sigterm.recv().await;
221            info!("received SIGTERM, shutting down gracefully");
222            state_for_sigterm.pool.shutdown_all().await;
223            state_for_sigterm.request_shutdown();
224        }
225    });
226
227    loop {
228        let idle_deadline = {
229            let last = *state.last_activity.lock().await;
230            last + idle_timeout
231        };
232        let sleep_dur = idle_deadline.saturating_duration_since(Instant::now());
233
234        tokio::select! {
235            result = listener.accept() => {
236                match result {
237                    Ok((stream, _)) => {
238                        let state = Arc::clone(&state);
239                        tokio::spawn(async move {
240                            if let Err(e) = handle_connection(stream, &state).await {
241                                error!("connection error: {e}");
242                            }
243                        });
244                    }
245                    Err(e) => {
246                        error!("accept error: {e}");
247                    }
248                }
249            }
250            () = tokio::time::sleep(sleep_dur) => {
251                let last = *state.last_activity.lock().await;
252                if last.elapsed() >= idle_timeout {
253                    info!("idle timeout reached, shutting down");
254                    state.pool.shutdown_all().await;
255                    break;
256                }
257            }
258            _ = shutdown_rx.changed() => {
259                if *shutdown_rx.borrow() {
260                    info!("shutdown requested");
261                    state.pool.shutdown_all().await;
262                    break;
263                }
264            }
265        }
266    }
267
268    Ok(())
269}
270
271/// Apply config-driven pool settings (priority workspaces, max sessions).
272fn apply_pool_config(state: &DaemonState, loaded: &config::LoadedConfig, project_root: &Path) {
273    if let Some(ref cfg) = loaded.config {
274        if let Some(max) = cfg.max_active_sessions {
275            state.pool.set_max_lru_sessions(max);
276            info!("config: max_active_sessions={max}");
277        }
278        if let Some(max) = cfg.max_language_servers {
279            state.pool.set_max_language_servers(max);
280            info!("config: max_language_servers={max}");
281        }
282        if !cfg.primary_workspaces.is_empty() {
283            let priority: HashSet<PathBuf> = cfg
284                .primary_workspaces
285                .iter()
286                .map(|p| project_root.join(p))
287                .collect();
288            info!("config: {} primary workspaces", priority.len());
289            state.pool.set_priority_roots(priority);
290        }
291    }
292}
293
294/// Boot one server per language in the background so first query doesn't wait.
295///
296/// With per-language mutexes, each language boots truly concurrently —
297/// TypeScript and Go no longer block each other.
298#[allow(clippy::needless_pass_by_value)]
299fn spawn_background_boot(state: Arc<DaemonState>) {
300    let langs = state.pool.unique_languages();
301    let priority = state.pool.priority_roots();
302    info!("background boot: starting {} language servers", langs.len());
303
304    // Boot each language concurrently
305    for lang in langs {
306        let pool = Arc::clone(&state.pool);
307        tokio::spawn(async move {
308            match pool.get_or_start(lang).await {
309                Ok(mut guard) => {
310                    if let Err(e) = pool
311                        .attach_all_workspaces_with_guard(lang, &mut guard)
312                        .await
313                    {
314                        debug!("background boot: attach failed for {lang}: {e}");
315                    }
316                    info!("booted {lang}");
317                }
318                Err(e) => debug!("boot failed {lang}: {e}"),
319            }
320        });
321    }
322
323    // Pre-warm LRU priority roots
324    if !priority.is_empty() {
325        let pool = Arc::clone(&state.pool);
326        tokio::spawn(async move {
327            if let Err(e) = pool.warm_priority_roots().await {
328                debug!("priority warmup failed: {e}");
329            }
330        });
331    }
332}
333
334async fn handle_connection(mut stream: UnixStream, state: &DaemonState) -> anyhow::Result<()> {
335    state.touch().await;
336
337    let len = stream.read_u32().await?;
338    if len > crate::protocol::MAX_FRAME_SIZE {
339        anyhow::bail!("oversized frame: {len} bytes");
340    }
341    let mut buf = vec![0u8; len as usize];
342    stream.read_exact(&mut buf).await?;
343
344    let request: Request = serde_json::from_slice(&buf)?;
345    debug!("received request: {request:?}");
346
347    let response = dispatch(&request, state).await;
348
349    let response_bytes = serde_json::to_vec(&response)?;
350    let response_len = u32::try_from(response_bytes.len())?;
351    stream.write_u32(response_len).await?;
352    stream.write_all(&response_bytes).await?;
353    stream.flush().await?;
354
355    Ok(())
356}
357
358async fn dispatch(request: &Request, state: &DaemonState) -> Response {
359    match request {
360        Request::Status => build_status_response(state),
361        Request::DaemonStop => {
362            state.pool.shutdown_all().await;
363            state.request_shutdown();
364            Response::ok(json!({"message": "shutting down"}))
365        }
366        Request::Check { path, errors_only } => {
367            handle_check(path.as_deref(), *errors_only, state).await
368        }
369        Request::Init => handle_init(state).await,
370        Request::FindSymbol {
371            name,
372            path_filter,
373            src_only,
374            include_body,
375        } => {
376            handle_find_symbol(
377                name,
378                path_filter.as_deref(),
379                *src_only,
380                *include_body,
381                state,
382            )
383            .await
384        }
385        Request::FindRefs { name, with_symbol } => {
386            handle_find_refs(name, *with_symbol, state).await
387        }
388        Request::FindImpl { name } => handle_find_impl(name, state).await,
389        Request::ListSymbols { path, depth } => handle_list_symbols(path, *depth, state).await,
390        Request::ReadFile {
391            path,
392            from,
393            to,
394            max_lines,
395        } => handle_read_file(path, *from, *to, *max_lines, state),
396        Request::ReadSymbol {
397            name,
398            signature_only,
399            max_lines,
400            path_filter,
401            has_body,
402        } => {
403            handle_read_symbol(
404                name,
405                *signature_only,
406                *max_lines,
407                path_filter.as_deref(),
408                *has_body,
409                state,
410            )
411            .await
412        }
413        Request::EditReplace { symbol, code } => {
414            handle_edit(symbol, code, EditKind::Replace, state).await
415        }
416        Request::EditInsertAfter { symbol, code } => {
417            handle_edit(symbol, code, EditKind::InsertAfter, state).await
418        }
419        Request::EditInsertBefore { symbol, code } => {
420            handle_edit(symbol, code, EditKind::InsertBefore, state).await
421        }
422        Request::Hover { name } => handle_hover_cmd(name, state).await,
423        Request::Format { path } => handle_format_cmd(path, state).await,
424        Request::Rename { name, new_name } => handle_rename_cmd(name, new_name, state).await,
425        Request::Fix { path } => handle_fix_cmd(path.as_deref(), state).await,
426        Request::ServerStatus => handle_server_status(state),
427        Request::ServerRestart { language } => handle_server_restart(language, state).await,
428    }
429}
430
431fn handle_server_status(state: &DaemonState) -> Response {
432    let statuses = state.pool.status();
433    let items: Vec<serde_json::Value> = statuses
434        .iter()
435        .map(|s| {
436            json!({
437                "language": s.language,
438                "server": s.server_name,
439                "status": s.status,
440                "uptime_secs": s.uptime_secs,
441                "open_files": s.open_files,
442                "attached_folders": s.attached_folders,
443                "total_folders": s.total_folders,
444            })
445        })
446        .collect();
447    Response::ok(json!({"servers": items, "count": items.len()}))
448}
449
450async fn handle_server_restart(language: &str, state: &DaemonState) -> Response {
451    let Some(lang) = crate::config::parse_language(language) else {
452        return Response::err("unknown_language", format!("unknown language: {language}"));
453    };
454
455    match state.pool.restart_language(lang).await {
456        Ok(()) => {
457            let server_name = crate::lsp::registry::get_entry(lang)
458                .map_or_else(|| "unknown".to_string(), |e| e.binary_name.to_string());
459            Response::ok(json!({
460                "restarted": language,
461                "server_name": server_name,
462            }))
463        }
464        Err(e) => Response::err("restart_failed", e.to_string()),
465    }
466}
467
468/// Find symbol across all languages — runs each language concurrently.
469async fn handle_find_symbol(
470    name: &str,
471    path_filter: Option<&str>,
472    src_only: bool,
473    include_body: bool,
474    state: &DaemonState,
475) -> Response {
476    // Cache-first: try the index before touching LSP
477    if let Ok(guard) = state.index.lock() {
478        if let Some(ref store) = *guard {
479            if let Some(results) = cache_query::cached_find_symbol(
480                store,
481                name,
482                &state.project_root,
483                state.dirty_files_ref(),
484            ) {
485                debug!(
486                    "find_symbol cache hit for '{name}' ({} results)",
487                    results.len()
488                );
489                let filtered = postprocess_symbols(
490                    results,
491                    path_filter,
492                    src_only,
493                    include_body,
494                    &state.project_root,
495                );
496                return Response::ok(serde_json::to_value(filtered).unwrap_or_default());
497            }
498        }
499    }
500
501    let languages = state.pool.unique_languages();
502    if languages.is_empty() {
503        return Response::err("no_language", "No language detected in project");
504    }
505
506    // Query each language concurrently — per-language locks allow true parallelism
507    let handles: Vec<_> = languages
508        .iter()
509        .map(|lang| {
510            let pool = Arc::clone(&state.pool);
511            let name = name.to_string();
512            let project_root = state.project_root.clone();
513            let lang = *lang;
514            tokio::spawn(async move {
515                let mut guard = pool.get_or_start(lang).await?;
516                pool.attach_all_workspaces_with_guard(lang, &mut guard)
517                    .await?;
518                let session = guard
519                    .session_mut()
520                    .ok_or_else(|| anyhow::anyhow!("no session for {lang}"))?;
521                find::find_symbol(&name, &mut session.client, &project_root).await
522            })
523        })
524        .collect();
525
526    let mut all_results = Vec::new();
527    for (lang, handle) in languages.iter().zip(handles) {
528        match handle.await {
529            Ok(Ok(results)) => all_results.push(results),
530            Ok(Err(e)) => debug!("find_symbol failed for {lang}: {e}"),
531            Err(e) => debug!("find_symbol task panicked for {lang}: {e}"),
532        }
533    }
534
535    let merged = router::merge_symbol_results(all_results);
536    if merged.is_empty() {
537        let readiness = state.pool.readiness();
538        let uptime = state.start_time.elapsed().as_secs();
539        if uptime < INDEXING_GRACE_PERIOD_SECS && readiness.total > 0 && !readiness.is_all_ready() {
540            return Response::err_with_advice(
541                "indexing",
542                format!(
543                    "LSP servers still indexing ({}/{} ready, daemon uptime {}s)",
544                    readiness.ready, readiness.total, uptime
545                ),
546                "Wait a few seconds and try again, or run `krait daemon status`",
547            );
548        }
549
550        // LSP came up empty — fall back to text search for const exports and other
551        // identifiers that workspace/symbol doesn't index.
552        let name_owned = name.to_string();
553        let root = state.project_root.clone();
554        let fallback =
555            tokio::task::spawn_blocking(move || find::text_search_find_symbol(&name_owned, &root))
556                .await
557                .unwrap_or_default();
558
559        let filtered =
560            postprocess_symbols(fallback, path_filter, src_only, false, &state.project_root);
561        if filtered.is_empty() {
562            return Response::ok(json!([]));
563        }
564        let filtered = if include_body {
565            populate_bodies(filtered, &state.project_root)
566        } else {
567            filtered
568        };
569        Response::ok(serde_json::to_value(filtered).unwrap_or_default())
570    } else {
571        let filtered = postprocess_symbols(
572            merged,
573            path_filter,
574            src_only,
575            include_body,
576            &state.project_root,
577        );
578        Response::ok(serde_json::to_value(filtered).unwrap_or_default())
579    }
580}
581
582/// Find refs across all languages — runs each language concurrently.
583async fn handle_find_refs(name: &str, with_symbol: bool, state: &DaemonState) -> Response {
584    let languages = state.pool.unique_languages();
585    if languages.is_empty() {
586        return Response::err("no_language", "No language detected in project");
587    }
588
589    let handles: Vec<_> = languages
590        .iter()
591        .map(|lang| {
592            let pool = Arc::clone(&state.pool);
593            let name = name.to_string();
594            let project_root = state.project_root.clone();
595            let lang = *lang;
596            tokio::spawn(async move {
597                let mut guard = pool.get_or_start(lang).await?;
598                pool.attach_all_workspaces_with_guard(lang, &mut guard)
599                    .await?;
600                let session = guard
601                    .session_mut()
602                    .ok_or_else(|| anyhow::anyhow!("no session for {lang}"))?;
603                find::find_refs(
604                    &name,
605                    &mut session.client,
606                    &mut session.file_tracker,
607                    &project_root,
608                )
609                .await
610            })
611        })
612        .collect();
613
614    let mut all_results = Vec::new();
615    for (lang, handle) in languages.iter().zip(handles) {
616        match handle.await {
617            Ok(Ok(results)) => all_results.push(results),
618            Ok(Err(e)) => {
619                let msg = e.to_string();
620                if !msg.contains("not found") {
621                    debug!("find_refs failed for {lang}: {e}");
622                }
623            }
624            Err(e) => debug!("find_refs task panicked for {lang}: {e}"),
625        }
626    }
627
628    let merged = router::merge_reference_results(all_results);
629
630    // Check whether LSP returned any real call sites (non-definition references).
631    // Interface methods like `computeActions` often yield only type-level definitions
632    // (interface declaration, implementing method signatures) with zero runtime callers.
633    // In that case the text-search fallback is always worth running to find call sites
634    // that the LSP missed due to workspace boundaries or interface indirection.
635    let has_call_sites = merged.iter().any(|r| !r.is_definition);
636
637    if merged.is_empty() || !has_call_sites {
638        let readiness = state.pool.readiness();
639        let uptime = state.start_time.elapsed().as_secs();
640        if merged.is_empty()
641            && uptime < INDEXING_GRACE_PERIOD_SECS
642            && readiness.total > 0
643            && !readiness.is_all_ready()
644        {
645            return Response::err_with_advice(
646                "indexing",
647                format!(
648                    "LSP servers still indexing ({}/{} ready, daemon uptime {}s)",
649                    readiness.ready, readiness.total, uptime
650                ),
651                "Wait a few seconds and try again, or run `krait daemon status`",
652            );
653        }
654
655        // LSP found no call sites — run text search to find runtime callers.
656        // Merge with any LSP definition results already found.
657        let name_owned = name.to_string();
658        let root = state.project_root.clone();
659        let text_results =
660            tokio::task::spawn_blocking(move || find::text_search_find_refs(&name_owned, &root))
661                .await
662                .unwrap_or_default();
663
664        // Merge: keep LSP definition results + text search results, dedup by path:line
665        let mut combined = merged;
666        for r in text_results {
667            let already_present = combined
668                .iter()
669                .any(|m| m.path == r.path && m.line == r.line);
670            if !already_present {
671                combined.push(r);
672            }
673        }
674
675        if combined.is_empty() {
676            Response::err_with_advice(
677                "symbol_not_found",
678                format!("symbol '{name}' not found"),
679                "Check the symbol name and try again",
680            )
681        } else {
682            if with_symbol {
683                enrich_with_symbols(&mut combined, state).await;
684            }
685            Response::ok(serde_json::to_value(combined).unwrap_or_default())
686        }
687    } else {
688        let mut refs = merged;
689        if with_symbol {
690            enrich_with_symbols(&mut refs, state).await;
691        }
692        Response::ok(serde_json::to_value(refs).unwrap_or_default())
693    }
694}
695
696/// Apply path filter, src-only filter, and optional body population in one pass.
697fn postprocess_symbols(
698    symbols: Vec<find::SymbolMatch>,
699    path_filter: Option<&str>,
700    src_only: bool,
701    include_body: bool,
702    project_root: &Path,
703) -> Vec<find::SymbolMatch> {
704    let filtered = filter_by_path(symbols, path_filter);
705    let filtered = if src_only {
706        filter_src_only(filtered, project_root)
707    } else {
708        filtered
709    };
710    if include_body {
711        populate_bodies(filtered, project_root)
712    } else {
713        filtered
714    }
715}
716
717/// Filter a `Vec<SymbolMatch>` to entries whose path contains `substr`.
718/// Returns the original vec unchanged when `substr` is `None`.
719fn filter_by_path(symbols: Vec<find::SymbolMatch>, substr: Option<&str>) -> Vec<find::SymbolMatch> {
720    match substr {
721        None => symbols,
722        Some(s) => symbols.into_iter().filter(|m| m.path.contains(s)).collect(),
723    }
724}
725
726/// Populate the `body` field for each symbol by reading its source file.
727///
728/// Pure file I/O — no LSP needed. Works for all symbol kinds including
729/// `const` variables that `documentSymbol` doesn't surface.
730fn populate_bodies(symbols: Vec<find::SymbolMatch>, project_root: &Path) -> Vec<find::SymbolMatch> {
731    symbols
732        .into_iter()
733        .map(|mut m| {
734            let abs = project_root.join(&m.path);
735            m.body = find::extract_symbol_body(&abs, m.line);
736            m
737        })
738        .collect()
739}
740
741/// Filter out gitignored paths from symbol results.
742///
743/// Loads `.gitignore` (and `.git/info/exclude`) from `project_root` using the same
744/// rules as ripgrep / git. Paths that would be ignored by git are excluded.
745/// Falls back to keeping all results if no ignore file is found.
746fn filter_src_only(symbols: Vec<find::SymbolMatch>, project_root: &Path) -> Vec<find::SymbolMatch> {
747    use ignore::gitignore::GitignoreBuilder;
748
749    let mut builder = GitignoreBuilder::new(project_root);
750
751    // Root .gitignore
752    let root_ignore = project_root.join(".gitignore");
753    if root_ignore.exists() {
754        let _ = builder.add(&root_ignore);
755    }
756
757    // .git/info/exclude (per-repo excludes that aren't committed)
758    let git_exclude = project_root.join(".git/info/exclude");
759    if git_exclude.exists() {
760        let _ = builder.add(&git_exclude);
761    }
762
763    let Ok(gitignore) = builder.build() else {
764        return symbols;
765    };
766
767    symbols
768        .into_iter()
769        .filter(|m| {
770            let abs = project_root.join(&m.path);
771            !gitignore
772                .matched_path_or_any_parents(&abs, /* is_dir */ false)
773                .is_ignore()
774        })
775        .collect()
776}
777
778/// Enrich a list of references with the containing symbol (function/class) for each site.
779///
780/// For every unique file in `refs`, fetches the document symbol tree via LSP
781/// and resolves which symbol's range contains each reference line.
782async fn enrich_with_symbols(refs: &mut [find::ReferenceMatch], state: &DaemonState) {
783    use crate::commands::list;
784    use std::collections::HashMap;
785
786    // Collect unique non-definition file paths
787    let files: std::collections::HashSet<String> = refs
788        .iter()
789        .filter(|r| !r.is_definition)
790        .map(|r| r.path.clone())
791        .collect();
792
793    let mut file_symbols: HashMap<String, Vec<list::SymbolEntry>> = HashMap::new();
794
795    for file_path in files {
796        let abs_path = state.project_root.join(&file_path);
797        let Ok(mut guard) = state.pool.route_for_file(&abs_path).await else {
798            continue;
799        };
800        let Some(session) = guard.session_mut() else {
801            continue;
802        };
803        if let Ok(symbols) = list::list_symbols(
804            &abs_path,
805            3,
806            &mut session.client,
807            &mut session.file_tracker,
808            &state.project_root,
809        )
810        .await
811        {
812            file_symbols.insert(file_path, symbols);
813        }
814    }
815
816    for r in refs.iter_mut() {
817        if r.is_definition {
818            continue;
819        }
820        if let Some(symbols) = file_symbols.get(&r.path) {
821            r.containing_symbol = find::find_innermost_containing(symbols, r.line);
822        }
823    }
824}
825
826/// Route `list_symbols` to the correct LSP based on file extension.
827async fn handle_list_symbols(path: &Path, depth: u8, state: &DaemonState) -> Response {
828    // Directory mode: walk source files and aggregate symbols per file
829    let abs_path = if path.is_absolute() {
830        path.to_path_buf()
831    } else {
832        state.project_root.join(path)
833    };
834    if abs_path.is_dir() {
835        return handle_list_symbols_dir(&abs_path, depth, state).await;
836    }
837
838    // Cache-first: check index for this file
839    let rel_path = path.to_string_lossy();
840    if let Ok(guard) = state.index.lock() {
841        if let Some(ref store) = *guard {
842            if let Some(symbols) = cache_query::cached_list_symbols(
843                store,
844                &rel_path,
845                depth,
846                &state.project_root,
847                state.dirty_files_ref(),
848            ) {
849                debug!(
850                    "list_symbols cache hit for '{rel_path}' ({} symbols)",
851                    symbols.len()
852                );
853                return Response::ok(serde_json::to_value(symbols).unwrap_or_default());
854            }
855        }
856    }
857
858    let lang = language_for_file(path).or_else(|| state.languages.first().copied());
859
860    let Some(lang) = lang else {
861        return Response::err("no_language", "No language detected in project");
862    };
863
864    // Route for file: acquires only the relevant language's lock
865    let mut guard = match state.pool.route_for_file(path).await {
866        Ok(g) => g,
867        Err(e) => {
868            // Fallback: try get_or_start with detected language
869            match state.pool.get_or_start(lang).await {
870                Ok(g) => g,
871                Err(e2) => return Response::err("lsp_not_available", format!("{e}; {e2}")),
872            }
873        }
874    };
875
876    let Some(session) = guard.session_mut() else {
877        return Response::err("lsp_not_available", "No active session");
878    };
879
880    match list::list_symbols(
881        path,
882        depth,
883        &mut session.client,
884        &mut session.file_tracker,
885        &state.project_root,
886    )
887    .await
888    {
889        Ok(symbols) => Response::ok(serde_json::to_value(symbols).unwrap_or_default()),
890        Err(e) => {
891            let msg = e.to_string();
892            if msg.contains("not found") {
893                Response::err_with_advice("file_not_found", &msg, "Check the file path exists")
894            } else {
895                debug!("list_symbols error: {e:?}");
896                Response::err("list_symbols_failed", &msg)
897            }
898        }
899    }
900}
901
902/// Walk a directory and return symbols per file, depth=1 (top-level only).
903async fn handle_list_symbols_dir(dir: &Path, depth: u8, state: &DaemonState) -> Response {
904    use ignore::WalkBuilder;
905
906    let valid_exts = language_extensions(&state.languages);
907
908    // Collect source files in the directory (respects .gitignore)
909    let mut files: Vec<PathBuf> = WalkBuilder::new(dir)
910        .hidden(true)
911        .git_ignore(true)
912        .build()
913        .filter_map(Result::ok)
914        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
915        .map(ignore::DirEntry::into_path)
916        .filter(|p| {
917            p.extension()
918                .and_then(|ext| ext.to_str())
919                .is_some_and(|ext| valid_exts.iter().any(|v| v == ext))
920        })
921        .collect();
922    files.sort();
923
924    if files.is_empty() {
925        return Response::ok(json!({"dir": true, "files": []}));
926    }
927
928    let effective_depth = if depth == 0 { 1 } else { depth };
929    let mut file_entries = Vec::new();
930
931    for file_path in &files {
932        // Route to the appropriate LSP session for this file
933        let Ok(mut guard) = state.pool.route_for_file(file_path).await else {
934            continue;
935        };
936        let Some(session) = guard.session_mut() else {
937            continue;
938        };
939
940        let rel = file_path
941            .strip_prefix(&state.project_root)
942            .unwrap_or(file_path)
943            .to_string_lossy()
944            .into_owned();
945
946        match list::list_symbols(
947            file_path,
948            effective_depth,
949            &mut session.client,
950            &mut session.file_tracker,
951            &state.project_root,
952        )
953        .await
954        {
955            Ok(symbols) if !symbols.is_empty() => {
956                file_entries.push(json!({
957                    "file": rel,
958                    "symbols": serde_json::to_value(symbols).unwrap_or_default(),
959                }));
960            }
961            _ => {} // skip files with no symbols or LSP errors
962        }
963    }
964
965    Response::ok(json!({"dir": true, "files": file_entries}))
966}
967
968/// Handle `init` — build the symbol index for the project.
969#[allow(clippy::similar_names)]
970async fn handle_init(state: &DaemonState) -> Response {
971    let krait_dir = state.project_root.join(".krait");
972    if let Err(e) = std::fs::create_dir_all(&krait_dir) {
973        return Response::err("init_failed", format!("failed to create .krait/: {e}"));
974    }
975
976    let db_path = krait_dir.join("index.db");
977    let extensions = language_extensions(&state.languages);
978    let exts: Vec<&str> = extensions.iter().map(String::as_str).collect();
979    let init_start = Instant::now();
980
981    // Phase 1: plan which files need indexing
982    let (files_to_index, files_cached) = match plan_index_phase(&db_path, state, &exts) {
983        Ok(result) => result,
984        Err(resp) => return resp,
985    };
986    let files_total = files_to_index.len() + files_cached;
987    info!(
988        "init: phase 1 (plan) {:?} — {} to index, {} cached",
989        init_start.elapsed(),
990        files_to_index.len(),
991        files_cached
992    );
993
994    // Store discovered workspaces in SQLite
995    if let Err(e) = store_workspaces(&db_path, state) {
996        debug!("init: failed to store workspaces: {e}");
997    }
998
999    // Phase 2: query LSP for symbols — one server per language
1000    let phase2_start = Instant::now();
1001    let all_results = match collect_symbols_parallel(state, files_to_index).await {
1002        Ok(results) => results,
1003        Err(resp) => return resp,
1004    };
1005    let phase2_dur = phase2_start.elapsed();
1006    info!(
1007        "init: phase 2 (LSP) {phase2_dur:?} — {} results",
1008        all_results.len()
1009    );
1010
1011    // Phase 3: write results to DB
1012    let phase3_start = Instant::now();
1013    let (files_indexed, symbols_total) = match commit_index_phase(&db_path, &all_results) {
1014        Ok(result) => result,
1015        Err(resp) => return resp,
1016    };
1017    let phase3_dur = phase3_start.elapsed();
1018    info!("init: phase 3 (commit) {phase3_dur:?}");
1019
1020    // When all files were cached, symbols_total counts only newly indexed symbols (0).
1021    // Report the DB total instead so the user sees the real symbol count.
1022    #[allow(clippy::cast_possible_truncation)]
1023    let symbols_total = if symbols_total == 0 && files_cached > 0 {
1024        IndexStore::open(&db_path)
1025            .and_then(|s| s.count_all_symbols())
1026            .unwrap_or(0) as usize
1027    } else {
1028        symbols_total
1029    };
1030
1031    let total_ms = init_start.elapsed().as_millis();
1032    let batch_size = builder::detect_batch_size();
1033    info!(
1034        "init: total {:?} — {files_indexed} files, {symbols_total} symbols",
1035        init_start.elapsed()
1036    );
1037
1038    // Optimize the DB after a full index build
1039    if let Ok(store) = crate::index::store::IndexStore::open(&db_path) {
1040        if let Err(e) = store.optimize() {
1041            debug!("init: optimize failed (non-fatal): {e}");
1042        }
1043    }
1044
1045    // Refresh the cache-first index so subsequent queries use the new data
1046    state.refresh_index();
1047    // Clear the dirty set — everything is freshly indexed
1048    state.dirty_files.clear();
1049
1050    let num_workers = builder::detect_worker_count();
1051    Response::ok(json!({
1052        "message": "index built",
1053        "db_path": db_path.display().to_string(),
1054        "files_total": files_total,
1055        "files_indexed": files_indexed,
1056        "files_cached": files_cached,
1057        "symbols_total": symbols_total,
1058        "elapsed_ms": total_ms,
1059        "batch_size": batch_size,
1060        "workers": num_workers,
1061        "phase2_lsp_ms": phase2_dur.as_millis(),
1062        "phase3_commit_ms": phase3_dur.as_millis(),
1063    }))
1064}
1065
1066/// Phase 1: plan which files need indexing (sync, no LSP).
1067fn plan_index_phase(
1068    db_path: &Path,
1069    state: &DaemonState,
1070    exts: &[&str],
1071) -> Result<(Vec<builder::FileEntry>, usize), Response> {
1072    let store = IndexStore::open(db_path)
1073        .or_else(|e| {
1074            // Corrupted DB — delete and retry with a fresh one
1075            info!("index DB corrupted in plan phase ({e}), deleting");
1076            let _ = std::fs::remove_file(db_path);
1077            IndexStore::open(db_path)
1078        })
1079        .map_err(|e| Response::err("init_failed", format!("failed to open index DB: {e}")))?;
1080    builder::plan_index(&store, &state.project_root, exts)
1081        .map_err(|e| Response::err("init_failed", format!("failed to plan index: {e}")))
1082}
1083
1084/// Phase 2: collect symbols using parallel temporary workers.
1085///
1086/// Spawns N temporary LSP servers per language (not from the pool) and splits
1087/// files across them for true parallel indexing. Works for all languages.
1088async fn collect_symbols_parallel(
1089    state: &DaemonState,
1090    files_to_index: Vec<builder::FileEntry>,
1091) -> Result<Vec<(String, String, Vec<crate::index::store::CachedSymbol>)>, Response> {
1092    let num_workers = builder::detect_worker_count();
1093    info!("init: workers={num_workers} (based on system resources)");
1094
1095    // Group files by language — use detected languages, falling back to pool
1096    let languages = {
1097        let detected = state.languages.clone();
1098        if detected.is_empty() {
1099            state.pool.unique_languages()
1100        } else {
1101            detected
1102        }
1103    };
1104    if languages.is_empty() {
1105        return Err(Response::err(
1106            "no_language",
1107            "No language detected in project",
1108        ));
1109    }
1110
1111    let mut handles = Vec::new();
1112    for lang in &languages {
1113        let lang_files: Vec<builder::FileEntry> = files_to_index
1114            .iter()
1115            .filter(|f| language_for_file(&f.abs_path) == Some(*lang))
1116            .map(|f| builder::FileEntry {
1117                abs_path: f.abs_path.clone(),
1118                rel_path: f.rel_path.clone(),
1119                hash: f.hash.clone(),
1120            })
1121            .collect();
1122
1123        if lang_files.is_empty() {
1124            continue;
1125        }
1126
1127        info!(
1128            "init: {lang} — {} files, {num_workers} workers",
1129            lang_files.len()
1130        );
1131        let lang = *lang;
1132        let root = state.project_root.clone();
1133        handles.push(tokio::spawn(async move {
1134            builder::collect_symbols_parallel(lang_files, lang, &root, num_workers).await
1135        }));
1136    }
1137
1138    let mut all_results = Vec::new();
1139    for handle in handles {
1140        match handle.await {
1141            Ok(Ok(results)) => all_results.extend(results),
1142            Ok(Err(e)) => debug!("init: worker error: {e}"),
1143            Err(e) => debug!("init: task panicked: {e}"),
1144        }
1145    }
1146
1147    Ok(all_results)
1148}
1149
1150/// Phase 3: commit results to the index store (sync, no LSP).
1151fn commit_index_phase(
1152    db_path: &Path,
1153    results: &[(String, String, Vec<crate::index::store::CachedSymbol>)],
1154) -> Result<(usize, usize), Response> {
1155    let store = IndexStore::open(db_path)
1156        .map_err(|e| Response::err("init_failed", format!("failed to open index DB: {e}")))?;
1157    let symbols = builder::commit_index(&store, results)
1158        .map_err(|e| Response::err("init_failed", format!("failed to write index: {e}")))?;
1159    Ok((results.len(), symbols))
1160}
1161
1162/// Store all discovered workspaces in the index database.
1163fn store_workspaces(db_path: &Path, state: &DaemonState) -> anyhow::Result<()> {
1164    let store = IndexStore::open(db_path)?;
1165    store.clear_workspaces()?;
1166    for (lang, root) in &state.package_roots {
1167        let rel = root
1168            .strip_prefix(&state.project_root)
1169            .unwrap_or(root)
1170            .to_string_lossy();
1171        let path = if rel.is_empty() { "." } else { &rel };
1172        store.upsert_workspace(path, lang.name())?;
1173    }
1174    info!(
1175        "init: stored {} workspaces in index",
1176        state.package_roots.len()
1177    );
1178    Ok(())
1179}
1180
1181/// Get file extensions to watch/index for the detected languages.
1182/// TypeScript and JavaScript always include each other's extensions since they interoperate.
1183fn language_extensions(languages: &[Language]) -> Vec<String> {
1184    let mut exts = Vec::new();
1185    for &lang in languages {
1186        match lang {
1187            Language::TypeScript | Language::JavaScript => {
1188                // TS/JS share file types — watch both when either is detected
1189                for &e in Language::TypeScript
1190                    .extensions()
1191                    .iter()
1192                    .chain(Language::JavaScript.extensions())
1193                {
1194                    exts.push(e.to_string());
1195                }
1196            }
1197            _ => {
1198                for &e in lang.extensions() {
1199                    exts.push(e.to_string());
1200                }
1201            }
1202        }
1203    }
1204    exts.sort();
1205    exts.dedup();
1206    exts
1207}
1208
1209/// Handle `read file` — pure file I/O, no LSP needed.
1210fn handle_read_file(
1211    path: &Path,
1212    from: Option<u32>,
1213    to: Option<u32>,
1214    max_lines: Option<u32>,
1215    state: &DaemonState,
1216) -> Response {
1217    match read::handle_read_file(path, from, to, max_lines, &state.project_root) {
1218        Ok(data) => Response::ok(data),
1219        Err(e) => {
1220            let msg = e.to_string();
1221            if msg.contains("not found") {
1222                Response::err_with_advice("file_not_found", &msg, "Check the file path exists")
1223            } else if msg.contains("binary file") {
1224                Response::err("binary_file", &msg)
1225            } else {
1226                Response::err("read_failed", &msg)
1227            }
1228        }
1229    }
1230}
1231
1232/// Handle `read symbol` — find symbol via LSP then extract lines.
1233#[allow(clippy::too_many_lines)]
1234async fn handle_read_symbol(
1235    name: &str,
1236    signature_only: bool,
1237    max_lines: Option<u32>,
1238    path_filter: Option<&str>,
1239    has_body: bool,
1240    state: &DaemonState,
1241) -> Response {
1242    // When path_filter is set, use cached_find_symbol to get ALL candidates, filter
1243    // by path, then read the matching symbol body from disk via the LSP session.
1244    // This avoids the "cache only returns first match" problem.
1245    if let Some(filter) = path_filter {
1246        // Try to get all candidates from the index
1247        let filtered = if let Ok(guard) = state.index.lock() {
1248            if let Some(ref store) = *guard {
1249                cache_query::cached_find_symbol(
1250                    store,
1251                    name,
1252                    &state.project_root,
1253                    state.dirty_files_ref(),
1254                )
1255                .map(|all| filter_by_path(all, Some(filter)))
1256            } else {
1257                None
1258            }
1259        } else {
1260            None
1261        };
1262
1263        if let Some(candidates) = filtered {
1264            if !candidates.is_empty() {
1265                // Have filtered candidates from cache — use LSP session to read body
1266                let languages = state.pool.unique_languages();
1267                for lang in &languages {
1268                    let Ok(mut guard) = state.pool.get_or_start(*lang).await else {
1269                        continue;
1270                    };
1271                    let Some(session) = guard.session_mut() else {
1272                        continue;
1273                    };
1274                    match read::handle_read_symbol(
1275                        name,
1276                        &candidates,
1277                        signature_only,
1278                        max_lines,
1279                        has_body,
1280                        &mut session.client,
1281                        &mut session.file_tracker,
1282                        &state.project_root,
1283                    )
1284                    .await
1285                    {
1286                        Ok(data) => return Response::ok(data),
1287                        Err(e) => return Response::err("read_symbol_failed", e.to_string()),
1288                    }
1289                }
1290            }
1291        }
1292
1293        // Cache miss with path_filter: fall through to LSP path below (path_filter
1294        // will be applied to candidates returned by workspace/symbol)
1295    }
1296
1297    // Cache-first: read symbol body from index + disk (no path filter, no has_body).
1298    if path_filter.is_none() && !has_body {
1299        if let Ok(guard) = state.index.lock() {
1300            if let Some(ref store) = *guard {
1301                if let Some(data) = cache_query::cached_read_symbol(
1302                    store,
1303                    name,
1304                    signature_only,
1305                    max_lines,
1306                    &state.project_root,
1307                    state.dirty_files_ref(),
1308                ) {
1309                    debug!("read_symbol cache hit for '{name}'");
1310                    return Response::ok(data);
1311                }
1312            }
1313        }
1314    }
1315
1316    let languages = state.pool.unique_languages();
1317    if languages.is_empty() {
1318        return Response::err("no_language", "No language detected in project");
1319    }
1320
1321    // For dotted names like "Config.new", search for the parent symbol
1322    let search_name = name.split('.').next().unwrap_or(name);
1323
1324    // Search each language server sequentially (first hit wins)
1325    for lang in &languages {
1326        let mut guard = match state.pool.get_or_start(*lang).await {
1327            Ok(g) => g,
1328            Err(e) => {
1329                debug!("skipping {lang}: {e}");
1330                continue;
1331            }
1332        };
1333        if let Err(e) = state
1334            .pool
1335            .attach_all_workspaces_with_guard(*lang, &mut guard)
1336            .await
1337        {
1338            debug!("skipping {lang}: {e}");
1339            continue;
1340        }
1341
1342        let Some(session) = guard.session_mut() else {
1343            continue;
1344        };
1345
1346        // Find symbol candidates in this language, optionally filtered by path
1347        let raw_candidates =
1348            match find::find_symbol(search_name, &mut session.client, &state.project_root).await {
1349                Ok(s) if !s.is_empty() => s,
1350                _ => continue,
1351            };
1352        let candidates = filter_by_path(raw_candidates, path_filter);
1353        if candidates.is_empty() {
1354            continue;
1355        }
1356
1357        // Try to resolve the symbol body from the candidates
1358        match read::handle_read_symbol(
1359            name,
1360            &candidates,
1361            signature_only,
1362            max_lines,
1363            has_body,
1364            &mut session.client,
1365            &mut session.file_tracker,
1366            &state.project_root,
1367        )
1368        .await
1369        {
1370            Ok(data) => return Response::ok(data),
1371            Err(e) => {
1372                let msg = e.to_string();
1373                debug!("read_symbol failed for {name}: {msg}");
1374                return Response::err("read_symbol_failed", &msg);
1375            }
1376        }
1377    }
1378
1379    // Symbol not found in any language
1380    let readiness = state.pool.readiness();
1381    let uptime = state.start_time.elapsed().as_secs();
1382    if uptime < INDEXING_GRACE_PERIOD_SECS && readiness.total > 0 && !readiness.is_all_ready() {
1383        Response::err_with_advice(
1384            "indexing",
1385            format!(
1386                "LSP servers still indexing ({}/{} ready, daemon uptime {}s)",
1387                readiness.ready, readiness.total, uptime
1388            ),
1389            "Wait a few seconds and try again, or run `krait daemon status`",
1390        )
1391    } else {
1392        Response::err_with_advice(
1393            "symbol_not_found",
1394            format!("symbol '{name}' not found"),
1395            "Check the symbol name and try again",
1396        )
1397    }
1398}
1399
1400/// Find concrete implementations of an interface method using `textDocument/implementation`.
1401async fn handle_find_impl(name: &str, state: &DaemonState) -> Response {
1402    let languages = state.pool.unique_languages();
1403    if languages.is_empty() {
1404        return Response::err("no_language", "No language detected in project");
1405    }
1406
1407    let mut all_results = Vec::new();
1408    for lang in &languages {
1409        let mut guard = match state.pool.get_or_start(*lang).await {
1410            Ok(g) => g,
1411            Err(e) => {
1412                debug!("find_impl skipping {lang}: {e}");
1413                continue;
1414            }
1415        };
1416        if let Err(e) = state
1417            .pool
1418            .attach_all_workspaces_with_guard(*lang, &mut guard)
1419            .await
1420        {
1421            debug!("find_impl skipping {lang}: {e}");
1422            continue;
1423        }
1424        let Some(session) = guard.session_mut() else {
1425            continue;
1426        };
1427        match find::find_impl(
1428            name,
1429            &mut session.client,
1430            &mut session.file_tracker,
1431            &state.project_root,
1432        )
1433        .await
1434        {
1435            Ok(results) => all_results.extend(results),
1436            Err(e) => debug!("find_impl failed for {lang}: {e}"),
1437        }
1438    }
1439
1440    // Deduplicate by path:line
1441    all_results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
1442    all_results.dedup_by(|a, b| a.path == b.path && a.line == b.line);
1443
1444    if all_results.is_empty() {
1445        Response::err_with_advice(
1446            "impl_not_found",
1447            format!("no implementations found for '{name}'"),
1448            "Try krait find refs <name> to see where it's called, or krait find symbol <name> for definitions",
1449        )
1450    } else {
1451        Response::ok(serde_json::to_value(all_results).unwrap_or_default())
1452    }
1453}
1454
1455// ── Semantic editing ──────────────────────────────────────────────────────────
1456
1457enum EditKind {
1458    Replace,
1459    InsertAfter,
1460    InsertBefore,
1461}
1462
1463/// Shared dispatcher for all three edit commands.
1464async fn handle_edit(name: &str, code: &str, kind: EditKind, state: &DaemonState) -> Response {
1465    let languages = state.pool.unique_languages();
1466    if languages.is_empty() {
1467        return Response::err("no_language", "No language detected in project");
1468    }
1469
1470    for lang in &languages {
1471        let mut guard = match state.pool.get_or_start(*lang).await {
1472            Ok(g) => g,
1473            Err(e) => {
1474                debug!("skipping {lang}: {e}");
1475                continue;
1476            }
1477        };
1478        if let Err(e) = state
1479            .pool
1480            .attach_all_workspaces_with_guard(*lang, &mut guard)
1481            .await
1482        {
1483            debug!("skipping {lang} attach: {e}");
1484            continue;
1485        }
1486
1487        let Some(session) = guard.session_mut() else {
1488            continue;
1489        };
1490
1491        let result = match kind {
1492            EditKind::Replace => {
1493                edit::handle_edit_replace(
1494                    name,
1495                    code,
1496                    &mut session.client,
1497                    &mut session.file_tracker,
1498                    &state.project_root,
1499                    &state.dirty_files,
1500                )
1501                .await
1502            }
1503            EditKind::InsertAfter => {
1504                edit::handle_edit_insert_after(
1505                    name,
1506                    code,
1507                    &mut session.client,
1508                    &mut session.file_tracker,
1509                    &state.project_root,
1510                    &state.dirty_files,
1511                )
1512                .await
1513            }
1514            EditKind::InsertBefore => {
1515                edit::handle_edit_insert_before(
1516                    name,
1517                    code,
1518                    &mut session.client,
1519                    &mut session.file_tracker,
1520                    &state.project_root,
1521                    &state.dirty_files,
1522                )
1523                .await
1524            }
1525        };
1526
1527        match result {
1528            Ok(data) => return Response::ok(data),
1529            Err(e) => {
1530                let msg = e.to_string();
1531                if msg.contains("not found") {
1532                    continue; // try next language
1533                }
1534                return Response::err("edit_failed", msg);
1535            }
1536        }
1537    }
1538
1539    let readiness = state.pool.readiness();
1540    let uptime = state.start_time.elapsed().as_secs();
1541    if uptime < INDEXING_GRACE_PERIOD_SECS && readiness.total > 0 && !readiness.is_all_ready() {
1542        Response::err_with_advice(
1543            "indexing",
1544            format!(
1545                "LSP servers still indexing ({}/{} ready)",
1546                readiness.ready, readiness.total
1547            ),
1548            "Wait a few seconds and try again",
1549        )
1550    } else {
1551        Response::err_with_advice(
1552            "symbol_not_found",
1553            format!("symbol '{name}' not found"),
1554            "Check the symbol name and try again",
1555        )
1556    }
1557}
1558
1559/// Handle `krait check [path]`.
1560///
1561/// If `path` is given, actively reopens the file so the LSP analyses its current on-disk
1562/// content (important after `krait edit`), then waits for `publishDiagnostics` to arrive.
1563/// If no path, returns diagnostics accumulated passively from prior queries.
1564async fn handle_check(
1565    path: Option<&std::path::Path>,
1566    errors_only: bool,
1567    state: &DaemonState,
1568) -> Response {
1569    const DIAG_WAIT_MS: u64 = 3_000;
1570
1571    if let Some(file_path) = path {
1572        let Some(lang) = language_for_file(file_path) else {
1573            // Unknown language — return any passively-stored diagnostics
1574            let data = check::handle_check(
1575                Some(file_path),
1576                &state.diagnostic_store,
1577                &state.project_root,
1578                errors_only,
1579            );
1580            return Response::ok(data);
1581        };
1582
1583        // Ensure the LSP is running for this language
1584        let Ok(mut guard) = state.pool.get_or_start(lang).await else {
1585            // Can't reach LSP — return passive diagnostics
1586            let data = check::handle_check(
1587                path,
1588                &state.diagnostic_store,
1589                &state.project_root,
1590                errors_only,
1591            );
1592            return Response::ok(data);
1593        };
1594        if let Err(e) = state
1595            .pool
1596            .attach_all_workspaces_with_guard(lang, &mut guard)
1597            .await
1598        {
1599            return Response::err("lsp_error", e.to_string());
1600        }
1601
1602        let Some(session) = guard.session_mut() else {
1603            let data = check::handle_check(
1604                path,
1605                &state.diagnostic_store,
1606                &state.project_root,
1607                errors_only,
1608            );
1609            return Response::ok(data);
1610        };
1611
1612        // Force-reopen so the LSP sees the latest on-disk content (post-edit)
1613        let _ = session
1614            .file_tracker
1615            .reopen(file_path, session.client.transport_mut())
1616            .await;
1617
1618        // Send a documentSymbol probe — during wait, publishDiagnostics notifications are captured
1619        let abs = if file_path.is_absolute() {
1620            file_path.to_path_buf()
1621        } else {
1622            state.project_root.join(file_path)
1623        };
1624        if let Ok(uri) = path_to_uri(&abs) {
1625            let params = serde_json::json!({
1626                "textDocument": { "uri": uri.as_str() }
1627            });
1628            if let Ok(req_id) = session
1629                .client
1630                .transport_mut()
1631                .send_request("textDocument/documentSymbol", params)
1632                .await
1633            {
1634                let timeout = std::time::Duration::from_millis(DIAG_WAIT_MS);
1635                let _ =
1636                    tokio::time::timeout(timeout, session.client.wait_for_response_public(req_id))
1637                        .await;
1638            }
1639        }
1640    }
1641
1642    let data = check::handle_check(
1643        path,
1644        &state.diagnostic_store,
1645        &state.project_root,
1646        errors_only,
1647    );
1648    Response::ok(data)
1649}
1650
1651// ── Phase 10 handlers ─────────────────────────────────────────────────────────
1652
1653/// Handle `krait hover <name>` — type info at symbol definition.
1654async fn handle_hover_cmd(name: &str, state: &DaemonState) -> Response {
1655    let languages = state.pool.unique_languages();
1656    if languages.is_empty() {
1657        return Response::err("no_language", "No language detected in project");
1658    }
1659
1660    for lang in &languages {
1661        let mut guard = match state.pool.get_or_start(*lang).await {
1662            Ok(g) => g,
1663            Err(e) => {
1664                debug!("skipping {lang}: {e}");
1665                continue;
1666            }
1667        };
1668        if let Err(e) = state
1669            .pool
1670            .attach_all_workspaces_with_guard(*lang, &mut guard)
1671            .await
1672        {
1673            debug!("skipping {lang} attach: {e}");
1674            continue;
1675        }
1676        let Some(session) = guard.session_mut() else {
1677            continue;
1678        };
1679        match hover::handle_hover(
1680            name,
1681            &mut session.client,
1682            &mut session.file_tracker,
1683            &state.project_root,
1684        )
1685        .await
1686        {
1687            Ok(data) => return Response::ok(data),
1688            Err(e) => {
1689                if !e.to_string().contains("not found") {
1690                    return Response::err("hover_failed", e.to_string());
1691                }
1692            }
1693        }
1694    }
1695
1696    Response::err_with_advice(
1697        "symbol_not_found",
1698        format!("symbol '{name}' not found"),
1699        "Check the symbol name and try again",
1700    )
1701}
1702
1703/// Handle `krait format <path>` — run LSP formatter on a file.
1704async fn handle_format_cmd(path: &std::path::Path, state: &DaemonState) -> Response {
1705    let lang = language_for_file(path).or_else(|| state.languages.first().copied());
1706    let Some(lang) = lang else {
1707        return Response::err("no_language", "Cannot detect language for file");
1708    };
1709
1710    let mut guard = match state.pool.get_or_start(lang).await {
1711        Ok(g) => g,
1712        Err(e) => return Response::err("lsp_not_available", e.to_string()),
1713    };
1714    if let Err(e) = state
1715        .pool
1716        .attach_all_workspaces_with_guard(lang, &mut guard)
1717        .await
1718    {
1719        debug!("format attach warning: {e}");
1720    }
1721    let Some(session) = guard.session_mut() else {
1722        return Response::err("lsp_not_available", "No active session");
1723    };
1724
1725    match fmt::handle_format(
1726        path,
1727        &mut session.client,
1728        &mut session.file_tracker,
1729        &state.project_root,
1730    )
1731    .await
1732    {
1733        Ok(data) => Response::ok(data),
1734        Err(e) => Response::err("format_failed", e.to_string()),
1735    }
1736}
1737
1738/// Handle `krait rename <symbol> <new_name>` — cross-file rename.
1739async fn handle_rename_cmd(name: &str, new_name: &str, state: &DaemonState) -> Response {
1740    let languages = state.pool.unique_languages();
1741    if languages.is_empty() {
1742        return Response::err("no_language", "No language detected in project");
1743    }
1744
1745    for lang in &languages {
1746        let mut guard = match state.pool.get_or_start(*lang).await {
1747            Ok(g) => g,
1748            Err(e) => {
1749                debug!("skipping {lang}: {e}");
1750                continue;
1751            }
1752        };
1753        if let Err(e) = state
1754            .pool
1755            .attach_all_workspaces_with_guard(*lang, &mut guard)
1756            .await
1757        {
1758            debug!("skipping {lang} attach: {e}");
1759            continue;
1760        }
1761        let Some(session) = guard.session_mut() else {
1762            continue;
1763        };
1764        match rename::handle_rename(
1765            name,
1766            new_name,
1767            &mut session.client,
1768            &mut session.file_tracker,
1769            &state.project_root,
1770        )
1771        .await
1772        {
1773            Ok(data) => return Response::ok(data),
1774            Err(e) => {
1775                if !e.to_string().contains("not found") {
1776                    return Response::err("rename_failed", e.to_string());
1777                }
1778            }
1779        }
1780    }
1781
1782    Response::err_with_advice(
1783        "symbol_not_found",
1784        format!("symbol '{name}' not found"),
1785        "Check the symbol name and try again",
1786    )
1787}
1788
1789/// Handle `krait fix [path]` — apply LSP quick-fix code actions.
1790async fn handle_fix_cmd(path: Option<&std::path::Path>, state: &DaemonState) -> Response {
1791    let languages = state.pool.unique_languages();
1792    if languages.is_empty() {
1793        return Response::err("no_language", "No language detected in project");
1794    }
1795
1796    // Pick the first available language session — diagnostics are file-agnostic
1797    for lang in &languages {
1798        let mut guard = match state.pool.get_or_start(*lang).await {
1799            Ok(g) => g,
1800            Err(e) => {
1801                debug!("skipping {lang}: {e}");
1802                continue;
1803            }
1804        };
1805        if let Err(e) = state
1806            .pool
1807            .attach_all_workspaces_with_guard(*lang, &mut guard)
1808            .await
1809        {
1810            debug!("skipping {lang} attach: {e}");
1811            continue;
1812        }
1813        let Some(session) = guard.session_mut() else {
1814            continue;
1815        };
1816        match fix::handle_fix(
1817            path,
1818            &mut session.client,
1819            &mut session.file_tracker,
1820            &state.project_root,
1821            &state.diagnostic_store,
1822        )
1823        .await
1824        {
1825            Ok(data) => return Response::ok(data),
1826            Err(e) => return Response::err("fix_failed", e.to_string()),
1827        }
1828    }
1829
1830    Response::err("lsp_not_available", "No LSP server available for fix")
1831}
1832
1833fn build_status_response(state: &DaemonState) -> Response {
1834    let uptime = state.start_time.elapsed().as_secs();
1835    let language_names: Vec<&str> = state.languages.iter().map(|l| l.name()).collect();
1836
1837    let (lsp_info, workspace_count) = {
1838        let readiness = state.pool.readiness();
1839        let statuses = state.pool.status();
1840        let workspace_count = state.pool.workspace_roots().len();
1841
1842        let lsp = if readiness.total == 0 {
1843            // No workspaces configured
1844            match state.languages.first() {
1845                Some(lang) => {
1846                    let entry = get_entry(*lang);
1847                    let available = entry.as_ref().is_some_and(|e| find_server(e).is_some());
1848                    let server = entry.as_ref().map_or("unknown", |e| e.binary_name);
1849                    let advice = entry.as_ref().map(|e| e.install_advice);
1850                    json!({
1851                        "language": lang.name(),
1852                        "status": if available { "available" } else { "not_installed" },
1853                        "server": server,
1854                        "advice": advice,
1855                    })
1856                }
1857                None => json!(null),
1858            }
1859        } else {
1860            let status_label = if readiness.is_all_ready() {
1861                "ready"
1862            } else {
1863                "pending"
1864            };
1865            json!({
1866                "status": status_label,
1867                "sessions": readiness.total,
1868                "ready": readiness.ready,
1869                "progress": format!("{}/{}", readiness.ready, readiness.total),
1870                "servers": statuses.iter().map(|s| json!({
1871                    "language": s.language,
1872                    "server": s.server_name,
1873                    "status": s.status,
1874                    "uptime_secs": s.uptime_secs,
1875                    "open_files": s.open_files,
1876                    "attached_folders": s.attached_folders,
1877                    "total_folders": s.total_folders,
1878                })).collect::<Vec<_>>(),
1879            })
1880        };
1881        (lsp, workspace_count)
1882    };
1883
1884    // Try to read workspace counts from the index DB
1885    let (discovered, attached) = {
1886        let db_path = state.project_root.join(".krait/index.db");
1887        IndexStore::open(&db_path)
1888            .ok()
1889            .and_then(|store| store.workspace_counts().ok())
1890            .unwrap_or((workspace_count, 0))
1891    };
1892
1893    let config_label = state.config_source.label();
1894
1895    Response::ok(json!({
1896        "daemon": {
1897            "pid": std::process::id(),
1898            "uptime_secs": uptime,
1899        },
1900        "config": config_label,
1901        "lsp": lsp_info,
1902        "project": {
1903            "root": state.project_root.display().to_string(),
1904            "languages": language_names,
1905            "workspaces": workspace_count,
1906            "workspaces_discovered": discovered,
1907            "workspaces_attached": attached,
1908        },
1909        "index": {
1910            "dirty_files": state.dirty_files.len(),
1911            "watcher_active": state.watcher_active,
1912        }
1913    }))
1914}
1915
1916#[cfg(test)]
1917mod tests {
1918    use std::time::Duration;
1919
1920    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1921    use tokio::net::UnixStream;
1922
1923    use super::*;
1924
1925    async fn send_request(socket_path: &Path, req: &Request) -> Response {
1926        let mut stream = UnixStream::connect(socket_path).await.unwrap();
1927
1928        let json = serde_json::to_vec(req).unwrap();
1929        let len = u32::try_from(json.len()).unwrap();
1930        stream.write_u32(len).await.unwrap();
1931        stream.write_all(&json).await.unwrap();
1932        stream.flush().await.unwrap();
1933
1934        let resp_len = stream.read_u32().await.unwrap();
1935        let mut resp_buf = vec![0u8; resp_len as usize];
1936        stream.read_exact(&mut resp_buf).await.unwrap();
1937        serde_json::from_slice(&resp_buf).unwrap()
1938    }
1939
1940    fn start_server(
1941        sock: &Path,
1942        project_root: &Path,
1943        timeout_secs: u64,
1944    ) -> tokio::task::JoinHandle<()> {
1945        let sock = sock.to_path_buf();
1946        let root = project_root.to_path_buf();
1947        tokio::spawn(async move {
1948            run_server(&sock, Duration::from_secs(timeout_secs), &root)
1949                .await
1950                .unwrap();
1951        })
1952    }
1953
1954    #[tokio::test]
1955    async fn daemon_starts_and_listens() {
1956        let dir = tempfile::tempdir().unwrap();
1957        let sock = dir.path().join("test.sock");
1958
1959        let handle = start_server(&sock, dir.path(), 2);
1960        tokio::time::sleep(Duration::from_millis(50)).await;
1961        assert!(sock.exists());
1962
1963        send_request(&sock, &Request::DaemonStop).await;
1964        let _ = handle.await;
1965    }
1966
1967    #[tokio::test]
1968    async fn daemon_handles_status_request() {
1969        let dir = tempfile::tempdir().unwrap();
1970        let sock = dir.path().join("test.sock");
1971
1972        let handle = start_server(&sock, dir.path(), 5);
1973        tokio::time::sleep(Duration::from_millis(50)).await;
1974
1975        let resp = send_request(&sock, &Request::Status).await;
1976        assert!(resp.success);
1977        assert!(resp.data.is_some());
1978
1979        let data = resp.data.unwrap();
1980        assert!(data["daemon"]["pid"].is_u64());
1981        assert!(data["daemon"]["uptime_secs"].is_u64());
1982        assert!(data["project"]["root"].is_string());
1983        assert!(data["project"]["languages"].is_array());
1984
1985        send_request(&sock, &Request::DaemonStop).await;
1986        let _ = handle.await;
1987    }
1988
1989    #[tokio::test]
1990    async fn status_shows_lsp_null_when_no_languages() {
1991        let dir = tempfile::tempdir().unwrap();
1992        let sock = dir.path().join("test.sock");
1993
1994        let handle = start_server(&sock, dir.path(), 5);
1995        tokio::time::sleep(Duration::from_millis(50)).await;
1996
1997        let resp = send_request(&sock, &Request::Status).await;
1998        let data = resp.data.unwrap();
1999        assert!(data["lsp"].is_null());
2000        assert!(data["project"]["languages"].as_array().unwrap().is_empty());
2001
2002        send_request(&sock, &Request::DaemonStop).await;
2003        let _ = handle.await;
2004    }
2005
2006    #[tokio::test]
2007    async fn status_shows_language_detection() {
2008        let dir = tempfile::tempdir().unwrap();
2009        std::fs::write(
2010            dir.path().join("Cargo.toml"),
2011            "[package]\nname = \"test\"\n",
2012        )
2013        .unwrap();
2014
2015        let sock = dir.path().join("test.sock");
2016        let handle = start_server(&sock, dir.path(), 5);
2017        tokio::time::sleep(Duration::from_millis(50)).await;
2018
2019        let resp = send_request(&sock, &Request::Status).await;
2020        let data = resp.data.unwrap();
2021        assert_eq!(data["project"]["languages"][0], "rust");
2022
2023        send_request(&sock, &Request::DaemonStop).await;
2024        let _ = handle.await;
2025    }
2026
2027    #[tokio::test]
2028    async fn status_shows_workspace_count() {
2029        let dir = tempfile::tempdir().unwrap();
2030        std::fs::write(
2031            dir.path().join("Cargo.toml"),
2032            "[package]\nname = \"test\"\n",
2033        )
2034        .unwrap();
2035
2036        let sock = dir.path().join("test.sock");
2037        let handle = start_server(&sock, dir.path(), 5);
2038        tokio::time::sleep(Duration::from_millis(50)).await;
2039
2040        let resp = send_request(&sock, &Request::Status).await;
2041        let data = resp.data.unwrap();
2042        assert!(data["project"]["workspaces"].is_u64());
2043
2044        send_request(&sock, &Request::DaemonStop).await;
2045        let _ = handle.await;
2046    }
2047
2048    #[tokio::test]
2049    async fn dispatch_unknown_returns_not_implemented() {
2050        let dir = tempfile::tempdir().unwrap();
2051        let sock = dir.path().join("test.sock");
2052
2053        let handle = start_server(&sock, dir.path(), 5);
2054        tokio::time::sleep(Duration::from_millis(50)).await;
2055
2056        let resp = send_request(&sock, &Request::Init).await;
2057        assert!(!resp.success);
2058        assert!(resp.error.is_some());
2059
2060        send_request(&sock, &Request::DaemonStop).await;
2061        let _ = handle.await;
2062    }
2063
2064    #[tokio::test]
2065    async fn daemon_cleans_up_on_stop() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let sock = dir.path().join("test.sock");
2068
2069        let handle = start_server(&sock, dir.path(), 5);
2070        tokio::time::sleep(Duration::from_millis(50)).await;
2071
2072        let resp = send_request(&sock, &Request::DaemonStop).await;
2073        assert!(resp.success);
2074
2075        let _ = handle.await;
2076    }
2077
2078    #[tokio::test]
2079    async fn daemon_idle_timeout() {
2080        let dir = tempfile::tempdir().unwrap();
2081        let sock = dir.path().join("test.sock");
2082
2083        let sock_clone = sock.clone();
2084        let root = dir.path().to_path_buf();
2085        let handle = tokio::spawn(async move {
2086            run_server(&sock_clone, Duration::from_millis(200), &root)
2087                .await
2088                .unwrap();
2089        });
2090
2091        let result = tokio::time::timeout(Duration::from_secs(2), handle).await;
2092        assert!(result.is_ok(), "daemon should have exited on idle timeout");
2093    }
2094
2095    #[tokio::test]
2096    async fn daemon_handles_concurrent_connections() {
2097        let dir = tempfile::tempdir().unwrap();
2098        let sock = dir.path().join("test.sock");
2099
2100        let handle = start_server(&sock, dir.path(), 5);
2101        tokio::time::sleep(Duration::from_millis(50)).await;
2102
2103        let mut tasks = Vec::new();
2104        for _ in 0..3 {
2105            let s = sock.clone();
2106            tasks.push(tokio::spawn(async move {
2107                send_request(&s, &Request::Status).await
2108            }));
2109        }
2110
2111        for task in tasks {
2112            let resp = task.await.unwrap();
2113            assert!(resp.success);
2114        }
2115
2116        send_request(&sock, &Request::DaemonStop).await;
2117        let _ = handle.await;
2118    }
2119
2120    #[tokio::test]
2121    async fn dispatch_status_returns_success() {
2122        let dir = tempfile::tempdir().unwrap();
2123        let sock = dir.path().join("test.sock");
2124
2125        let handle = start_server(&sock, dir.path(), 5);
2126        tokio::time::sleep(Duration::from_millis(50)).await;
2127
2128        let resp = send_request(&sock, &Request::Status).await;
2129        assert!(resp.success);
2130        assert!(resp.data.is_some());
2131
2132        // Verify key fields are present
2133        let data = resp.data.unwrap();
2134        assert!(data["daemon"]["pid"].as_u64().is_some());
2135        assert!(data["daemon"]["uptime_secs"].as_u64().is_some());
2136        assert!(data["project"]["root"].is_string());
2137        assert!(data["index"]["watcher_active"].is_boolean());
2138
2139        send_request(&sock, &Request::DaemonStop).await;
2140        let _ = handle.await;
2141    }
2142
2143    #[tokio::test]
2144    async fn handle_connection_rejects_oversized_frame() {
2145        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2146        use tokio::net::UnixStream;
2147
2148        let dir = tempfile::tempdir().unwrap();
2149        let sock = dir.path().join("test.sock");
2150
2151        let handle = start_server(&sock, dir.path(), 5);
2152        tokio::time::sleep(Duration::from_millis(50)).await;
2153
2154        // Send an oversized frame (20 MB > 10 MB limit)
2155        let mut stream = UnixStream::connect(&sock).await.unwrap();
2156        let oversized_len: u32 = 20 * 1024 * 1024;
2157        stream.write_u32(oversized_len).await.unwrap();
2158        stream.flush().await.unwrap();
2159
2160        // The connection should be closed by the server
2161        let result = stream.read_u32().await;
2162        assert!(
2163            result.is_err(),
2164            "server should close connection on oversized frame"
2165        );
2166
2167        // Daemon should still be running after rejecting the bad frame
2168        let resp = send_request(&sock, &Request::Status).await;
2169        assert!(resp.success, "daemon should still accept valid requests");
2170
2171        send_request(&sock, &Request::DaemonStop).await;
2172        let _ = handle.await;
2173    }
2174}