Skip to main content

aft/
context.rs

1use std::cell::{Ref, RefCell, RefMut};
2use std::path::{Component, Path, PathBuf};
3use std::sync::mpsc;
4
5use notify::RecommendedWatcher;
6
7use crate::backup::BackupStore;
8use crate::callgraph::CallGraph;
9use crate::checkpoint::CheckpointStore;
10use crate::config::Config;
11use crate::language::LanguageProvider;
12use crate::lsp::manager::LspManager;
13use crate::search_index::SearchIndex;
14use crate::semantic_index::SemanticIndex;
15
16#[derive(Debug, Clone)]
17pub enum SemanticIndexStatus {
18    Disabled,
19    Building {
20        stage: String,
21        files: Option<usize>,
22        entries_done: Option<usize>,
23        entries_total: Option<usize>,
24    },
25    Ready,
26    Failed(String),
27}
28
29pub enum SemanticIndexEvent {
30    Progress {
31        stage: String,
32        files: Option<usize>,
33        entries_done: Option<usize>,
34        entries_total: Option<usize>,
35    },
36    Ready(SemanticIndex),
37    Failed(String),
38}
39
40/// Normalize a path by resolving `.` and `..` components lexically,
41/// without touching the filesystem. This prevents path traversal
42/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
43fn normalize_path(path: &Path) -> PathBuf {
44    let mut result = PathBuf::new();
45    for component in path.components() {
46        match component {
47            Component::ParentDir => {
48                // Pop the last component unless we're at root or have no components
49                if !result.pop() {
50                    result.push(component);
51                }
52            }
53            Component::CurDir => {} // Skip `.`
54            _ => result.push(component),
55        }
56    }
57    result
58}
59
60fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
61    let mut existing = path.to_path_buf();
62    let mut tail_segments = Vec::new();
63
64    while !existing.exists() {
65        if let Some(name) = existing.file_name() {
66            tail_segments.push(name.to_owned());
67        } else {
68            break;
69        }
70
71        existing = match existing.parent() {
72            Some(parent) => parent.to_path_buf(),
73            None => break,
74        };
75    }
76
77    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
78    for segment in tail_segments.into_iter().rev() {
79        resolved.push(segment);
80    }
81
82    resolved
83}
84
85/// Shared application context threaded through all command handlers.
86///
87/// Holds the language provider, backup/checkpoint stores, configuration,
88/// and call graph engine. Constructed once at startup and passed by
89/// reference to `dispatch`.
90///
91/// Stores use `RefCell` for interior mutability — the binary is single-threaded
92/// (one request at a time on the stdin read loop) so runtime borrow checking
93/// is safe and never contended.
94pub struct AppContext {
95    provider: Box<dyn LanguageProvider>,
96    backup: RefCell<BackupStore>,
97    checkpoint: RefCell<CheckpointStore>,
98    config: RefCell<Config>,
99    callgraph: RefCell<Option<CallGraph>>,
100    search_index: RefCell<Option<SearchIndex>>,
101    search_index_rx:
102        RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>,
103    semantic_index: RefCell<Option<SemanticIndex>>,
104    semantic_index_rx: RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
105    semantic_index_status: RefCell<SemanticIndexStatus>,
106    semantic_embedding_model: RefCell<Option<crate::semantic_index::EmbeddingModel>>,
107    watcher: RefCell<Option<RecommendedWatcher>>,
108    watcher_rx: RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>>,
109    lsp_manager: RefCell<LspManager>,
110}
111
112impl AppContext {
113    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
114        AppContext {
115            provider,
116            backup: RefCell::new(BackupStore::new()),
117            checkpoint: RefCell::new(CheckpointStore::new()),
118            config: RefCell::new(config),
119            callgraph: RefCell::new(None),
120            search_index: RefCell::new(None),
121            search_index_rx: RefCell::new(None),
122            semantic_index: RefCell::new(None),
123            semantic_index_rx: RefCell::new(None),
124            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
125            semantic_embedding_model: RefCell::new(None),
126            watcher: RefCell::new(None),
127            watcher_rx: RefCell::new(None),
128            lsp_manager: RefCell::new(LspManager::new()),
129        }
130    }
131
132    /// Access the language provider.
133    pub fn provider(&self) -> &dyn LanguageProvider {
134        self.provider.as_ref()
135    }
136
137    /// Access the backup store.
138    pub fn backup(&self) -> &RefCell<BackupStore> {
139        &self.backup
140    }
141
142    /// Access the checkpoint store.
143    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
144        &self.checkpoint
145    }
146
147    /// Access the configuration (shared borrow).
148    pub fn config(&self) -> Ref<'_, Config> {
149        self.config.borrow()
150    }
151
152    /// Access the configuration (mutable borrow).
153    pub fn config_mut(&self) -> RefMut<'_, Config> {
154        self.config.borrow_mut()
155    }
156
157    /// Access the call graph engine.
158    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
159        &self.callgraph
160    }
161
162    /// Access the search index.
163    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
164        &self.search_index
165    }
166
167    /// Access the search-index build receiver (returns index + pre-warmed symbol cache).
168    pub fn search_index_rx(
169        &self,
170    ) -> &RefCell<Option<crossbeam_channel::Receiver<(SearchIndex, crate::parser::SymbolCache)>>>
171    {
172        &self.search_index_rx
173    }
174
175    /// Access the semantic search index.
176    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
177        &self.semantic_index
178    }
179
180    /// Access the semantic-index build receiver.
181    pub fn semantic_index_rx(
182        &self,
183    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
184        &self.semantic_index_rx
185    }
186
187    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
188        &self.semantic_index_status
189    }
190
191    /// Access the cached semantic embedding model.
192    pub fn semantic_embedding_model(
193        &self,
194    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
195        &self.semantic_embedding_model
196    }
197
198    /// Access the file watcher handle (kept alive to continue watching).
199    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
200        &self.watcher
201    }
202
203    /// Access the watcher event receiver.
204    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
205        &self.watcher_rx
206    }
207
208    /// Access the LSP manager.
209    pub fn lsp(&self) -> RefMut<'_, LspManager> {
210        self.lsp_manager.borrow_mut()
211    }
212
213    /// Notify LSP servers that a file was written.
214    /// Call this after write_format_validate in command handlers.
215    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
216        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
217            let config = self.config();
218            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
219                log::warn!("sync error for {}: {}", file_path.display(), e);
220            }
221        }
222    }
223
224    /// Notify LSP and optionally wait for diagnostics.
225    ///
226    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
227    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
228    /// any diagnostics for the file. If no server is running, returns empty immediately.
229    ///
230    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
231    /// are NEVER returned — only entries whose `version` matches the
232    /// post-edit document version (or, for unversioned servers, whose
233    /// `epoch` advanced past the pre-edit snapshot).
234    pub fn lsp_notify_and_collect_diagnostics(
235        &self,
236        file_path: &Path,
237        content: &str,
238        timeout: std::time::Duration,
239    ) -> crate::lsp::manager::PostEditWaitOutcome {
240        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
241            return crate::lsp::manager::PostEditWaitOutcome::default();
242        };
243
244        // Clear any queued notifications before this write so the wait loop only
245        // observes diagnostics triggered by the current change.
246        lsp.drain_events();
247
248        // Snapshot per-server epochs BEFORE sending didChange so the wait
249        // loop can detect freshness via epoch-delta for servers that don't
250        // echo `version` on publishDiagnostics.
251        let pre_snapshot = lsp.snapshot_diagnostic_epochs(file_path);
252
253        // Send didChange/didOpen and capture per-server target version.
254        let config = self.config();
255        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
256        {
257            Ok(v) => v,
258            Err(e) => {
259                log::warn!("sync error for {}: {}", file_path.display(), e);
260                return crate::lsp::manager::PostEditWaitOutcome::default();
261            }
262        };
263
264        // No server matched this file — return an empty outcome that's
265        // honestly `complete: true` (nothing to wait for).
266        if expected_versions.is_empty() {
267            return crate::lsp::manager::PostEditWaitOutcome::default();
268        }
269
270        lsp.wait_for_post_edit_diagnostics(
271            file_path,
272            &config,
273            &expected_versions,
274            &pre_snapshot,
275            timeout,
276        )
277    }
278
279    /// Post-write LSP hook: notify server and optionally collect diagnostics.
280    ///
281    /// This is the single call site for all command handlers after `write_format_validate`.
282    /// Behavior:
283    /// - When `diagnostics: true` is in `params`, notifies the server, waits
284    ///   until matching diagnostics arrive or the timeout expires, and returns
285    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
286    ///   status.
287    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
288    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
289    ///   `None` is what tells the response builder to omit the LSP fields
290    ///   entirely (preserves the no-diagnostics-requested response shape).
291    ///
292    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
293    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
294    /// capped at 10000ms.
295    pub fn lsp_post_write(
296        &self,
297        file_path: &Path,
298        content: &str,
299        params: &serde_json::Value,
300    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
301        let wants_diagnostics = params
302            .get("diagnostics")
303            .and_then(|v| v.as_bool())
304            .unwrap_or(false);
305
306        if !wants_diagnostics {
307            self.lsp_notify_file_changed(file_path, content);
308            return None;
309        }
310
311        let wait_ms = params
312            .get("wait_ms")
313            .and_then(|v| v.as_u64())
314            .unwrap_or(3000)
315            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
316
317        Some(self.lsp_notify_and_collect_diagnostics(
318            file_path,
319            content,
320            std::time::Duration::from_millis(wait_ms),
321        ))
322    }
323
324    /// Validate that a file path falls within the configured project root.
325    ///
326    /// When `project_root` is configured (normal plugin usage), this resolves the
327    /// path and checks it starts with the root. Returns the canonicalized path on
328    /// success, or an error response on violation.
329    ///
330    /// When no `project_root` is configured (direct CLI usage), all paths pass
331    /// through unrestricted for backward compatibility.
332    pub fn validate_path(
333        &self,
334        req_id: &str,
335        path: &Path,
336    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
337        let config = self.config();
338        // When restrict_to_project_root is false (default), allow all paths
339        if !config.restrict_to_project_root {
340            return Ok(path.to_path_buf());
341        }
342        let root = match &config.project_root {
343            Some(r) => r.clone(),
344            None => return Ok(path.to_path_buf()), // No root configured, allow all
345        };
346        drop(config);
347
348        // Resolve the path (follow symlinks, normalize ..)
349        let resolved = std::fs::canonicalize(path)
350            .unwrap_or_else(|_| resolve_with_existing_ancestors(&normalize_path(path)));
351
352        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
353
354        if !resolved.starts_with(&resolved_root) {
355            return Err(crate::protocol::Response::error(
356                req_id,
357                "path_outside_root",
358                format!(
359                    "path '{}' is outside the project root '{}'",
360                    path.display(),
361                    resolved_root.display()
362                ),
363            ));
364        }
365
366        Ok(resolved)
367    }
368
369    /// Count active LSP server instances.
370    pub fn lsp_server_count(&self) -> usize {
371        self.lsp_manager
372            .try_borrow()
373            .map(|lsp| lsp.server_count())
374            .unwrap_or(0)
375    }
376
377    /// Symbol cache statistics from the language provider.
378    pub fn symbol_cache_stats(&self) -> serde_json::Value {
379        if let Some(tsp) = self
380            .provider
381            .as_any()
382            .downcast_ref::<crate::parser::TreeSitterProvider>()
383        {
384            let (local, warm) = tsp.symbol_cache_stats();
385            serde_json::json!({
386                "local_entries": local,
387                "warm_entries": warm,
388            })
389        } else {
390            serde_json::json!({
391                "local_entries": 0,
392                "warm_entries": 0,
393            })
394        }
395    }
396}