1use std::cell::{Ref, RefCell, RefMut};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::{mpsc, Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use lsp_types::FileChangeType;
8use notify::RecommendedWatcher;
9
10use crate::backup::BackupStore;
11use crate::bash_background::{BgCompletion, BgTaskRegistry};
12use crate::callgraph::CallGraph;
13use crate::checkpoint::CheckpointStore;
14use crate::config::Config;
15use crate::language::LanguageProvider;
16use crate::lsp::manager::LspManager;
17use crate::lsp::registry::is_config_file_path_with_custom;
18use crate::parser::{SharedSymbolCache, SymbolCache};
19use crate::protocol::{ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload};
20
21pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
22pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
23pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
24const STATUS_DEBOUNCE_MS: u64 = 1_000;
25
26pub struct StatusEmitter {
27 latest: Arc<Mutex<Option<StatusPayload>>>,
28 notify: mpsc::Sender<()>,
29}
30
31impl StatusEmitter {
32 fn new(progress_sender: SharedProgressSender) -> Self {
33 let (notify, rx) = mpsc::channel();
34 let latest = Arc::new(Mutex::new(None));
35 let latest_for_thread = Arc::clone(&latest);
36 std::thread::spawn(move || {
37 status_debounce_loop(rx, latest_for_thread, progress_sender);
38 });
39 Self { latest, notify }
40 }
41
42 pub fn signal(&self, snapshot: StatusPayload) {
43 if let Ok(mut latest) = self.latest.lock() {
44 *latest = Some(snapshot);
45 }
46 let _ = self.notify.send(());
47 }
48}
49
50fn status_debounce_loop(
51 rx: mpsc::Receiver<()>,
52 latest: Arc<Mutex<Option<StatusPayload>>>,
53 progress_sender: SharedProgressSender,
54) {
55 while rx.recv().is_ok() {
56 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
57 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
58 match rx.recv_timeout(remaining) {
59 Ok(()) => continue,
60 Err(mpsc::RecvTimeoutError::Timeout) => break,
61 Err(mpsc::RecvTimeoutError::Disconnected) => return,
62 }
63 }
64
65 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
66 let Some(snapshot) = snapshot else { continue };
67 let sender = progress_sender
68 .lock()
69 .ok()
70 .and_then(|sender| sender.clone());
71 if let Some(sender) = sender {
72 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
73 None, snapshot,
74 )));
75 }
76 }
77}
78use crate::search_index::SearchIndex;
79use crate::semantic_index::SemanticIndex;
80
81#[derive(Debug, Clone)]
82pub enum SemanticIndexStatus {
83 Disabled,
84 Building {
85 stage: String,
86 files: Option<usize>,
87 entries_done: Option<usize>,
88 entries_total: Option<usize>,
89 },
90 Ready,
91 Failed(String),
92}
93
94pub enum SemanticIndexEvent {
95 Progress {
96 stage: String,
97 files: Option<usize>,
98 entries_done: Option<usize>,
99 entries_total: Option<usize>,
100 },
101 Ready(SemanticIndex),
102 Failed(String),
103}
104
105fn normalize_path(path: &Path) -> PathBuf {
109 let mut result = PathBuf::new();
110 for component in path.components() {
111 match component {
112 Component::ParentDir => {
113 if !result.pop() {
115 result.push(component);
116 }
117 }
118 Component::CurDir => {} _ => result.push(component),
120 }
121 }
122 result
123}
124
125fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
126 let mut existing = path.to_path_buf();
127 let mut tail_segments = Vec::new();
128
129 while !existing.exists() {
130 if let Some(name) = existing.file_name() {
131 tail_segments.push(name.to_owned());
132 } else {
133 break;
134 }
135
136 existing = match existing.parent() {
137 Some(parent) => parent.to_path_buf(),
138 None => break,
139 };
140 }
141
142 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
143 for segment in tail_segments.into_iter().rev() {
144 resolved.push(segment);
145 }
146
147 resolved
148}
149
150fn path_error_response(
151 req_id: &str,
152 path: &Path,
153 resolved_root: &Path,
154) -> crate::protocol::Response {
155 crate::protocol::Response::error(
156 req_id,
157 "path_outside_root",
158 format!(
159 "path '{}' is outside the project root '{}'",
160 path.display(),
161 resolved_root.display()
162 ),
163 )
164}
165
166fn reject_escaping_symlink(
176 req_id: &str,
177 original_path: &Path,
178 candidate: &Path,
179 resolved_root: &Path,
180 raw_root: &Path,
181) -> Result<(), crate::protocol::Response> {
182 let mut current = PathBuf::new();
183
184 for component in candidate.components() {
185 current.push(component);
186
187 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
188 continue;
189 };
190
191 if !metadata.file_type().is_symlink() {
192 continue;
193 }
194
195 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
204 if !inside_root {
205 continue;
206 }
207
208 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
209 }
210
211 Ok(())
212}
213
214fn iterative_follow_chain(
217 req_id: &str,
218 original_path: &Path,
219 start: &Path,
220 resolved_root: &Path,
221) -> Result<(), crate::protocol::Response> {
222 let mut link = start.to_path_buf();
223 let mut depth = 0usize;
224
225 loop {
226 if depth > 40 {
227 return Err(path_error_response(req_id, original_path, resolved_root));
228 }
229
230 let target = match std::fs::read_link(&link) {
231 Ok(t) => t,
232 Err(_) => {
233 return Err(path_error_response(req_id, original_path, resolved_root));
235 }
236 };
237
238 let resolved_target = if target.is_absolute() {
239 normalize_path(&target)
240 } else {
241 let parent = link.parent().unwrap_or_else(|| Path::new(""));
242 normalize_path(&parent.join(&target))
243 };
244
245 let canonical_target =
249 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
250
251 if !canonical_target.starts_with(resolved_root)
252 && !resolved_target.starts_with(resolved_root)
253 {
254 return Err(path_error_response(req_id, original_path, resolved_root));
255 }
256
257 match std::fs::symlink_metadata(&resolved_target) {
259 Ok(meta) if meta.file_type().is_symlink() => {
260 link = resolved_target;
261 depth += 1;
262 }
263 _ => break, }
265 }
266
267 Ok(())
268}
269
270pub struct AppContext {
280 provider: Box<dyn LanguageProvider>,
281 backup: RefCell<BackupStore>,
282 checkpoint: RefCell<CheckpointStore>,
283 config: RefCell<Config>,
284 canonical_cache_root: RefCell<Option<PathBuf>>,
285 is_worktree_bridge: RefCell<bool>,
286 git_common_dir: RefCell<Option<PathBuf>>,
287 degraded_reasons: RefCell<Vec<String>>,
295 callgraph: RefCell<Option<CallGraph>>,
296 search_index: RefCell<Option<SearchIndex>>,
297 search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
298 symbol_cache: SharedSymbolCache,
299 semantic_index: RefCell<Option<SemanticIndex>>,
300 semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
301 semantic_index_status: RefCell<SemanticIndexStatus>,
302 semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
303 watcher: RefCell<Option<RecommendedWatcher>>,
304 watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
305 lsp_manager: RefCell<LspManager>,
306 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
310 stdout_writer: SharedStdoutWriter,
311 progress_sender: SharedProgressSender,
312 status_emitter: StatusEmitter,
313 bash_background: BgTaskRegistry,
314 filter_registry: crate::compress::SharedFilterRegistry,
321 filter_registry_loaded: std::sync::atomic::AtomicBool,
324 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
329 gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
336}
337
338impl AppContext {
339 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
340 let bash_compress_enabled = config.experimental_bash_compress;
341 let progress_sender = Arc::new(Mutex::new(None));
342 let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
343 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
344 let symbol_cache = provider
345 .as_any()
346 .downcast_ref::<crate::parser::TreeSitterProvider>()
347 .map(|provider| provider.symbol_cache())
348 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
349 let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
350 let mut lsp_manager = LspManager::new();
351 lsp_manager.set_child_registry(lsp_child_registry.clone());
352 AppContext {
353 provider,
354 backup: RefCell::new(BackupStore::new()),
355 checkpoint: RefCell::new(CheckpointStore::new()),
356 config: RefCell::new(config),
357 canonical_cache_root: RefCell::new(None),
358 is_worktree_bridge: RefCell::new(false),
359 git_common_dir: RefCell::new(None),
360 degraded_reasons: RefCell::new(Vec::new()),
361 callgraph: RefCell::new(None),
362 search_index: RefCell::new(None),
363 search_index_rx: RefCell::new(None),
364 symbol_cache,
365 semantic_index: RefCell::new(None),
366 semantic_index_rx: RefCell::new(None),
367 semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
368 semantic_embedding_model: RefCell::new(None),
369 watcher: RefCell::new(None),
370 watcher_rx: RefCell::new(None),
371 lsp_manager: RefCell::new(lsp_manager),
372 lsp_child_registry,
373 stdout_writer,
374 progress_sender: Arc::clone(&progress_sender),
375 status_emitter,
376 bash_background: BgTaskRegistry::new(progress_sender),
377 filter_registry: Arc::new(std::sync::RwLock::new(
378 crate::compress::toml_filter::FilterRegistry::default(),
379 )),
380 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
381 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
382 gitignore: RefCell::new(None),
383 }
384 }
385
386 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
389 self.gitignore.borrow().clone()
390 }
391
392 pub fn clear_gitignore(&self) {
413 *self.gitignore.borrow_mut() = None;
414 }
415
416 pub fn rebuild_gitignore(&self) {
417 use ignore::gitignore::GitignoreBuilder;
418 use std::path::Path;
419 let root_raw = match self.config().project_root.clone() {
420 Some(r) => r,
421 None => {
422 *self.gitignore.borrow_mut() = None;
423 return;
424 }
425 };
426 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
434 let mut builder = GitignoreBuilder::new(&root);
435 let root_ignore = Path::new(&root).join(".gitignore");
437 if root_ignore.exists() {
438 if let Some(err) = builder.add(&root_ignore) {
439 crate::slog_warn!(
440 "gitignore parse error in {}: {}",
441 root_ignore.display(),
442 err
443 );
444 }
445 }
446 let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
449 if info_exclude.exists() {
450 if let Some(err) = builder.add(&info_exclude) {
451 crate::slog_warn!(
452 "gitignore parse error in {}: {}",
453 info_exclude.display(),
454 err
455 );
456 }
457 }
458 let walker = ignore::WalkBuilder::new(&root)
464 .standard_filters(true)
465 .hidden(false)
468 .max_depth(Some(8))
469 .filter_entry(|entry| {
470 let name = entry.file_name().to_string_lossy();
471 !matches!(
472 name.as_ref(),
473 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
474 )
475 })
476 .build();
477 for entry in walker.flatten() {
478 if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
479 if let Some(err) = builder.add(entry.path()) {
480 crate::slog_warn!(
481 "nested gitignore parse error in {}: {}",
482 entry.path().display(),
483 err
484 );
485 }
486 }
487 }
488 match builder.build() {
489 Ok(gi) => {
490 let count = gi.num_ignores();
491 if count > 0 {
492 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
493 *self.gitignore.borrow_mut() = Some(Arc::new(gi));
494 } else {
495 *self.gitignore.borrow_mut() = None;
496 }
497 }
498 Err(err) => {
499 crate::slog_warn!("gitignore matcher build failed: {}", err);
500 *self.gitignore.borrow_mut() = None;
501 }
502 }
503 }
504
505 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
508 Arc::clone(&self.bash_compress_flag)
509 }
510
511 pub fn sync_bash_compress_flag(&self) {
515 let value = self.config().experimental_bash_compress;
516 self.bash_compress_flag
517 .store(value, std::sync::atomic::Ordering::Relaxed);
518 }
519
520 pub fn set_bash_compress_enabled(&self, enabled: bool) {
521 self.config_mut().experimental_bash_compress = enabled;
522 self.bash_compress_flag
523 .store(enabled, std::sync::atomic::Ordering::Relaxed);
524 }
525
526 pub fn filter_registry(
530 &self,
531 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
532 self.ensure_filter_registry_loaded();
533 match self.filter_registry.read() {
534 Ok(g) => g,
535 Err(poisoned) => poisoned.into_inner(),
536 }
537 }
538
539 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
543 self.ensure_filter_registry_loaded();
544 Arc::clone(&self.filter_registry)
545 }
546
547 pub fn reset_filter_registry(&self) {
551 let new_registry = crate::compress::build_registry_for_context(self);
552 match self.filter_registry.write() {
553 Ok(mut slot) => *slot = new_registry,
554 Err(poisoned) => *poisoned.into_inner() = new_registry,
555 }
556 self.filter_registry_loaded
557 .store(true, std::sync::atomic::Ordering::Release);
558 }
559
560 fn ensure_filter_registry_loaded(&self) {
561 use std::sync::atomic::Ordering;
562 if self.filter_registry_loaded.load(Ordering::Acquire) {
563 return;
564 }
565 let new_registry = crate::compress::build_registry_for_context(self);
568 if let Ok(mut slot) = self.filter_registry.write() {
569 *slot = new_registry;
570 self.filter_registry_loaded.store(true, Ordering::Release);
571 }
572 }
573
574 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
577 self.lsp_child_registry.clone()
578 }
579
580 pub fn stdout_writer(&self) -> SharedStdoutWriter {
581 Arc::clone(&self.stdout_writer)
582 }
583
584 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
585 if let Ok(mut progress_sender) = self.progress_sender.lock() {
586 *progress_sender = sender;
587 }
588 }
589
590 pub fn emit_progress(&self, frame: ProgressFrame) {
591 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
592 return;
593 };
594 if let Some(sender) = progress_sender.as_ref() {
595 sender(PushFrame::Progress(frame));
596 }
597 }
598
599 pub fn status_emitter(&self) -> &StatusEmitter {
600 &self.status_emitter
601 }
602
603 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
611 self.progress_sender
612 .lock()
613 .ok()
614 .and_then(|sender| sender.clone())
615 }
616
617 pub fn bash_background(&self) -> &BgTaskRegistry {
618 &self.bash_background
619 }
620
621 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
622 self.bash_background.drain_completions()
623 }
624
625 pub fn provider(&self) -> &dyn LanguageProvider {
627 self.provider.as_ref()
628 }
629
630 pub fn backup(&self) -> &RefCell<BackupStore> {
632 &self.backup
633 }
634
635 pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
637 &self.checkpoint
638 }
639
640 pub fn config(&self) -> Ref<'_, Config> {
642 self.config.borrow()
643 }
644
645 pub fn config_mut(&self) -> RefMut<'_, Config> {
647 self.config.borrow_mut()
648 }
649
650 pub fn set_canonical_cache_root(&self, root: PathBuf) {
651 debug_assert!(root.is_absolute());
652 *self.canonical_cache_root.borrow_mut() = Some(root);
653 }
654
655 pub fn canonical_cache_root(&self) -> PathBuf {
656 self.canonical_cache_root
657 .borrow()
658 .clone()
659 .expect("canonical_cache_root accessed before handle_configure")
660 }
661
662 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
663 self.canonical_cache_root.borrow().clone()
664 }
665
666 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
667 *self.is_worktree_bridge.borrow_mut() = is_worktree_bridge;
668 *self.git_common_dir.borrow_mut() = git_common_dir;
669 }
670
671 pub fn is_worktree_bridge(&self) -> bool {
672 *self.is_worktree_bridge.borrow()
673 }
674
675 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
679 *self.degraded_reasons.borrow_mut() = reasons;
680 }
681
682 pub fn degraded_reasons(&self) -> Vec<String> {
686 self.degraded_reasons.borrow().clone()
687 }
688
689 pub fn is_degraded(&self) -> bool {
691 !self.degraded_reasons.borrow().is_empty()
692 }
693
694 pub fn cache_role(&self) -> &'static str {
695 if self.canonical_cache_root.borrow().is_none() {
696 "not_initialized"
697 } else if self.is_worktree_bridge() {
698 "worktree"
699 } else {
700 "main"
701 }
702 }
703
704 pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
706 &self.callgraph
707 }
708
709 pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
711 &self.search_index
712 }
713
714 pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
716 &self.search_index_rx
717 }
718
719 pub fn symbol_cache(&self) -> SharedSymbolCache {
721 Arc::clone(&self.symbol_cache)
722 }
723
724 pub fn reset_symbol_cache(&self) -> u64 {
726 self.symbol_cache
727 .write()
728 .map(|mut cache| cache.reset())
729 .unwrap_or(0)
730 }
731
732 pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
734 &self.semantic_index
735 }
736
737 pub fn semantic_index_rx(
739 &self,
740 ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
741 &self.semantic_index_rx
742 }
743
744 pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
745 &self.semantic_index_status
746 }
747
748 pub fn semantic_embedding_model(
750 &self,
751 ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
752 &self.semantic_embedding_model
753 }
754
755 pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
757 &self.watcher
758 }
759
760 pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
762 &self.watcher_rx
763 }
764
765 pub fn lsp(&self) -> RefMut<'_, LspManager> {
767 self.lsp_manager.borrow_mut()
768 }
769
770 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
773 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
774 let config = self.config();
775 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
776 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
777 }
778 }
779 }
780
781 pub fn lsp_notify_and_collect_diagnostics(
792 &self,
793 file_path: &Path,
794 content: &str,
795 timeout: std::time::Duration,
796 ) -> crate::lsp::manager::PostEditWaitOutcome {
797 let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
798 return crate::lsp::manager::PostEditWaitOutcome::default();
799 };
800
801 lsp.drain_events();
804
805 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
809
810 let config = self.config();
812 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
813 {
814 Ok(v) => v,
815 Err(e) => {
816 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
817 return crate::lsp::manager::PostEditWaitOutcome::default();
818 }
819 };
820
821 if expected_versions.is_empty() {
824 return crate::lsp::manager::PostEditWaitOutcome::default();
825 }
826
827 lsp.wait_for_post_edit_diagnostics(
828 file_path,
829 &config,
830 &expected_versions,
831 &pre_snapshot,
832 timeout,
833 )
834 }
835
836 fn custom_lsp_root_markers(&self) -> Vec<String> {
839 self.config()
840 .lsp_servers
841 .iter()
842 .flat_map(|s| s.root_markers.iter().cloned())
843 .collect()
844 }
845
846 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
847 let custom_markers = self.custom_lsp_root_markers();
848 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
849 .iter()
850 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
851 .cloned()
852 .map(|path| {
853 let change_type = if path.exists() {
854 FileChangeType::CHANGED
855 } else {
856 FileChangeType::DELETED
857 };
858 (path, change_type)
859 })
860 .collect();
861
862 self.notify_watched_config_events(&config_paths);
863 }
864
865 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
866 let paths = params
867 .get("multi_file_write_paths")
868 .and_then(|value| value.as_array())?
869 .iter()
870 .filter_map(|value| value.as_str())
871 .map(PathBuf::from)
872 .collect::<Vec<_>>();
873
874 (!paths.is_empty()).then_some(paths)
875 }
876
877 fn watched_file_events_from_params(
889 params: &serde_json::Value,
890 extra_markers: &[String],
891 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
892 let events = params
893 .get("multi_file_write_paths")
894 .and_then(|value| value.as_array())?
895 .iter()
896 .filter_map(|entry| {
897 let path = entry
899 .get("path")
900 .and_then(|value| value.as_str())
901 .map(PathBuf::from)?;
902
903 if !is_config_file_path_with_custom(&path, extra_markers) {
904 return None;
905 }
906
907 let change_type = entry
908 .get("type")
909 .and_then(|value| value.as_str())
910 .and_then(Self::parse_file_change_type)
911 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
912
913 Some((path, change_type))
914 })
915 .collect::<Vec<_>>();
916
917 (!events.is_empty()).then_some(events)
918 }
919
920 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
921 match value {
922 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
923 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
924 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
925 _ => None,
926 }
927 }
928
929 fn change_type_from_current_state(path: &Path) -> FileChangeType {
930 if path.exists() {
931 FileChangeType::CHANGED
932 } else {
933 FileChangeType::DELETED
934 }
935 }
936
937 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
938 if config_paths.is_empty() {
939 return;
940 }
941
942 if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
943 let config = self.config();
944 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
945 crate::slog_warn!("watched-file sync error: {}", e);
946 }
947 }
948 }
949
950 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
951 let custom_markers = self.custom_lsp_root_markers();
952 if !is_config_file_path_with_custom(file_path, &custom_markers) {
953 return;
954 }
955
956 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
957 }
958
959 pub fn lsp_post_multi_file_write(
964 &self,
965 file_path: &Path,
966 content: &str,
967 file_paths: &[PathBuf],
968 params: &serde_json::Value,
969 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
970 self.notify_watched_config_files(file_paths);
971
972 let wants_diagnostics = params
973 .get("diagnostics")
974 .and_then(|v| v.as_bool())
975 .unwrap_or(false);
976
977 if !wants_diagnostics {
978 self.lsp_notify_file_changed(file_path, content);
979 return None;
980 }
981
982 let wait_ms = params
983 .get("wait_ms")
984 .and_then(|v| v.as_u64())
985 .unwrap_or(3000)
986 .min(10_000);
987
988 Some(self.lsp_notify_and_collect_diagnostics(
989 file_path,
990 content,
991 std::time::Duration::from_millis(wait_ms),
992 ))
993 }
994
995 pub fn lsp_post_write(
1012 &self,
1013 file_path: &Path,
1014 content: &str,
1015 params: &serde_json::Value,
1016 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
1017 let wants_diagnostics = params
1018 .get("diagnostics")
1019 .and_then(|v| v.as_bool())
1020 .unwrap_or(false);
1021
1022 let custom_markers = self.custom_lsp_root_markers();
1023
1024 if !wants_diagnostics {
1025 if let Some(file_paths) = Self::multi_file_write_paths(params) {
1026 self.notify_watched_config_files(&file_paths);
1027 } else if let Some(config_events) =
1028 Self::watched_file_events_from_params(params, &custom_markers)
1029 {
1030 self.notify_watched_config_events(&config_events);
1031 }
1032 self.lsp_notify_file_changed(file_path, content);
1033 return None;
1034 }
1035
1036 let wait_ms = params
1037 .get("wait_ms")
1038 .and_then(|v| v.as_u64())
1039 .unwrap_or(3000)
1040 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
1043 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
1044 }
1045
1046 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
1047 {
1048 self.notify_watched_config_events(&config_events);
1049 }
1050
1051 Some(self.lsp_notify_and_collect_diagnostics(
1052 file_path,
1053 content,
1054 std::time::Duration::from_millis(wait_ms),
1055 ))
1056 }
1057
1058 pub fn validate_path(
1067 &self,
1068 req_id: &str,
1069 path: &Path,
1070 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
1071 let config = self.config();
1072 if !config.restrict_to_project_root {
1074 return Ok(path.to_path_buf());
1075 }
1076 let root = match &config.project_root {
1077 Some(r) => r.clone(),
1078 None => return Ok(path.to_path_buf()), };
1080 drop(config);
1081
1082 let raw_root = root.clone();
1087 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
1088
1089 let path_for_resolution = if path.is_relative() {
1094 raw_root.join(path)
1095 } else {
1096 path.to_path_buf()
1097 };
1098 let resolved = match std::fs::canonicalize(&path_for_resolution) {
1099 Ok(resolved) => resolved,
1100 Err(_) => {
1101 let normalized = normalize_path(&path_for_resolution);
1102 reject_escaping_symlink(
1103 req_id,
1104 &path_for_resolution,
1105 &normalized,
1106 &resolved_root,
1107 &raw_root,
1108 )?;
1109 resolve_with_existing_ancestors(&normalized)
1110 }
1111 };
1112
1113 if !resolved.starts_with(&resolved_root) {
1114 return Err(path_error_response(req_id, path, &resolved_root));
1115 }
1116
1117 Ok(resolved)
1118 }
1119
1120 pub fn lsp_server_count(&self) -> usize {
1122 self.lsp_manager
1123 .try_borrow()
1124 .map(|lsp| lsp.server_count())
1125 .unwrap_or(0)
1126 }
1127
1128 pub fn symbol_cache_stats(&self) -> serde_json::Value {
1130 let entries = self
1131 .symbol_cache
1132 .read()
1133 .map(|cache| cache.len())
1134 .unwrap_or(0);
1135 serde_json::json!({
1136 "local_entries": entries,
1137 "warm_entries": 0,
1138 })
1139 }
1140}
1141
1142#[cfg(test)]
1143mod status_emitter_tests {
1144 use super::*;
1145 use crate::parser::TreeSitterProvider;
1146
1147 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
1148 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
1149 let (tx, rx) = mpsc::channel();
1150 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
1151 let _ = tx.send(frame);
1152 }))));
1153 (ctx, rx)
1154 }
1155
1156 #[test]
1157 fn status_emitter_signal_triggers_push() {
1158 let (ctx, rx) = ctx_with_frame_rx();
1159 ctx.status_emitter().signal(ctx.build_status_snapshot());
1160 let frame = rx
1161 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1162 .expect("status_changed push");
1163 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1164 }
1165
1166 #[test]
1167 fn status_emitter_debounces_burst() {
1168 let (ctx, rx) = ctx_with_frame_rx();
1169 for _ in 0..10 {
1170 ctx.status_emitter().signal(ctx.build_status_snapshot());
1171 }
1172 let frame = rx
1173 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1174 .expect("status_changed push");
1175 assert!(matches!(frame, PushFrame::StatusChanged(_)));
1176 assert!(rx.try_recv().is_err());
1177 }
1178
1179 #[test]
1180 fn status_emitter_separate_windows_separate_pushes() {
1181 let (ctx, rx) = ctx_with_frame_rx();
1182 ctx.status_emitter().signal(ctx.build_status_snapshot());
1183 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1184 .expect("first push");
1185 ctx.status_emitter().signal(ctx.build_status_snapshot());
1186 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
1187 .expect("second push");
1188 }
1189
1190 #[test]
1191 fn status_emitter_no_signal_no_push() {
1192 let (_ctx, rx) = ctx_with_frame_rx();
1193 assert!(rx
1194 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
1195 .is_err());
1196 }
1197
1198 #[test]
1199 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
1200 let (ctx, rx) = ctx_with_frame_rx();
1201 drop(ctx);
1202 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
1203 }
1204}
1205
1206#[cfg(test)]
1207mod gitignore_tests {
1208 use super::*;
1209 use std::fs;
1210 use std::path::Path;
1211 use tempfile::TempDir;
1212
1213 fn make_ctx_with_root(root: &Path) -> AppContext {
1214 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1215 let config = Config {
1216 project_root: Some(root.to_path_buf()),
1217 ..Config::default()
1218 };
1219 AppContext::new(provider, config)
1220 }
1221
1222 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1229 let Some(matcher) = ctx.gitignore() else {
1230 return false;
1231 };
1232 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1233 if !canonical.starts_with(matcher.path()) {
1234 return false;
1235 }
1236 let is_dir = canonical.is_dir();
1237 matcher
1238 .matched_path_or_any_parents(&canonical, is_dir)
1239 .is_ignore()
1240 }
1241
1242 #[test]
1243 fn rebuild_gitignore_returns_none_without_project_root() {
1244 let provider = Box::new(crate::parser::TreeSitterProvider::new());
1245 let ctx = AppContext::new(provider, Config::default());
1246 ctx.rebuild_gitignore();
1247 assert!(ctx.gitignore().is_none());
1248 }
1249
1250 #[test]
1251 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1252 let tmp = TempDir::new().unwrap();
1253 let ctx = make_ctx_with_root(tmp.path());
1254 ctx.rebuild_gitignore();
1255 assert!(ctx.gitignore().is_none());
1256 }
1257
1258 #[test]
1259 fn matcher_filters_files_in_ignored_dist_dir() {
1260 let tmp = TempDir::new().unwrap();
1261 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1262 fs::create_dir_all(tmp.path().join("dist")).unwrap();
1263 fs::create_dir_all(tmp.path().join("src")).unwrap();
1264 let dist_file = tmp.path().join("dist").join("bundle.js");
1265 let src_file = tmp.path().join("src").join("app.ts");
1266 fs::write(&dist_file, "x").unwrap();
1267 fs::write(&src_file, "y").unwrap();
1268
1269 let ctx = make_ctx_with_root(tmp.path());
1270 ctx.rebuild_gitignore();
1271
1272 assert!(ctx.gitignore().is_some());
1273 assert!(
1274 is_ignored(&ctx, &dist_file),
1275 "dist/bundle.js should be ignored"
1276 );
1277 assert!(
1278 !is_ignored(&ctx, &src_file),
1279 "src/app.ts should NOT be ignored"
1280 );
1281 }
1282
1283 #[test]
1284 fn matcher_handles_node_modules_and_target() {
1285 let tmp = TempDir::new().unwrap();
1286 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1287 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1288 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1289 let nm_file = tmp.path().join("node_modules/foo/index.js");
1290 let target_file = tmp.path().join("target/debug/aft");
1291 fs::write(&nm_file, "x").unwrap();
1292 fs::write(&target_file, "x").unwrap();
1293
1294 let ctx = make_ctx_with_root(tmp.path());
1295 ctx.rebuild_gitignore();
1296
1297 assert!(is_ignored(&ctx, &nm_file));
1298 assert!(is_ignored(&ctx, &target_file));
1299 }
1300
1301 #[test]
1302 fn matcher_honors_negation_pattern() {
1303 let tmp = TempDir::new().unwrap();
1305 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1306 let random_log = tmp.path().join("random.log");
1307 let important_log = tmp.path().join("important.log");
1308 fs::write(&random_log, "x").unwrap();
1309 fs::write(&important_log, "y").unwrap();
1310
1311 let ctx = make_ctx_with_root(tmp.path());
1312 ctx.rebuild_gitignore();
1313
1314 assert!(is_ignored(&ctx, &random_log));
1315 assert!(
1316 !is_ignored(&ctx, &important_log),
1317 "negation pattern should un-ignore important.log"
1318 );
1319 }
1320
1321 #[test]
1322 fn rebuild_picks_up_gitignore_changes() {
1323 let tmp = TempDir::new().unwrap();
1324 let ignore_path = tmp.path().join(".gitignore");
1325 fs::write(&ignore_path, "foo.txt\n").unwrap();
1326 let foo = tmp.path().join("foo.txt");
1327 let bar = tmp.path().join("bar.txt");
1328 fs::write(&foo, "").unwrap();
1329 fs::write(&bar, "").unwrap();
1330
1331 let ctx = make_ctx_with_root(tmp.path());
1332 ctx.rebuild_gitignore();
1333 assert!(is_ignored(&ctx, &foo));
1334 assert!(!is_ignored(&ctx, &bar));
1335
1336 fs::write(&ignore_path, "bar.txt\n").unwrap();
1338 ctx.rebuild_gitignore();
1339 assert!(!is_ignored(&ctx, &foo));
1340 assert!(is_ignored(&ctx, &bar));
1341 }
1342
1343 #[test]
1344 fn gitignore_loads_info_exclude_when_present() {
1345 let tmp = TempDir::new().unwrap();
1346 let info_dir = tmp.path().join(".git/info");
1347 fs::create_dir_all(&info_dir).unwrap();
1348 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1349 let secrets = tmp.path().join("secrets.txt");
1350 let public = tmp.path().join("public.txt");
1351 fs::write(&secrets, "token").unwrap();
1352 fs::write(&public, "ok").unwrap();
1353
1354 let ctx = make_ctx_with_root(tmp.path());
1355 ctx.rebuild_gitignore();
1356
1357 assert!(is_ignored(&ctx, &secrets));
1358 assert!(!is_ignored(&ctx, &public));
1359 }
1360
1361 #[test]
1362 fn matcher_picks_up_nested_gitignore() {
1363 let tmp = TempDir::new().unwrap();
1364 fs::write(tmp.path().join(".gitignore"), "").unwrap();
1366 let sub = tmp.path().join("packages/foo");
1367 fs::create_dir_all(&sub).unwrap();
1368 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1369 let generated_file = sub.join("generated").join("out.js");
1370 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1371 fs::write(&generated_file, "x").unwrap();
1372
1373 let ctx = make_ctx_with_root(tmp.path());
1374 ctx.rebuild_gitignore();
1375
1376 assert!(
1377 is_ignored(&ctx, &generated_file),
1378 "nested gitignore in packages/foo/.gitignore should ignore generated/"
1379 );
1380 }
1381}