Skip to main content

aft/
context.rs

1use std::cell::{Ref, RefCell, RefMut};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::{mpsc, Arc, Mutex};
5
6use lsp_types::FileChangeType;
7use notify::RecommendedWatcher;
8
9use crate::backup::BackupStore;
10use crate::bash_background::{BgCompletion, BgTaskRegistry};
11use crate::callgraph::CallGraph;
12use crate::checkpoint::CheckpointStore;
13use crate::config::Config;
14use crate::language::LanguageProvider;
15use crate::lsp::manager::LspManager;
16use crate::lsp::registry::is_config_file_path_with_custom;
17use crate::parser::{SharedSymbolCache, SymbolCache};
18use crate::protocol::{ProgressFrame, PushFrame};
19
20pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
21pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
22pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
23use crate::search_index::SearchIndex;
24use crate::semantic_index::SemanticIndex;
25
26#[derive(Debug, Clone)]
27pub enum SemanticIndexStatus {
28    Disabled,
29    Building {
30        stage: String,
31        files: Option<usize>,
32        entries_done: Option<usize>,
33        entries_total: Option<usize>,
34    },
35    Ready,
36    Failed(String),
37}
38
39pub enum SemanticIndexEvent {
40    Progress {
41        stage: String,
42        files: Option<usize>,
43        entries_done: Option<usize>,
44        entries_total: Option<usize>,
45    },
46    Ready(SemanticIndex),
47    Failed(String),
48}
49
50/// Normalize a path by resolving `.` and `..` components lexically,
51/// without touching the filesystem. This prevents path traversal
52/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
53fn normalize_path(path: &Path) -> PathBuf {
54    let mut result = PathBuf::new();
55    for component in path.components() {
56        match component {
57            Component::ParentDir => {
58                // Pop the last component unless we're at root or have no components
59                if !result.pop() {
60                    result.push(component);
61                }
62            }
63            Component::CurDir => {} // Skip `.`
64            _ => result.push(component),
65        }
66    }
67    result
68}
69
70fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
71    let mut existing = path.to_path_buf();
72    let mut tail_segments = Vec::new();
73
74    while !existing.exists() {
75        if let Some(name) = existing.file_name() {
76            tail_segments.push(name.to_owned());
77        } else {
78            break;
79        }
80
81        existing = match existing.parent() {
82            Some(parent) => parent.to_path_buf(),
83            None => break,
84        };
85    }
86
87    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
88    for segment in tail_segments.into_iter().rev() {
89        resolved.push(segment);
90    }
91
92    resolved
93}
94
95fn path_error_response(
96    req_id: &str,
97    path: &Path,
98    resolved_root: &Path,
99) -> crate::protocol::Response {
100    crate::protocol::Response::error(
101        req_id,
102        "path_outside_root",
103        format!(
104            "path '{}' is outside the project root '{}'",
105            path.display(),
106            resolved_root.display()
107        ),
108    )
109}
110
111/// Walk `candidate` component-by-component. For any component that is a
112/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
113/// reject if any hop's resolved target lies outside `resolved_root`.
114///
115/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
116/// Linux with broken symlink chains pointing to non-existent destinations).
117/// On macOS `canonicalize` also fails for broken symlinks but the returned
118/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
119/// form, so we must accept either form when deciding which symlinks to check.
120fn reject_escaping_symlink(
121    req_id: &str,
122    original_path: &Path,
123    candidate: &Path,
124    resolved_root: &Path,
125    raw_root: &Path,
126) -> Result<(), crate::protocol::Response> {
127    let mut current = PathBuf::new();
128
129    for component in candidate.components() {
130        current.push(component);
131
132        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
133            continue;
134        };
135
136        if !metadata.file_type().is_symlink() {
137            continue;
138        }
139
140        // Only check symlinks that live inside the project root. This skips
141        // OS-level prefix symlinks (macOS /var → /private/var) that are not
142        // inside our project directory and whose "escaping" is harmless.
143        //
144        // We compare against BOTH the canonicalized root (resolved_root, e.g.
145        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
146        // because tempdir() returns raw paths while fs::canonicalize returns
147        // the resolved form — and our `current` may be in either form.
148        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
149        if !inside_root {
150            continue;
151        }
152
153        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
154    }
155
156    Ok(())
157}
158
159/// Iteratively follow a symlink chain from `link` and reject if any hop's
160/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
161fn iterative_follow_chain(
162    req_id: &str,
163    original_path: &Path,
164    start: &Path,
165    resolved_root: &Path,
166) -> Result<(), crate::protocol::Response> {
167    let mut link = start.to_path_buf();
168    let mut depth = 0usize;
169
170    loop {
171        if depth > 40 {
172            return Err(path_error_response(req_id, original_path, resolved_root));
173        }
174
175        let target = match std::fs::read_link(&link) {
176            Ok(t) => t,
177            Err(_) => {
178                // Can't read the link — treat as escaping to be safe.
179                return Err(path_error_response(req_id, original_path, resolved_root));
180            }
181        };
182
183        let resolved_target = if target.is_absolute() {
184            normalize_path(&target)
185        } else {
186            let parent = link.parent().unwrap_or_else(|| Path::new(""));
187            normalize_path(&parent.join(&target))
188        };
189
190        // Check boundary: use canonicalized target when available (handles
191        // macOS /var → /private/var aliasing), fall back to the normalized
192        // path when canonicalize fails (e.g. broken symlink on Linux).
193        let canonical_target =
194            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
195
196        if !canonical_target.starts_with(resolved_root)
197            && !resolved_target.starts_with(resolved_root)
198        {
199            return Err(path_error_response(req_id, original_path, resolved_root));
200        }
201
202        // If the target is itself a symlink, follow the next hop.
203        match std::fs::symlink_metadata(&resolved_target) {
204            Ok(meta) if meta.file_type().is_symlink() => {
205                link = resolved_target;
206                depth += 1;
207            }
208            _ => break, // Non-symlink or non-existent target — chain ends here.
209        }
210    }
211
212    Ok(())
213}
214
215/// Shared application context threaded through all command handlers.
216///
217/// Holds the language provider, backup/checkpoint stores, configuration,
218/// and call graph engine. Constructed once at startup and passed by
219/// reference to `dispatch`.
220///
221/// Stores use `RefCell` for interior mutability — the binary is single-threaded
222/// (one request at a time on the stdin read loop) so runtime borrow checking
223/// is safe and never contended.
224pub struct AppContext {
225    provider: Box<dyn LanguageProvider>,
226    backup: RefCell<BackupStore>,
227    checkpoint: RefCell<CheckpointStore>,
228    config: RefCell<Config>,
229    callgraph: RefCell<Option<CallGraph>>,
230    search_index: RefCell<Option<SearchIndex>>,
231    search_index_rx: RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>>,
232    symbol_cache: SharedSymbolCache,
233    semantic_index: RefCell<Option<SemanticIndex>>,
234    semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
235    semantic_index_status: RefCell<SemanticIndexStatus>,
236    semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
237    watcher: RefCell<Option<RecommendedWatcher>>,
238    watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
239    lsp_manager: RefCell<LspManager>,
240    /// Shared registry of LSP child PIDs. Cloned and passed to the signal
241    /// handler so it can SIGKILL all children before aft exits, preventing
242    /// orphaned LSP processes when bridge.shutdown() SIGTERMs aft.
243    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
244    stdout_writer: SharedStdoutWriter,
245    progress_sender: SharedProgressSender,
246    bash_background: BgTaskRegistry,
247    /// Thread-safe registry of TOML output filters. Lazy-built on first
248    /// access; populated atomically via `RwLock`. Shared between command
249    /// handlers (which use it through `filter_registry()` -> read guard) and
250    /// the `BgTaskRegistry` watchdog thread (which uses it through
251    /// `compress::compress_with_registry`). Reloaded when configure changes
252    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
253    filter_registry: crate::compress::SharedFilterRegistry,
254    /// Set to true once the filter_registry has been populated. Avoids
255    /// double-loading on hot paths without holding a write lock.
256    filter_registry_loaded: std::sync::atomic::AtomicBool,
257    /// Live `experimental.bash.compress` flag, kept in sync with `config`
258    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
259    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
260    /// holding the config refcell.
261    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
262}
263
264impl AppContext {
265    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
266        let bash_compress_enabled = config.experimental_bash_compress;
267        let progress_sender = Arc::new(Mutex::new(None));
268        let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
269        let symbol_cache = provider
270            .as_any()
271            .downcast_ref::<crate::parser::TreeSitterProvider>()
272            .map(|provider| provider.symbol_cache())
273            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
274        let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
275        let mut lsp_manager = LspManager::new();
276        lsp_manager.set_child_registry(lsp_child_registry.clone());
277        AppContext {
278            provider,
279            backup: RefCell::new(BackupStore::new()),
280            checkpoint: RefCell::new(CheckpointStore::new()),
281            config: RefCell::new(config),
282            callgraph: RefCell::new(None),
283            search_index: RefCell::new(None),
284            search_index_rx: RefCell::new(None),
285            symbol_cache,
286            semantic_index: RefCell::new(None),
287            semantic_index_rx: RefCell::new(None),
288            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
289            semantic_embedding_model: RefCell::new(None),
290            watcher: RefCell::new(None),
291            watcher_rx: RefCell::new(None),
292            lsp_manager: RefCell::new(lsp_manager),
293            lsp_child_registry,
294            stdout_writer,
295            progress_sender: Arc::clone(&progress_sender),
296            bash_background: BgTaskRegistry::new(progress_sender),
297            filter_registry: Arc::new(std::sync::RwLock::new(
298                crate::compress::toml_filter::FilterRegistry::default(),
299            )),
300            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
301            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
302        }
303    }
304
305    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
306    /// configure handler. Read by the BgTaskRegistry compressor closure.
307    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
308        Arc::clone(&self.bash_compress_flag)
309    }
310
311    /// Update the shared `bash_compress_flag` mirror. Call this from the
312    /// configure handler whenever `experimental.bash.compress` changes so the
313    /// BgTaskRegistry watchdog sees the new value on the next completion.
314    pub fn sync_bash_compress_flag(&self) {
315        let value = self.config().experimental_bash_compress;
316        self.bash_compress_flag
317            .store(value, std::sync::atomic::Ordering::Relaxed);
318    }
319
320    pub fn set_bash_compress_enabled(&self, enabled: bool) {
321        self.config_mut().experimental_bash_compress = enabled;
322        self.bash_compress_flag
323            .store(enabled, std::sync::atomic::Ordering::Relaxed);
324    }
325
326    /// Read-only access to the TOML filter registry, building it lazily on
327    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
328    /// against directly.
329    pub fn filter_registry(
330        &self,
331    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
332        self.ensure_filter_registry_loaded();
333        match self.filter_registry.read() {
334            Ok(g) => g,
335            Err(poisoned) => poisoned.into_inner(),
336        }
337    }
338
339    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
340    /// outside `AppContext` (notably the bash watchdog) can read it without
341    /// touching the rest of the context.
342    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
343        self.ensure_filter_registry_loaded();
344        Arc::clone(&self.filter_registry)
345    }
346
347    /// Force a fresh load of the TOML filter registry. Called when configure
348    /// changes the project root, storage_dir, or trust state so subsequent
349    /// `compress::compress` calls pick up new filters.
350    pub fn reset_filter_registry(&self) {
351        let new_registry = crate::compress::build_registry_for_context(self);
352        match self.filter_registry.write() {
353            Ok(mut slot) => *slot = new_registry,
354            Err(poisoned) => *poisoned.into_inner() = new_registry,
355        }
356        self.filter_registry_loaded
357            .store(true, std::sync::atomic::Ordering::Release);
358    }
359
360    fn ensure_filter_registry_loaded(&self) {
361        use std::sync::atomic::Ordering;
362        if self.filter_registry_loaded.load(Ordering::Acquire) {
363            return;
364        }
365        // Build outside the lock to avoid blocking other readers during a
366        // multi-file TOML parse.
367        let new_registry = crate::compress::build_registry_for_context(self);
368        if let Ok(mut slot) = self.filter_registry.write() {
369            *slot = new_registry;
370            self.filter_registry_loaded.store(true, Ordering::Release);
371        }
372    }
373
374    /// Clone the LSP child registry handle. Used by main.rs to give the
375    /// signal handler thread a way to SIGKILL LSP children on shutdown.
376    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
377        self.lsp_child_registry.clone()
378    }
379
380    pub fn stdout_writer(&self) -> SharedStdoutWriter {
381        Arc::clone(&self.stdout_writer)
382    }
383
384    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
385        if let Ok(mut progress_sender) = self.progress_sender.lock() {
386            *progress_sender = sender;
387        }
388    }
389
390    pub fn emit_progress(&self, frame: ProgressFrame) {
391        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
392            return;
393        };
394        if let Some(sender) = progress_sender.as_ref() {
395            sender(PushFrame::Progress(frame));
396        }
397    }
398
399    /// Get a clone of the current progress sender for use from background
400    /// threads. Returns `None` when the main loop hasn't installed one (tests,
401    /// CLI without push frames).
402    ///
403    /// Used by `configure`'s deferred file-walk thread to push warnings after
404    /// configure has already returned, so configure latency stays sub-100 ms
405    /// even on huge directories.
406    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
407        self.progress_sender
408            .lock()
409            .ok()
410            .and_then(|sender| sender.clone())
411    }
412
413    pub fn bash_background(&self) -> &BgTaskRegistry {
414        &self.bash_background
415    }
416
417    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
418        self.bash_background.drain_completions()
419    }
420
421    /// Access the language provider.
422    pub fn provider(&self) -> &dyn LanguageProvider {
423        self.provider.as_ref()
424    }
425
426    /// Access the backup store.
427    pub fn backup(&self) -> &RefCell<BackupStore> {
428        &self.backup
429    }
430
431    /// Access the checkpoint store.
432    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
433        &self.checkpoint
434    }
435
436    /// Access the configuration (shared borrow).
437    pub fn config(&self) -> Ref<'_, Config> {
438        self.config.borrow()
439    }
440
441    /// Access the configuration (mutable borrow).
442    pub fn config_mut(&self) -> RefMut<'_, Config> {
443        self.config.borrow_mut()
444    }
445
446    /// Access the call graph engine.
447    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
448        &self.callgraph
449    }
450
451    /// Access the search index.
452    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
453        &self.search_index
454    }
455
456    /// Access the search-index build receiver.
457    pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
458        &self.search_index_rx
459    }
460
461    /// Access the shared symbol cache.
462    pub fn symbol_cache(&self) -> SharedSymbolCache {
463        Arc::clone(&self.symbol_cache)
464    }
465
466    /// Clear the shared symbol cache and return the new active generation.
467    pub fn reset_symbol_cache(&self) -> u64 {
468        self.symbol_cache
469            .write()
470            .map(|mut cache| cache.reset())
471            .unwrap_or(0)
472    }
473
474    /// Access the semantic search index.
475    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
476        &self.semantic_index
477    }
478
479    /// Access the semantic-index build receiver.
480    pub fn semantic_index_rx(
481        &self,
482    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
483        &self.semantic_index_rx
484    }
485
486    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
487        &self.semantic_index_status
488    }
489
490    /// Access the cached semantic embedding model.
491    pub fn semantic_embedding_model(
492        &self,
493    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
494        &self.semantic_embedding_model
495    }
496
497    /// Access the file watcher handle (kept alive to continue watching).
498    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
499        &self.watcher
500    }
501
502    /// Access the watcher event receiver.
503    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
504        &self.watcher_rx
505    }
506
507    /// Access the LSP manager.
508    pub fn lsp(&self) -> RefMut<'_, LspManager> {
509        self.lsp_manager.borrow_mut()
510    }
511
512    /// Notify LSP servers that a file was written.
513    /// Call this after write_format_validate in command handlers.
514    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
515        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
516            let config = self.config();
517            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
518                log::warn!("sync error for {}: {}", file_path.display(), e);
519            }
520        }
521    }
522
523    /// Notify LSP and optionally wait for diagnostics.
524    ///
525    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
526    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
527    /// any diagnostics for the file. If no server is running, returns empty immediately.
528    ///
529    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
530    /// are NEVER returned — only entries whose `version` matches the
531    /// post-edit document version (or, for unversioned servers, whose
532    /// `epoch` advanced past the pre-edit snapshot).
533    pub fn lsp_notify_and_collect_diagnostics(
534        &self,
535        file_path: &Path,
536        content: &str,
537        timeout: std::time::Duration,
538    ) -> crate::lsp::manager::PostEditWaitOutcome {
539        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
540            return crate::lsp::manager::PostEditWaitOutcome::default();
541        };
542
543        // Clear any queued notifications before this write so the wait loop only
544        // observes diagnostics triggered by the current change.
545        lsp.drain_events();
546
547        // Snapshot per-server epochs BEFORE sending didChange so the wait
548        // loop can detect freshness via epoch-delta for servers that don't
549        // echo `version` on publishDiagnostics.
550        let pre_snapshot = lsp.snapshot_diagnostic_epochs(file_path);
551
552        // Send didChange/didOpen and capture per-server target version.
553        let config = self.config();
554        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
555        {
556            Ok(v) => v,
557            Err(e) => {
558                log::warn!("sync error for {}: {}", file_path.display(), e);
559                return crate::lsp::manager::PostEditWaitOutcome::default();
560            }
561        };
562
563        // No server matched this file — return an empty outcome that's
564        // honestly `complete: true` (nothing to wait for).
565        if expected_versions.is_empty() {
566            return crate::lsp::manager::PostEditWaitOutcome::default();
567        }
568
569        lsp.wait_for_post_edit_diagnostics(
570            file_path,
571            &config,
572            &expected_versions,
573            &pre_snapshot,
574            timeout,
575        )
576    }
577
578    /// Collect custom server root_markers from user config for use in
579    /// `is_config_file_path_with_custom` checks (#25).
580    fn custom_lsp_root_markers(&self) -> Vec<String> {
581        self.config()
582            .lsp_servers
583            .iter()
584            .flat_map(|s| s.root_markers.iter().cloned())
585            .collect()
586    }
587
588    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
589        let custom_markers = self.custom_lsp_root_markers();
590        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
591            .iter()
592            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
593            .cloned()
594            .map(|path| {
595                let change_type = if path.exists() {
596                    FileChangeType::CHANGED
597                } else {
598                    FileChangeType::DELETED
599                };
600                (path, change_type)
601            })
602            .collect();
603
604        self.notify_watched_config_events(&config_paths);
605    }
606
607    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
608        let paths = params
609            .get("multi_file_write_paths")
610            .and_then(|value| value.as_array())?
611            .iter()
612            .filter_map(|value| value.as_str())
613            .map(PathBuf::from)
614            .collect::<Vec<_>>();
615
616        (!paths.is_empty()).then_some(paths)
617    }
618
619    /// Parse config-file watched events from `multi_file_write_paths` when the
620    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
621    ///
622    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
623    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
624    /// `notify_watched_config_files()`. Both variants read the same JSON key but
625    /// with different per-entry schemas — they are NOT redundant.
626    ///
627    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
628    /// and was reachable via the `else if` branch when all entries were objects.
629    /// Restoring both is correct.
630    fn watched_file_events_from_params(
631        params: &serde_json::Value,
632        extra_markers: &[String],
633    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
634        let events = params
635            .get("multi_file_write_paths")
636            .and_then(|value| value.as_array())?
637            .iter()
638            .filter_map(|entry| {
639                // Only handle object entries — string entries go through multi_file_write_paths()
640                let path = entry
641                    .get("path")
642                    .and_then(|value| value.as_str())
643                    .map(PathBuf::from)?;
644
645                if !is_config_file_path_with_custom(&path, extra_markers) {
646                    return None;
647                }
648
649                let change_type = entry
650                    .get("type")
651                    .and_then(|value| value.as_str())
652                    .and_then(Self::parse_file_change_type)
653                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
654
655                Some((path, change_type))
656            })
657            .collect::<Vec<_>>();
658
659        (!events.is_empty()).then_some(events)
660    }
661
662    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
663        match value {
664            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
665            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
666            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
667            _ => None,
668        }
669    }
670
671    fn change_type_from_current_state(path: &Path) -> FileChangeType {
672        if path.exists() {
673            FileChangeType::CHANGED
674        } else {
675            FileChangeType::DELETED
676        }
677    }
678
679    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
680        if config_paths.is_empty() {
681            return;
682        }
683
684        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
685            let config = self.config();
686            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
687                log::warn!("watched-file sync error: {}", e);
688            }
689        }
690    }
691
692    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
693        let custom_markers = self.custom_lsp_root_markers();
694        if !is_config_file_path_with_custom(file_path, &custom_markers) {
695            return;
696        }
697
698        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
699    }
700
701    /// Post-write LSP hook for multi-file edits. When the patch includes
702    /// config-file edits, notify active workspace servers via
703    /// `workspace/didChangeWatchedFiles` before sending the per-document
704    /// didOpen/didChange for the current file.
705    pub fn lsp_post_multi_file_write(
706        &self,
707        file_path: &Path,
708        content: &str,
709        file_paths: &[PathBuf],
710        params: &serde_json::Value,
711    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
712        self.notify_watched_config_files(file_paths);
713
714        let wants_diagnostics = params
715            .get("diagnostics")
716            .and_then(|v| v.as_bool())
717            .unwrap_or(false);
718
719        if !wants_diagnostics {
720            self.lsp_notify_file_changed(file_path, content);
721            return None;
722        }
723
724        let wait_ms = params
725            .get("wait_ms")
726            .and_then(|v| v.as_u64())
727            .unwrap_or(3000)
728            .min(10_000);
729
730        Some(self.lsp_notify_and_collect_diagnostics(
731            file_path,
732            content,
733            std::time::Duration::from_millis(wait_ms),
734        ))
735    }
736
737    /// Post-write LSP hook: notify server and optionally collect diagnostics.
738    ///
739    /// This is the single call site for all command handlers after `write_format_validate`.
740    /// Behavior:
741    /// - When `diagnostics: true` is in `params`, notifies the server, waits
742    ///   until matching diagnostics arrive or the timeout expires, and returns
743    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
744    ///   status.
745    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
746    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
747    ///   `None` is what tells the response builder to omit the LSP fields
748    ///   entirely (preserves the no-diagnostics-requested response shape).
749    ///
750    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
751    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
752    /// capped at 10000ms.
753    pub fn lsp_post_write(
754        &self,
755        file_path: &Path,
756        content: &str,
757        params: &serde_json::Value,
758    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
759        let wants_diagnostics = params
760            .get("diagnostics")
761            .and_then(|v| v.as_bool())
762            .unwrap_or(false);
763
764        let custom_markers = self.custom_lsp_root_markers();
765
766        if !wants_diagnostics {
767            if let Some(file_paths) = Self::multi_file_write_paths(params) {
768                self.notify_watched_config_files(&file_paths);
769            } else if let Some(config_events) =
770                Self::watched_file_events_from_params(params, &custom_markers)
771            {
772                self.notify_watched_config_events(&config_events);
773            }
774            self.lsp_notify_file_changed(file_path, content);
775            return None;
776        }
777
778        let wait_ms = params
779            .get("wait_ms")
780            .and_then(|v| v.as_u64())
781            .unwrap_or(3000)
782            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
783
784        if let Some(file_paths) = Self::multi_file_write_paths(params) {
785            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
786        }
787
788        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
789        {
790            self.notify_watched_config_events(&config_events);
791        }
792
793        Some(self.lsp_notify_and_collect_diagnostics(
794            file_path,
795            content,
796            std::time::Duration::from_millis(wait_ms),
797        ))
798    }
799
800    /// Validate that a file path falls within the configured project root.
801    ///
802    /// When `project_root` is configured (normal plugin usage), this resolves the
803    /// path and checks it starts with the root. Returns the canonicalized path on
804    /// success, or an error response on violation.
805    ///
806    /// When no `project_root` is configured (direct CLI usage), all paths pass
807    /// through unrestricted for backward compatibility.
808    pub fn validate_path(
809        &self,
810        req_id: &str,
811        path: &Path,
812    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
813        let config = self.config();
814        // When restrict_to_project_root is false (default), allow all paths
815        if !config.restrict_to_project_root {
816            return Ok(path.to_path_buf());
817        }
818        let root = match &config.project_root {
819            Some(r) => r.clone(),
820            None => return Ok(path.to_path_buf()), // No root configured, allow all
821        };
822        drop(config);
823
824        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
825        // returns /var/... paths while canonicalize gives /private/var/...; we
826        // need both forms so reject_escaping_symlink can recognise in-root
827        // symlinks regardless of which prefix form `current` happens to have.
828        let raw_root = root.clone();
829        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
830
831        // Resolve the path (follow symlinks, normalize ..). If canonicalization
832        // fails (e.g. path does not exist or traverses a broken symlink), inspect
833        // every existing component with lstat before falling back lexically so a
834        // broken in-root symlink cannot be used to write outside project_root.
835        let resolved = match std::fs::canonicalize(path) {
836            Ok(resolved) => resolved,
837            Err(_) => {
838                let normalized = normalize_path(path);
839                reject_escaping_symlink(req_id, path, &normalized, &resolved_root, &raw_root)?;
840                resolve_with_existing_ancestors(&normalized)
841            }
842        };
843
844        if !resolved.starts_with(&resolved_root) {
845            return Err(path_error_response(req_id, path, &resolved_root));
846        }
847
848        Ok(resolved)
849    }
850
851    /// Count active LSP server instances.
852    pub fn lsp_server_count(&self) -> usize {
853        self.lsp_manager
854            .try_borrow()
855            .map(|lsp| lsp.server_count())
856            .unwrap_or(0)
857    }
858
859    /// Symbol cache statistics from the language provider.
860    pub fn symbol_cache_stats(&self) -> serde_json::Value {
861        let entries = self
862            .symbol_cache
863            .read()
864            .map(|cache| cache.len())
865            .unwrap_or(0);
866        serde_json::json!({
867            "local_entries": entries,
868            "warm_entries": 0,
869        })
870    }
871}