Skip to main content

fresh/input/quick_open/
mod.rs

1//! Quick Open Provider System
2//!
3//! A unified prompt system with prefix-based routing to different providers.
4//! Inspired by VSCode's Quick Open (Ctrl+P) which supports:
5//! - Empty prefix: file finder
6//! - `>`: command palette
7//! - `#`: symbol finder (future)
8//! - `@`: go to symbol in file (future)
9//! - `:`: go to line
10//!
11//! Providers are registered with a prefix and handle suggestion generation
12//! and selection for their domain.
13
14pub mod providers;
15
16pub use providers::{BufferProvider, CommandProvider, FileProvider, GotoLineProvider};
17
18use crate::input::commands::Suggestion;
19use crate::input::keybindings::Action;
20use std::collections::HashMap;
21
22/// Result of confirming a selection in a provider
23#[derive(Debug, Clone)]
24pub enum QuickOpenResult {
25    /// Execute an editor action
26    ExecuteAction(Action),
27    /// Open a file at optional line/column
28    OpenFile {
29        path: String,
30        line: Option<usize>,
31        column: Option<usize>,
32    },
33    /// Show a buffer by ID
34    ShowBuffer(usize),
35    /// Go to a line in the current buffer
36    GotoLine(GotoLineTarget),
37    /// Do nothing (provider handled it internally)
38    None,
39    /// Show an error message
40    Error(String),
41}
42
43/// A parsed goto-line target. The presence of an explicit sign in the user's
44/// input chooses between absolute and relative jumps independently of the
45/// `relative_line_numbers` display setting.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum GotoLineTarget {
48    /// Absolute 1-based line number (input had no leading sign).
49    Absolute(usize),
50    /// Signed offset from the current cursor line (input had `+`/`-` prefix).
51    Relative(isize),
52}
53
54/// Parse a goto-line input string.
55///
56/// - `"500"` → `Absolute(500)`
57/// - `"+3"` → `Relative(3)`
58/// - `"-3"` → `Relative(-3)`
59/// - `"0"`, `"+0"`, `"-0"`, `""`, `"abc"` → `None`
60///
61/// Whitespace around the input is ignored. The leading-sign convention is
62/// independent of any display setting: the user's literal input decides.
63pub fn parse_goto_line_input(input: &str) -> Option<GotoLineTarget> {
64    let trimmed = input.trim();
65    if trimmed.is_empty() {
66        return None;
67    }
68    if let Some(rest) = trimmed
69        .strip_prefix('+')
70        .or_else(|| trimmed.strip_prefix('-'))
71    {
72        // Reject bare "+"/"-" and avoid double signs like "++3" or "+-3".
73        if rest.is_empty() || rest.starts_with('+') || rest.starts_with('-') {
74            return None;
75        }
76        let delta = trimmed.parse::<isize>().ok()?;
77        if delta == 0 {
78            return None;
79        }
80        Some(GotoLineTarget::Relative(delta))
81    } else {
82        let n = trimmed.parse::<usize>().ok()?;
83        if n == 0 {
84            return None;
85        }
86        Some(GotoLineTarget::Absolute(n))
87    }
88}
89
90/// Context provided to providers when generating suggestions
91#[derive(Debug, Clone)]
92pub struct QuickOpenContext {
93    /// Current working directory
94    pub cwd: String,
95    /// List of open buffer paths
96    pub open_buffers: Vec<BufferInfo>,
97    /// Active buffer ID
98    pub active_buffer_id: usize,
99    /// Active buffer path (if any)
100    pub active_buffer_path: Option<String>,
101    /// Whether there's an active selection
102    pub has_selection: bool,
103    /// Current key context
104    pub key_context: crate::input::keybindings::KeyContext,
105    /// Active custom contexts (for command filtering)
106    pub custom_contexts: std::collections::HashSet<String>,
107    /// Active buffer mode (e.g., "vi_normal")
108    pub buffer_mode: Option<String>,
109    /// Whether the active buffer's language has an LSP server configured
110    pub has_lsp_config: bool,
111    /// Whether relative line numbers are enabled
112    pub relative_line_numbers: bool,
113}
114
115/// Information about an open buffer
116#[derive(Debug, Clone)]
117pub struct BufferInfo {
118    pub id: usize,
119    pub path: String,
120    pub name: String,
121    pub modified: bool,
122}
123
124/// Parse a `path:line:col` string into its components.
125///
126/// Supports formats like `file.rs:10`, `file.rs:10:5`, and Windows paths with drive prefixes.
127pub fn parse_path_line_col(input: &str) -> (String, Option<usize>, Option<usize>) {
128    use std::path::{Component, Path};
129
130    let trimmed = input.trim();
131    if trimmed.is_empty() {
132        return (String::new(), None, None);
133    }
134
135    // Skip past Windows drive prefix (e.g., "C:") when looking for :line:col
136    let has_drive = Path::new(trimmed)
137        .components()
138        .next()
139        .is_some_and(|c| matches!(c, Component::Prefix(_)));
140    let search_start = if has_drive {
141        trimmed.find(':').map(|i| i + 1).unwrap_or(0)
142    } else {
143        0
144    };
145
146    let suffix = &trimmed[search_start..];
147    let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
148
149    // Reconstruct the path portion, re-attaching the drive prefix if needed
150    let rebuild_path = |rest: &str| {
151        if has_drive {
152            format!("{}{}", &trimmed[..search_start], rest)
153        } else {
154            rest.to_string()
155        }
156    };
157
158    // Try path:line:col, then path:line
159    let parsed = match parts.as_slice() {
160        [col_s, line_s, rest] if !rest.is_empty() => col_s
161            .parse::<usize>()
162            .ok()
163            .filter(|&c| c > 0)
164            .zip(line_s.parse::<usize>().ok().filter(|&l| l > 0))
165            .map(|(col, line)| (rebuild_path(rest), Some(line), Some(col))),
166        [line_s, rest] if !rest.is_empty() => line_s
167            .parse::<usize>()
168            .ok()
169            .filter(|&l| l > 0)
170            .map(|line| (rebuild_path(rest), Some(line), None)),
171        _ => None,
172    };
173
174    parsed.unwrap_or_else(|| (trimmed.to_string(), None, None))
175}
176
177/// Trait for quick open providers
178///
179/// Each provider handles a specific prefix and provides suggestions
180/// for that domain (files, commands, buffers, etc.)
181pub trait QuickOpenProvider: Send + Sync {
182    /// The prefix that triggers this provider (e.g., ">", "#", ":")
183    /// Empty string means this is the default provider (no prefix)
184    fn prefix(&self) -> &str;
185
186    /// Generate suggestions for the given query
187    ///
188    /// The query has already had the prefix stripped.
189    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion>;
190
191    /// Handle selection of a suggestion
192    ///
193    /// `suggestion` is the currently selected suggestion (already resolved by the caller).
194    /// `query` is the original query (without prefix).
195    fn on_select(
196        &self,
197        suggestion: Option<&Suggestion>,
198        query: &str,
199        context: &QuickOpenContext,
200    ) -> QuickOpenResult;
201
202    /// Downcast support for concrete provider access (e.g., updating cache).
203    fn as_any(&self) -> &dyn std::any::Any;
204}
205
206/// Registry for quick open providers
207pub struct QuickOpenRegistry {
208    /// Providers indexed by their prefix
209    providers: HashMap<String, Box<dyn QuickOpenProvider>>,
210}
211
212impl QuickOpenRegistry {
213    pub fn new() -> Self {
214        Self {
215            providers: HashMap::new(),
216        }
217    }
218
219    /// Register a provider
220    ///
221    /// If a provider with the same prefix exists, it will be replaced.
222    pub fn register(&mut self, provider: Box<dyn QuickOpenProvider>) {
223        let prefix = provider.prefix().to_string();
224        self.providers.insert(prefix, provider);
225    }
226
227    /// Get the provider for a given input
228    ///
229    /// Returns (provider, query_without_prefix)
230    pub fn get_provider_for_input<'a>(
231        &'a self,
232        input: &'a str,
233    ) -> Option<(&'a dyn QuickOpenProvider, &'a str)> {
234        // Try prefixes in order (longest first to handle overlapping prefixes)
235        let mut prefixes: Vec<_> = self.providers.keys().collect();
236        prefixes.sort_by_key(|b| std::cmp::Reverse(b.len()));
237
238        for prefix in prefixes {
239            if prefix.is_empty() {
240                continue; // Handle default provider last
241            }
242            if input.starts_with(prefix.as_str()) {
243                let query = &input[prefix.len()..];
244                return self.providers.get(prefix).map(|p| (p.as_ref(), query));
245            }
246        }
247
248        // Fall back to default provider (empty prefix)
249        self.providers.get("").map(|p| (p.as_ref(), input))
250    }
251}
252
253impl Default for QuickOpenRegistry {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    struct TestProvider {
264        prefix: String,
265    }
266
267    impl QuickOpenProvider for TestProvider {
268        fn prefix(&self) -> &str {
269            &self.prefix
270        }
271
272        fn suggestions(&self, _query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
273            vec![]
274        }
275
276        fn on_select(
277            &self,
278            _suggestion: Option<&Suggestion>,
279            _query: &str,
280            _context: &QuickOpenContext,
281        ) -> QuickOpenResult {
282            QuickOpenResult::None
283        }
284
285        fn as_any(&self) -> &dyn std::any::Any {
286            self
287        }
288    }
289
290    #[test]
291    fn parse_goto_line_input_absolute() {
292        assert_eq!(
293            parse_goto_line_input("500"),
294            Some(GotoLineTarget::Absolute(500))
295        );
296        assert_eq!(
297            parse_goto_line_input("  42 "),
298            Some(GotoLineTarget::Absolute(42))
299        );
300    }
301
302    #[test]
303    fn parse_goto_line_input_relative() {
304        assert_eq!(
305            parse_goto_line_input("+3"),
306            Some(GotoLineTarget::Relative(3))
307        );
308        assert_eq!(
309            parse_goto_line_input("-3"),
310            Some(GotoLineTarget::Relative(-3))
311        );
312        assert_eq!(
313            parse_goto_line_input(" -10 "),
314            Some(GotoLineTarget::Relative(-10))
315        );
316    }
317
318    #[test]
319    fn parse_goto_line_input_rejects_invalid() {
320        assert_eq!(parse_goto_line_input(""), None);
321        assert_eq!(parse_goto_line_input("   "), None);
322        assert_eq!(parse_goto_line_input("0"), None);
323        assert_eq!(parse_goto_line_input("+0"), None);
324        assert_eq!(parse_goto_line_input("-0"), None);
325        assert_eq!(parse_goto_line_input("+"), None);
326        assert_eq!(parse_goto_line_input("-"), None);
327        assert_eq!(parse_goto_line_input("++3"), None);
328        assert_eq!(parse_goto_line_input("+-3"), None);
329        assert_eq!(parse_goto_line_input("abc"), None);
330        assert_eq!(parse_goto_line_input("3a"), None);
331    }
332
333    #[test]
334    fn test_provider_routing() {
335        let mut registry = QuickOpenRegistry::new();
336
337        registry.register(Box::new(TestProvider {
338            prefix: "".to_string(),
339        }));
340        registry.register(Box::new(TestProvider {
341            prefix: ">".to_string(),
342        }));
343        registry.register(Box::new(TestProvider {
344            prefix: "#".to_string(),
345        }));
346
347        // Default provider for no prefix
348        let (provider, query) = registry.get_provider_for_input("hello").unwrap();
349        assert_eq!(provider.prefix(), "");
350        assert_eq!(query, "hello");
351
352        // Command provider
353        let (provider, query) = registry.get_provider_for_input(">save").unwrap();
354        assert_eq!(provider.prefix(), ">");
355        assert_eq!(query, "save");
356
357        // Buffer provider
358        let (provider, query) = registry.get_provider_for_input("#main").unwrap();
359        assert_eq!(provider.prefix(), "#");
360        assert_eq!(query, "main");
361    }
362}