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