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    pub fn lsp_notify_and_collect_diagnostics(
230        &self,
231        file_path: &Path,
232        content: &str,
233        timeout: std::time::Duration,
234    ) -> Vec<crate::lsp::diagnostics::StoredDiagnostic> {
235        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
236            return Vec::new();
237        };
238
239        // Clear any queued notifications before this write so the wait loop only
240        // observes diagnostics triggered by the current change.
241        lsp.drain_events();
242
243        // Send didChange/didOpen
244        let config = self.config();
245        if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
246            log::warn!("sync error for {}: {}", file_path.display(), e);
247            return Vec::new();
248        }
249
250        // Wait for diagnostics to arrive
251        lsp.wait_for_diagnostics(file_path, &config, timeout)
252    }
253
254    /// Post-write LSP hook: notify server and optionally collect diagnostics.
255    ///
256    /// This is the single call site for all command handlers after `write_format_validate`.
257    /// When `diagnostics` is true, it notifies the server, waits until matching
258    /// diagnostics arrive or the timeout expires, and returns diagnostics for the file.
259    /// When false, it just notifies (fire-and-forget).
260    pub fn lsp_post_write(
261        &self,
262        file_path: &Path,
263        content: &str,
264        params: &serde_json::Value,
265    ) -> Vec<crate::lsp::diagnostics::StoredDiagnostic> {
266        let wants_diagnostics = params
267            .get("diagnostics")
268            .and_then(|v| v.as_bool())
269            .unwrap_or(false);
270
271        if !wants_diagnostics {
272            self.lsp_notify_file_changed(file_path, content);
273            return Vec::new();
274        }
275
276        let wait_ms = params
277            .get("wait_ms")
278            .and_then(|v| v.as_u64())
279            .unwrap_or(1500)
280            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
281
282        self.lsp_notify_and_collect_diagnostics(
283            file_path,
284            content,
285            std::time::Duration::from_millis(wait_ms),
286        )
287    }
288
289    /// Validate that a file path falls within the configured project root.
290    ///
291    /// When `project_root` is configured (normal plugin usage), this resolves the
292    /// path and checks it starts with the root. Returns the canonicalized path on
293    /// success, or an error response on violation.
294    ///
295    /// When no `project_root` is configured (direct CLI usage), all paths pass
296    /// through unrestricted for backward compatibility.
297    pub fn validate_path(
298        &self,
299        req_id: &str,
300        path: &Path,
301    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
302        let config = self.config();
303        // When restrict_to_project_root is false (default), allow all paths
304        if !config.restrict_to_project_root {
305            return Ok(path.to_path_buf());
306        }
307        let root = match &config.project_root {
308            Some(r) => r.clone(),
309            None => return Ok(path.to_path_buf()), // No root configured, allow all
310        };
311        drop(config);
312
313        // Resolve the path (follow symlinks, normalize ..)
314        let resolved = std::fs::canonicalize(path)
315            .unwrap_or_else(|_| resolve_with_existing_ancestors(&normalize_path(path)));
316
317        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
318
319        if !resolved.starts_with(&resolved_root) {
320            return Err(crate::protocol::Response::error(
321                req_id,
322                "path_outside_root",
323                format!(
324                    "path '{}' is outside the project root '{}'",
325                    path.display(),
326                    resolved_root.display()
327                ),
328            ));
329        }
330
331        Ok(resolved)
332    }
333
334    /// Count active LSP server instances.
335    pub fn lsp_server_count(&self) -> usize {
336        self.lsp_manager
337            .try_borrow()
338            .map(|lsp| lsp.server_count())
339            .unwrap_or(0)
340    }
341
342    /// Symbol cache statistics from the language provider.
343    pub fn symbol_cache_stats(&self) -> serde_json::Value {
344        if let Some(tsp) = self
345            .provider
346            .as_any()
347            .downcast_ref::<crate::parser::TreeSitterProvider>()
348        {
349            let (local, warm) = tsp.symbol_cache_stats();
350            serde_json::json!({
351                "local_entries": local,
352                "warm_entries": warm,
353            })
354        } else {
355            serde_json::json!({
356                "local_entries": 0,
357                "warm_entries": 0,
358            })
359        }
360    }
361}