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