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    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
263    /// whenever `project_root` changes or a watcher event reports a
264    /// `.gitignore` write. Used by the watcher event filter to decide which
265    /// path-changes are interesting to AFT's caches. `None` when no project
266    /// root is configured or when the project has no gitignore files; in that
267    /// case the watcher falls back to a small hardcoded infra-directory skip.
268    gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
269}
270
271impl AppContext {
272    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
273        let bash_compress_enabled = config.experimental_bash_compress;
274        let progress_sender = Arc::new(Mutex::new(None));
275        let stdout_writer = Arc::new(Mutex::new(BufWriter::new(io::stdout())));
276        let symbol_cache = provider
277            .as_any()
278            .downcast_ref::<crate::parser::TreeSitterProvider>()
279            .map(|provider| provider.symbol_cache())
280            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
281        let lsp_child_registry = crate::lsp::child_registry::LspChildRegistry::new();
282        let mut lsp_manager = LspManager::new();
283        lsp_manager.set_child_registry(lsp_child_registry.clone());
284        AppContext {
285            provider,
286            backup: RefCell::new(BackupStore::new()),
287            checkpoint: RefCell::new(CheckpointStore::new()),
288            config: RefCell::new(config),
289            callgraph: RefCell::new(None),
290            search_index: RefCell::new(None),
291            search_index_rx: RefCell::new(None),
292            symbol_cache,
293            semantic_index: RefCell::new(None),
294            semantic_index_rx: RefCell::new(None),
295            semantic_index_status: RefCell::new(SemanticIndexStatus::Disabled),
296            semantic_embedding_model: RefCell::new(None),
297            watcher: RefCell::new(None),
298            watcher_rx: RefCell::new(None),
299            lsp_manager: RefCell::new(lsp_manager),
300            lsp_child_registry,
301            stdout_writer,
302            progress_sender: Arc::clone(&progress_sender),
303            bash_background: BgTaskRegistry::new(progress_sender),
304            filter_registry: Arc::new(std::sync::RwLock::new(
305                crate::compress::toml_filter::FilterRegistry::default(),
306            )),
307            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
308            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
309            gitignore: RefCell::new(None),
310        }
311    }
312
313    /// Borrow the cached project gitignore matcher. Returns `None` when no
314    /// project_root is configured or when the project has no gitignore files.
315    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
316        self.gitignore.borrow().clone()
317    }
318
319    /// Rebuild the gitignore matcher from the current `project_root` and
320    /// cache it. Called by the configure handler whenever the project root
321    /// changes, and by the watcher event drain when a `.gitignore` file
322    /// itself is modified.
323    ///
324    /// The builder honors:
325    /// - `<project_root>/.gitignore`
326    /// - `<project_root>/.git/info/exclude` (loaded explicitly because
327    ///   `GitignoreBuilder::new` does not auto-discover it)
328    /// - nested `.gitignore` files (each `.gitignore` discovered during
329    ///   the recursive walk)
330    ///
331    /// Stores `None` if there's no project_root or no matchable gitignore
332    /// files. Logs build errors but never fails configure.
333    pub fn rebuild_gitignore(&self) {
334        use ignore::gitignore::GitignoreBuilder;
335        use std::path::Path;
336        let root_raw = match self.config().project_root.clone() {
337            Some(r) => r,
338            None => {
339                *self.gitignore.borrow_mut() = None;
340                return;
341            }
342        };
343        // Canonicalize the root so symlink-prefix mismatches don't cause
344        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
345        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
346        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
347        // crate's matcher panics when a query path isn't lexically under the
348        // matcher's root. Canonicalizing both ends (here for root, naturally
349        // for watcher events on macOS) keeps them in the same prefix space.
350        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
351        let mut builder = GitignoreBuilder::new(&root);
352        // Add root .gitignore (the most common case)
353        let root_ignore = Path::new(&root).join(".gitignore");
354        if root_ignore.exists() {
355            if let Some(err) = builder.add(&root_ignore) {
356                log::warn!(
357                    "gitignore parse error in {}: {}",
358                    root_ignore.display(),
359                    err
360                );
361            }
362        }
363        // .git/info/exclude — manually added because GitignoreBuilder::new()
364        // does not auto-discover it (verified against ignore-0.4.25 source).
365        let info_exclude = Path::new(&root).join(".git").join("info").join("exclude");
366        if info_exclude.exists() {
367            if let Some(err) = builder.add(&info_exclude) {
368                log::warn!(
369                    "gitignore parse error in {}: {}",
370                    info_exclude.display(),
371                    err
372                );
373            }
374        }
375        // Walk the project to pick up nested .gitignore files. Cap the walk
376        // at the same SOURCE_WALK_LIMIT used by other configure-time walks
377        // (currently 20000 files); gitignore lookup-cost stays bounded for
378        // huge monorepos. Skip the obvious infra dirs so we don't accidentally
379        // load a vendored repo's .gitignore that doesn't apply to ours.
380        let walker = ignore::WalkBuilder::new(&root)
381            .standard_filters(true)
382            // Hidden files are filtered by default, but `.gitignore` starts with
383            // `.` so we need to traverse "hidden" entries to find nested ones.
384            .hidden(false)
385            .max_depth(Some(8))
386            .filter_entry(|entry| {
387                let name = entry.file_name().to_string_lossy();
388                !matches!(
389                    name.as_ref(),
390                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
391                )
392            })
393            .build();
394        for entry in walker.flatten() {
395            if entry.file_name() == ".gitignore" && entry.path() != root_ignore {
396                if let Some(err) = builder.add(entry.path()) {
397                    log::warn!(
398                        "nested gitignore parse error in {}: {}",
399                        entry.path().display(),
400                        err
401                    );
402                }
403            }
404        }
405        match builder.build() {
406            Ok(gi) => {
407                let count = gi.num_ignores();
408                if count > 0 {
409                    log::info!("gitignore matcher built: {} pattern(s)", count);
410                    *self.gitignore.borrow_mut() = Some(Arc::new(gi));
411                } else {
412                    *self.gitignore.borrow_mut() = None;
413                }
414            }
415            Err(err) => {
416                log::warn!("gitignore matcher build failed: {}", err);
417                *self.gitignore.borrow_mut() = None;
418            }
419        }
420    }
421
422    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
423    /// configure handler. Read by the BgTaskRegistry compressor closure.
424    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
425        Arc::clone(&self.bash_compress_flag)
426    }
427
428    /// Update the shared `bash_compress_flag` mirror. Call this from the
429    /// configure handler whenever `experimental.bash.compress` changes so the
430    /// BgTaskRegistry watchdog sees the new value on the next completion.
431    pub fn sync_bash_compress_flag(&self) {
432        let value = self.config().experimental_bash_compress;
433        self.bash_compress_flag
434            .store(value, std::sync::atomic::Ordering::Relaxed);
435    }
436
437    pub fn set_bash_compress_enabled(&self, enabled: bool) {
438        self.config_mut().experimental_bash_compress = enabled;
439        self.bash_compress_flag
440            .store(enabled, std::sync::atomic::Ordering::Relaxed);
441    }
442
443    /// Read-only access to the TOML filter registry, building it lazily on
444    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
445    /// against directly.
446    pub fn filter_registry(
447        &self,
448    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
449        self.ensure_filter_registry_loaded();
450        match self.filter_registry.read() {
451            Ok(g) => g,
452            Err(poisoned) => poisoned.into_inner(),
453        }
454    }
455
456    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
457    /// outside `AppContext` (notably the bash watchdog) can read it without
458    /// touching the rest of the context.
459    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
460        self.ensure_filter_registry_loaded();
461        Arc::clone(&self.filter_registry)
462    }
463
464    /// Force a fresh load of the TOML filter registry. Called when configure
465    /// changes the project root, storage_dir, or trust state so subsequent
466    /// `compress::compress` calls pick up new filters.
467    pub fn reset_filter_registry(&self) {
468        let new_registry = crate::compress::build_registry_for_context(self);
469        match self.filter_registry.write() {
470            Ok(mut slot) => *slot = new_registry,
471            Err(poisoned) => *poisoned.into_inner() = new_registry,
472        }
473        self.filter_registry_loaded
474            .store(true, std::sync::atomic::Ordering::Release);
475    }
476
477    fn ensure_filter_registry_loaded(&self) {
478        use std::sync::atomic::Ordering;
479        if self.filter_registry_loaded.load(Ordering::Acquire) {
480            return;
481        }
482        // Build outside the lock to avoid blocking other readers during a
483        // multi-file TOML parse.
484        let new_registry = crate::compress::build_registry_for_context(self);
485        if let Ok(mut slot) = self.filter_registry.write() {
486            *slot = new_registry;
487            self.filter_registry_loaded.store(true, Ordering::Release);
488        }
489    }
490
491    /// Clone the LSP child registry handle. Used by main.rs to give the
492    /// signal handler thread a way to SIGKILL LSP children on shutdown.
493    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
494        self.lsp_child_registry.clone()
495    }
496
497    pub fn stdout_writer(&self) -> SharedStdoutWriter {
498        Arc::clone(&self.stdout_writer)
499    }
500
501    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
502        if let Ok(mut progress_sender) = self.progress_sender.lock() {
503            *progress_sender = sender;
504        }
505    }
506
507    pub fn emit_progress(&self, frame: ProgressFrame) {
508        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
509            return;
510        };
511        if let Some(sender) = progress_sender.as_ref() {
512            sender(PushFrame::Progress(frame));
513        }
514    }
515
516    /// Get a clone of the current progress sender for use from background
517    /// threads. Returns `None` when the main loop hasn't installed one (tests,
518    /// CLI without push frames).
519    ///
520    /// Used by `configure`'s deferred file-walk thread to push warnings after
521    /// configure has already returned, so configure latency stays sub-100 ms
522    /// even on huge directories.
523    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
524        self.progress_sender
525            .lock()
526            .ok()
527            .and_then(|sender| sender.clone())
528    }
529
530    pub fn bash_background(&self) -> &BgTaskRegistry {
531        &self.bash_background
532    }
533
534    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
535        self.bash_background.drain_completions()
536    }
537
538    /// Access the language provider.
539    pub fn provider(&self) -> &dyn LanguageProvider {
540        self.provider.as_ref()
541    }
542
543    /// Access the backup store.
544    pub fn backup(&self) -> &RefCell<BackupStore> {
545        &self.backup
546    }
547
548    /// Access the checkpoint store.
549    pub fn checkpoint(&self) -> &RefCell<CheckpointStore> {
550        &self.checkpoint
551    }
552
553    /// Access the configuration (shared borrow).
554    pub fn config(&self) -> Ref<'_, Config> {
555        self.config.borrow()
556    }
557
558    /// Access the configuration (mutable borrow).
559    pub fn config_mut(&self) -> RefMut<'_, Config> {
560        self.config.borrow_mut()
561    }
562
563    /// Access the call graph engine.
564    pub fn callgraph(&self) -> &RefCell<Option<CallGraph>> {
565        &self.callgraph
566    }
567
568    /// Access the search index.
569    pub fn search_index(&self) -> &RefCell<Option<SearchIndex>> {
570        &self.search_index
571    }
572
573    /// Access the search-index build receiver.
574    pub fn search_index_rx(&self) -> &RefCell<Option<crossbeam_channel::Receiver<SearchIndex>>> {
575        &self.search_index_rx
576    }
577
578    /// Access the shared symbol cache.
579    pub fn symbol_cache(&self) -> SharedSymbolCache {
580        Arc::clone(&self.symbol_cache)
581    }
582
583    /// Clear the shared symbol cache and return the new active generation.
584    pub fn reset_symbol_cache(&self) -> u64 {
585        self.symbol_cache
586            .write()
587            .map(|mut cache| cache.reset())
588            .unwrap_or(0)
589    }
590
591    /// Access the semantic search index.
592    pub fn semantic_index(&self) -> &RefCell<Option<SemanticIndex>> {
593        &self.semantic_index
594    }
595
596    /// Access the semantic-index build receiver.
597    pub fn semantic_index_rx(
598        &self,
599    ) -> &RefCell<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
600        &self.semantic_index_rx
601    }
602
603    pub fn semantic_index_status(&self) -> &RefCell<SemanticIndexStatus> {
604        &self.semantic_index_status
605    }
606
607    /// Access the cached semantic embedding model.
608    pub fn semantic_embedding_model(
609        &self,
610    ) -> &RefCell<Option<crate::semantic_index::EmbeddingModel>> {
611        &self.semantic_embedding_model
612    }
613
614    /// Access the file watcher handle (kept alive to continue watching).
615    pub fn watcher(&self) -> &RefCell<Option<RecommendedWatcher>> {
616        &self.watcher
617    }
618
619    /// Access the watcher event receiver.
620    pub fn watcher_rx(&self) -> &RefCell<Option<mpsc::Receiver<notify::Result<notify::Event>>>> {
621        &self.watcher_rx
622    }
623
624    /// Access the LSP manager.
625    pub fn lsp(&self) -> RefMut<'_, LspManager> {
626        self.lsp_manager.borrow_mut()
627    }
628
629    /// Notify LSP servers that a file was written.
630    /// Call this after write_format_validate in command handlers.
631    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
632        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
633            let config = self.config();
634            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
635                log::warn!("sync error for {}: {}", file_path.display(), e);
636            }
637        }
638    }
639
640    /// Notify LSP and optionally wait for diagnostics.
641    ///
642    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
643    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
644    /// any diagnostics for the file. If no server is running, returns empty immediately.
645    ///
646    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
647    /// are NEVER returned — only entries whose `version` matches the
648    /// post-edit document version (or, for unversioned servers, whose
649    /// `epoch` advanced past the pre-edit snapshot).
650    pub fn lsp_notify_and_collect_diagnostics(
651        &self,
652        file_path: &Path,
653        content: &str,
654        timeout: std::time::Duration,
655    ) -> crate::lsp::manager::PostEditWaitOutcome {
656        let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() else {
657            return crate::lsp::manager::PostEditWaitOutcome::default();
658        };
659
660        // Clear any queued notifications before this write so the wait loop only
661        // observes diagnostics triggered by the current change.
662        lsp.drain_events();
663
664        // Snapshot per-server epochs and document versions BEFORE sending
665        // didChange so the wait loop can prove freshness without accepting
666        // stale pre-edit publishes that arrived late.
667        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
668
669        // Send didChange/didOpen and capture per-server target version.
670        let config = self.config();
671        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
672        {
673            Ok(v) => v,
674            Err(e) => {
675                log::warn!("sync error for {}: {}", file_path.display(), e);
676                return crate::lsp::manager::PostEditWaitOutcome::default();
677            }
678        };
679
680        // No server matched this file — return an empty outcome that's
681        // honestly `complete: true` (nothing to wait for).
682        if expected_versions.is_empty() {
683            return crate::lsp::manager::PostEditWaitOutcome::default();
684        }
685
686        lsp.wait_for_post_edit_diagnostics(
687            file_path,
688            &config,
689            &expected_versions,
690            &pre_snapshot,
691            timeout,
692        )
693    }
694
695    /// Collect custom server root_markers from user config for use in
696    /// `is_config_file_path_with_custom` checks (#25).
697    fn custom_lsp_root_markers(&self) -> Vec<String> {
698        self.config()
699            .lsp_servers
700            .iter()
701            .flat_map(|s| s.root_markers.iter().cloned())
702            .collect()
703    }
704
705    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
706        let custom_markers = self.custom_lsp_root_markers();
707        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
708            .iter()
709            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
710            .cloned()
711            .map(|path| {
712                let change_type = if path.exists() {
713                    FileChangeType::CHANGED
714                } else {
715                    FileChangeType::DELETED
716                };
717                (path, change_type)
718            })
719            .collect();
720
721        self.notify_watched_config_events(&config_paths);
722    }
723
724    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
725        let paths = params
726            .get("multi_file_write_paths")
727            .and_then(|value| value.as_array())?
728            .iter()
729            .filter_map(|value| value.as_str())
730            .map(PathBuf::from)
731            .collect::<Vec<_>>();
732
733        (!paths.is_empty()).then_some(paths)
734    }
735
736    /// Parse config-file watched events from `multi_file_write_paths` when the
737    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
738    ///
739    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
740    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
741    /// `notify_watched_config_files()`. Both variants read the same JSON key but
742    /// with different per-entry schemas — they are NOT redundant.
743    ///
744    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
745    /// and was reachable via the `else if` branch when all entries were objects.
746    /// Restoring both is correct.
747    fn watched_file_events_from_params(
748        params: &serde_json::Value,
749        extra_markers: &[String],
750    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
751        let events = params
752            .get("multi_file_write_paths")
753            .and_then(|value| value.as_array())?
754            .iter()
755            .filter_map(|entry| {
756                // Only handle object entries — string entries go through multi_file_write_paths()
757                let path = entry
758                    .get("path")
759                    .and_then(|value| value.as_str())
760                    .map(PathBuf::from)?;
761
762                if !is_config_file_path_with_custom(&path, extra_markers) {
763                    return None;
764                }
765
766                let change_type = entry
767                    .get("type")
768                    .and_then(|value| value.as_str())
769                    .and_then(Self::parse_file_change_type)
770                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
771
772                Some((path, change_type))
773            })
774            .collect::<Vec<_>>();
775
776        (!events.is_empty()).then_some(events)
777    }
778
779    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
780        match value {
781            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
782            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
783            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
784            _ => None,
785        }
786    }
787
788    fn change_type_from_current_state(path: &Path) -> FileChangeType {
789        if path.exists() {
790            FileChangeType::CHANGED
791        } else {
792            FileChangeType::DELETED
793        }
794    }
795
796    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
797        if config_paths.is_empty() {
798            return;
799        }
800
801        if let Ok(mut lsp) = self.lsp_manager.try_borrow_mut() {
802            let config = self.config();
803            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
804                log::warn!("watched-file sync error: {}", e);
805            }
806        }
807    }
808
809    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
810        let custom_markers = self.custom_lsp_root_markers();
811        if !is_config_file_path_with_custom(file_path, &custom_markers) {
812            return;
813        }
814
815        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
816    }
817
818    /// Post-write LSP hook for multi-file edits. When the patch includes
819    /// config-file edits, notify active workspace servers via
820    /// `workspace/didChangeWatchedFiles` before sending the per-document
821    /// didOpen/didChange for the current file.
822    pub fn lsp_post_multi_file_write(
823        &self,
824        file_path: &Path,
825        content: &str,
826        file_paths: &[PathBuf],
827        params: &serde_json::Value,
828    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
829        self.notify_watched_config_files(file_paths);
830
831        let wants_diagnostics = params
832            .get("diagnostics")
833            .and_then(|v| v.as_bool())
834            .unwrap_or(false);
835
836        if !wants_diagnostics {
837            self.lsp_notify_file_changed(file_path, content);
838            return None;
839        }
840
841        let wait_ms = params
842            .get("wait_ms")
843            .and_then(|v| v.as_u64())
844            .unwrap_or(3000)
845            .min(10_000);
846
847        Some(self.lsp_notify_and_collect_diagnostics(
848            file_path,
849            content,
850            std::time::Duration::from_millis(wait_ms),
851        ))
852    }
853
854    /// Post-write LSP hook: notify server and optionally collect diagnostics.
855    ///
856    /// This is the single call site for all command handlers after `write_format_validate`.
857    /// Behavior:
858    /// - When `diagnostics: true` is in `params`, notifies the server, waits
859    ///   until matching diagnostics arrive or the timeout expires, and returns
860    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
861    ///   status.
862    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
863    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
864    ///   `None` is what tells the response builder to omit the LSP fields
865    ///   entirely (preserves the no-diagnostics-requested response shape).
866    ///
867    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
868    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
869    /// capped at 10000ms.
870    pub fn lsp_post_write(
871        &self,
872        file_path: &Path,
873        content: &str,
874        params: &serde_json::Value,
875    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
876        let wants_diagnostics = params
877            .get("diagnostics")
878            .and_then(|v| v.as_bool())
879            .unwrap_or(false);
880
881        let custom_markers = self.custom_lsp_root_markers();
882
883        if !wants_diagnostics {
884            if let Some(file_paths) = Self::multi_file_write_paths(params) {
885                self.notify_watched_config_files(&file_paths);
886            } else if let Some(config_events) =
887                Self::watched_file_events_from_params(params, &custom_markers)
888            {
889                self.notify_watched_config_events(&config_events);
890            }
891            self.lsp_notify_file_changed(file_path, content);
892            return None;
893        }
894
895        let wait_ms = params
896            .get("wait_ms")
897            .and_then(|v| v.as_u64())
898            .unwrap_or(3000)
899            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
900
901        if let Some(file_paths) = Self::multi_file_write_paths(params) {
902            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
903        }
904
905        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
906        {
907            self.notify_watched_config_events(&config_events);
908        }
909
910        Some(self.lsp_notify_and_collect_diagnostics(
911            file_path,
912            content,
913            std::time::Duration::from_millis(wait_ms),
914        ))
915    }
916
917    /// Validate that a file path falls within the configured project root.
918    ///
919    /// When `project_root` is configured (normal plugin usage), this resolves the
920    /// path and checks it starts with the root. Returns the canonicalized path on
921    /// success, or an error response on violation.
922    ///
923    /// When no `project_root` is configured (direct CLI usage), all paths pass
924    /// through unrestricted for backward compatibility.
925    pub fn validate_path(
926        &self,
927        req_id: &str,
928        path: &Path,
929    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
930        let config = self.config();
931        // When restrict_to_project_root is false (default), allow all paths
932        if !config.restrict_to_project_root {
933            return Ok(path.to_path_buf());
934        }
935        let root = match &config.project_root {
936            Some(r) => r.clone(),
937            None => return Ok(path.to_path_buf()), // No root configured, allow all
938        };
939        drop(config);
940
941        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
942        // returns /var/... paths while canonicalize gives /private/var/...; we
943        // need both forms so reject_escaping_symlink can recognise in-root
944        // symlinks regardless of which prefix form `current` happens to have.
945        let raw_root = root.clone();
946        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
947
948        // Resolve the path (follow symlinks, normalize ..). If canonicalization
949        // fails (e.g. path does not exist or traverses a broken symlink), inspect
950        // every existing component with lstat before falling back lexically so a
951        // broken in-root symlink cannot be used to write outside project_root.
952        let resolved = match std::fs::canonicalize(path) {
953            Ok(resolved) => resolved,
954            Err(_) => {
955                let normalized = normalize_path(path);
956                reject_escaping_symlink(req_id, path, &normalized, &resolved_root, &raw_root)?;
957                resolve_with_existing_ancestors(&normalized)
958            }
959        };
960
961        if !resolved.starts_with(&resolved_root) {
962            return Err(path_error_response(req_id, path, &resolved_root));
963        }
964
965        Ok(resolved)
966    }
967
968    /// Count active LSP server instances.
969    pub fn lsp_server_count(&self) -> usize {
970        self.lsp_manager
971            .try_borrow()
972            .map(|lsp| lsp.server_count())
973            .unwrap_or(0)
974    }
975
976    /// Symbol cache statistics from the language provider.
977    pub fn symbol_cache_stats(&self) -> serde_json::Value {
978        let entries = self
979            .symbol_cache
980            .read()
981            .map(|cache| cache.len())
982            .unwrap_or(0);
983        serde_json::json!({
984            "local_entries": entries,
985            "warm_entries": 0,
986        })
987    }
988}
989
990#[cfg(test)]
991mod gitignore_tests {
992    use super::*;
993    use std::fs;
994    use std::path::Path;
995    use tempfile::TempDir;
996
997    fn make_ctx_with_root(root: &Path) -> AppContext {
998        let provider = Box::new(crate::parser::TreeSitterProvider::new());
999        let config = Config {
1000            project_root: Some(root.to_path_buf()),
1001            ..Config::default()
1002        };
1003        AppContext::new(provider, config)
1004    }
1005
1006    /// Helper: returns true when the matcher would skip `path` (as if it
1007    /// arrived via a watcher event for this project root). Canonicalizes
1008    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
1009    /// don't trip the `ignore` crate's "path is expected to be under the
1010    /// root" panic — production code does the same guard via
1011    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
1012    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
1013        let Some(matcher) = ctx.gitignore() else {
1014            return false;
1015        };
1016        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1017        if !canonical.starts_with(matcher.path()) {
1018            return false;
1019        }
1020        let is_dir = canonical.is_dir();
1021        matcher
1022            .matched_path_or_any_parents(&canonical, is_dir)
1023            .is_ignore()
1024    }
1025
1026    #[test]
1027    fn rebuild_gitignore_returns_none_without_project_root() {
1028        let provider = Box::new(crate::parser::TreeSitterProvider::new());
1029        let ctx = AppContext::new(provider, Config::default());
1030        ctx.rebuild_gitignore();
1031        assert!(ctx.gitignore().is_none());
1032    }
1033
1034    #[test]
1035    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
1036        let tmp = TempDir::new().unwrap();
1037        let ctx = make_ctx_with_root(tmp.path());
1038        ctx.rebuild_gitignore();
1039        assert!(ctx.gitignore().is_none());
1040    }
1041
1042    #[test]
1043    fn matcher_filters_files_in_ignored_dist_dir() {
1044        let tmp = TempDir::new().unwrap();
1045        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
1046        fs::create_dir_all(tmp.path().join("dist")).unwrap();
1047        fs::create_dir_all(tmp.path().join("src")).unwrap();
1048        let dist_file = tmp.path().join("dist").join("bundle.js");
1049        let src_file = tmp.path().join("src").join("app.ts");
1050        fs::write(&dist_file, "x").unwrap();
1051        fs::write(&src_file, "y").unwrap();
1052
1053        let ctx = make_ctx_with_root(tmp.path());
1054        ctx.rebuild_gitignore();
1055
1056        assert!(ctx.gitignore().is_some());
1057        assert!(
1058            is_ignored(&ctx, &dist_file),
1059            "dist/bundle.js should be ignored"
1060        );
1061        assert!(
1062            !is_ignored(&ctx, &src_file),
1063            "src/app.ts should NOT be ignored"
1064        );
1065    }
1066
1067    #[test]
1068    fn matcher_handles_node_modules_and_target() {
1069        let tmp = TempDir::new().unwrap();
1070        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
1071        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
1072        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
1073        let nm_file = tmp.path().join("node_modules/foo/index.js");
1074        let target_file = tmp.path().join("target/debug/aft");
1075        fs::write(&nm_file, "x").unwrap();
1076        fs::write(&target_file, "x").unwrap();
1077
1078        let ctx = make_ctx_with_root(tmp.path());
1079        ctx.rebuild_gitignore();
1080
1081        assert!(is_ignored(&ctx, &nm_file));
1082        assert!(is_ignored(&ctx, &target_file));
1083    }
1084
1085    #[test]
1086    fn matcher_honors_negation_pattern() {
1087        // .gitignore: ignore all *.log files EXCEPT important.log
1088        let tmp = TempDir::new().unwrap();
1089        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
1090        let random_log = tmp.path().join("random.log");
1091        let important_log = tmp.path().join("important.log");
1092        fs::write(&random_log, "x").unwrap();
1093        fs::write(&important_log, "y").unwrap();
1094
1095        let ctx = make_ctx_with_root(tmp.path());
1096        ctx.rebuild_gitignore();
1097
1098        assert!(is_ignored(&ctx, &random_log));
1099        assert!(
1100            !is_ignored(&ctx, &important_log),
1101            "negation pattern should un-ignore important.log"
1102        );
1103    }
1104
1105    #[test]
1106    fn rebuild_picks_up_gitignore_changes() {
1107        let tmp = TempDir::new().unwrap();
1108        let ignore_path = tmp.path().join(".gitignore");
1109        fs::write(&ignore_path, "foo.txt\n").unwrap();
1110        let foo = tmp.path().join("foo.txt");
1111        let bar = tmp.path().join("bar.txt");
1112        fs::write(&foo, "").unwrap();
1113        fs::write(&bar, "").unwrap();
1114
1115        let ctx = make_ctx_with_root(tmp.path());
1116        ctx.rebuild_gitignore();
1117        assert!(is_ignored(&ctx, &foo));
1118        assert!(!is_ignored(&ctx, &bar));
1119
1120        // Now flip the rules: ignore bar.txt instead of foo.txt
1121        fs::write(&ignore_path, "bar.txt\n").unwrap();
1122        ctx.rebuild_gitignore();
1123        assert!(!is_ignored(&ctx, &foo));
1124        assert!(is_ignored(&ctx, &bar));
1125    }
1126
1127    #[test]
1128    fn gitignore_loads_info_exclude_when_present() {
1129        let tmp = TempDir::new().unwrap();
1130        let info_dir = tmp.path().join(".git/info");
1131        fs::create_dir_all(&info_dir).unwrap();
1132        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
1133        let secrets = tmp.path().join("secrets.txt");
1134        let public = tmp.path().join("public.txt");
1135        fs::write(&secrets, "token").unwrap();
1136        fs::write(&public, "ok").unwrap();
1137
1138        let ctx = make_ctx_with_root(tmp.path());
1139        ctx.rebuild_gitignore();
1140
1141        assert!(is_ignored(&ctx, &secrets));
1142        assert!(!is_ignored(&ctx, &public));
1143    }
1144
1145    #[test]
1146    fn matcher_picks_up_nested_gitignore() {
1147        let tmp = TempDir::new().unwrap();
1148        // Root .gitignore is intentionally empty — only the nested one ignores
1149        fs::write(tmp.path().join(".gitignore"), "").unwrap();
1150        let sub = tmp.path().join("packages/foo");
1151        fs::create_dir_all(&sub).unwrap();
1152        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
1153        let generated_file = sub.join("generated").join("out.js");
1154        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
1155        fs::write(&generated_file, "x").unwrap();
1156
1157        let ctx = make_ctx_with_root(tmp.path());
1158        ctx.rebuild_gitignore();
1159
1160        assert!(
1161            is_ignored(&ctx, &generated_file),
1162            "nested gitignore in packages/foo/.gitignore should ignore generated/"
1163        );
1164    }
1165}