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
26const INDEXING_GRACE_PERIOD_SECS: u64 = 60;
28
29pub 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 pub package_roots: Vec<(Language, PathBuf)>,
39 pub index: std::sync::Mutex<Option<IndexStore>>,
41 pub dirty_files: DirtyFiles,
43 pub watcher_active: bool,
45 _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 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 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 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 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 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
153fn 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
170pub 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 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 if matches!(
227 state.config_source,
228 ConfigSource::KraitToml | ConfigSource::LegacyKraitToml
229 ) {
230 spawn_background_boot(Arc::clone(&state));
231 }
232
233 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
290fn 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#[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 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 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
487async 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 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 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 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
601async 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 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 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 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
715fn 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
736fn 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
745fn 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
760fn 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 let root_ignore = project_root.join(".gitignore");
772 if root_ignore.exists() {
773 let _ = builder.add(&root_ignore);
774 }
775
776 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, false)
792 .is_ignore()
793 })
794 .collect()
795}
796
797async fn enrich_with_symbols(refs: &mut [find::ReferenceMatch], state: &DaemonState) {
802 use crate::commands::list;
803 use std::collections::HashMap;
804
805 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
845async fn handle_list_symbols(path: &Path, depth: u8, state: &DaemonState) -> Response {
847 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 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 let mut guard = match state.pool.route_for_file(path).await {
885 Ok(g) => g,
886 Err(e) => {
887 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
921async 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 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 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 _ => {} }
982 }
983
984 Response::ok(json!({"dir": true, "files": file_entries}))
985}
986
987#[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 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 if let Err(e) = store_workspaces(&db_path, state) {
1015 debug!("init: failed to store workspaces: {e}");
1016 }
1017
1018 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 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 #[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 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 state.refresh_index();
1067 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
1090fn 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 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
1108async 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 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
1190fn 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
1202fn 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
1221fn 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 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
1249fn 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#[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 if let Some(filter) = path_filter {
1286 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 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 }
1336
1337 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 let search_name = name.split('.').next().unwrap_or(name);
1363
1364 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 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 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 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
1440async 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 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
1495enum EditKind {
1498 Replace,
1499 InsertAfter,
1500 InsertBefore,
1501}
1502
1503async 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; }
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
1599async 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 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 let Ok(mut guard) = state.pool.get_or_start(lang).await else {
1625 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 let _ = session
1654 .file_tracker
1655 .reopen(file_path, session.client.transport_mut())
1656 .await;
1657
1658 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
1691async 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
1743async 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
1778async 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
1829async 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 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 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 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 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 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 let result = stream.read_u32().await;
2202 assert!(
2203 result.is_err(),
2204 "server should close connection on oversized frame"
2205 );
2206
2207 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}