Skip to main content

reovim_module_snippet/
lib.rs

1#![cfg_attr(coverage_nightly, allow(unused_features))]
2#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
3//! Snippet expansion module for reovim (#136).
4//!
5//! Provides TextMate/LSP-compatible snippet expansion with intelligent
6//! tab stop navigation, placeholder mirroring, variable resolution,
7//! regex transforms, and choice support.
8//!
9//! # Architecture
10//!
11//! - `ast` - Snippet element types (full EBNF grammar coverage)
12//! - `parser` - Recursive descent parser (full TextMate/LSP grammar)
13//! - `engine` - Active snippet tracking with position updates
14//! - `transform` - Regex transform execution with case modifiers
15//! - `variables` - Built-in variable resolver (`TM_FILENAME`, etc.)
16//! - `provider` - Snippet provider trait and registry
17//! - `loader` - JSON file loader (VSCode-compatible format)
18//! - `resolver` - Mode key resolver for snippet navigation mode
19//! - `state` - Per-client session extension state
20//! - `command` - Command handlers (expand, jump next/prev, cancel)
21//!
22//! # Commands
23//!
24//! - `snippet:expand` (`<C-s>` in insert mode) - Expand snippet at cursor
25//! - `snippet:jump-next` (`<Tab>` in snippet mode) - Next tab stop
26//! - `snippet:jump-prev` (`<S-Tab>` in snippet mode) - Previous tab stop
27//! - `snippet:cancel` (`<Esc>` in snippet mode) - Cancel snippet navigation
28
29pub mod ast;
30pub mod command;
31pub mod engine;
32mod expander;
33pub mod ids;
34pub mod inheritance;
35mod keybinding;
36pub mod loader;
37pub mod loader_friendly;
38pub mod parser;
39pub mod project;
40pub mod provider;
41pub mod resolver;
42pub mod state;
43pub mod transform;
44pub mod variables;
45
46use std::path::Path;
47
48use std::sync::Arc;
49
50use {
51    reovim_driver_command::{CommandHandler, CommandHandlerStore, CommandProvider},
52    reovim_driver_input::{KeybindingStore, ModeInfo, ModeInfoStore, ResolverRegistry},
53    reovim_driver_session::SnippetExpanderRegistry,
54    reovim_kernel::api::v1::{
55        CursorStyle, KeybindingRegistration, Module, ModuleContext, ModuleError, ModuleId,
56        ProbeResult, Version, pr_info,
57    },
58};
59
60use crate::{
61    loader::JsonSnippetProvider,
62    loader_friendly::FriendlySnippetsProvider,
63    provider::{SnippetRegistry, SnippetRegistryHandle},
64};
65
66/// Build a snippet registry from the module data directory.
67///
68/// Loads providers in priority order (first registered = highest priority):
69/// 1. User snippets: `{data_dir}/user/` (or legacy `{data_dir}/snippets/`)
70/// 2. Friendly-snippets: `{data_dir}/friendly-snippets/` (if present)
71/// 3. Built-in snippets: `{data_dir}/built-in/`
72#[must_use]
73pub fn build_registry(data_dir: &Path) -> SnippetRegistry {
74    let mut registry = SnippetRegistry::new();
75
76    // 1. User snippets (highest priority)
77    let user_dir = data_dir.join("user");
78    let legacy_dir = data_dir.join("snippets");
79
80    let user_source = if user_dir.exists() {
81        &user_dir
82    } else {
83        // Backward compat: treat legacy `snippets/` as user source
84        &legacy_dir
85    };
86
87    if let Ok(provider) = JsonSnippetProvider::load_directory(user_source) {
88        registry.register(Box::new(provider));
89    }
90
91    // 2. Friendly-snippets (middle priority)
92    let friendly_dir = data_dir.join("friendly-snippets");
93    if friendly_dir.join("package.json").exists()
94        && let Ok(provider) = FriendlySnippetsProvider::load(&friendly_dir)
95    {
96        registry.register(Box::new(provider));
97    }
98
99    // 3. Built-in snippets (lowest priority)
100    let builtin_dir = data_dir.join("built-in");
101    if let Ok(provider) = JsonSnippetProvider::load_directory(&builtin_dir) {
102        registry.register(Box::new(provider));
103    }
104
105    registry
106}
107
108/// Snippet expansion module.
109///
110/// Manages snippet loading, parsing, expansion, and tab stop navigation.
111/// Registers commands, mode resolver, and keybindings during init.
112pub struct SnippetModule {
113    /// Snippet registry handle (kept alive for command handler access and hot reload).
114    handle: Option<SnippetRegistryHandle>,
115    /// Data directory for reload support.
116    data_dir: Option<std::path::PathBuf>,
117}
118
119impl SnippetModule {
120    /// Create a new snippet module.
121    #[must_use]
122    pub const fn new() -> Self {
123        Self {
124            handle: None,
125            data_dir: None,
126        }
127    }
128}
129
130impl Default for SnippetModule {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl Module for SnippetModule {
137    fn id(&self) -> ModuleId {
138        ids::MODULE
139    }
140
141    fn name(&self) -> &'static str {
142        "Snippet"
143    }
144
145    fn version(&self) -> Version {
146        Version::new(0, 1, 0)
147    }
148
149    fn dependencies(&self) -> Vec<ModuleId> {
150        vec![]
151    }
152
153    fn optional_dependencies(&self) -> Vec<ModuleId> {
154        // Personality modules (e.g., vim) populate ModeBridgeStore before us.
155        // Optional: snippet still works without a personality, just no parent mode bridging.
156        vec![ModuleId::new("vim")]
157    }
158
159    #[cfg_attr(coverage_nightly, coverage(off))]
160    fn init(&mut self, ctx: &ModuleContext) -> ProbeResult {
161        // 1. Load snippet files from data directory (multi-source hierarchy)
162        let handle = SnippetRegistryHandle::new(build_registry(&ctx.data_dir));
163        self.handle = Some(handle.clone());
164        self.data_dir = Some(ctx.data_dir.clone());
165
166        // #585/#610: Read parent mode from ModeBridgeStore (manifest-driven).
167        // Falls back to snippet's own mode if no personality loaded.
168        let modes = ctx.services.get_or_create::<ModeInfoStore>();
169        let parent_insert = resolve_snippet_parent(ctx, &modes);
170
171        // Use resolved parent or fallback to snippet's own mode (reduced functionality)
172        let effective_parent = parent_insert.unwrap_or(ids::NAVIGATING_MODE);
173
174        // 2. Register command handlers with return mode
175        let store = ctx.services.get_or_create::<CommandHandlerStore>();
176        let commands =
177            command::all_commands(handle, effective_parent.clone(), ctx.data_dir.clone());
178        let command_count = commands.len();
179        for cmd_handler in commands {
180            store.add(cmd_handler);
181        }
182
183        // 3. Register snippet resolver with looked-up parent
184        let resolvers = ctx.services.get_or_create::<ResolverRegistry>();
185        resolvers.register(resolver::SnippetResolver::with_parent(effective_parent.clone()));
186
187        // 4. Register mode info for display
188        modes.add(ModeInfo {
189            id: ids::NAVIGATING_MODE,
190            display_name: "SNIPPET",
191            cursor_style: CursorStyle::Bar,
192            accepts_char_input: true,
193            has_selection: true,
194            inherits_from: Some(effective_parent),
195            is_entry: false,
196        });
197
198        // 5. Register keybindings
199        let keybinding_store = ctx.services.get_or_create::<KeybindingStore>();
200        keybinding_store.add_all(self.keybindings());
201
202        // 6. Register SnippetExpander implementation (#542: decouple completion from this module).
203        let expander_registry = ctx.services.get_or_create::<SnippetExpanderRegistry>();
204        expander_registry.register(Arc::new(expander::SnippetExpanderImpl));
205
206        pr_info!("Snippet module initialized with {command_count} commands");
207        ProbeResult::Success
208    }
209
210    #[cfg_attr(coverage_nightly, coverage(off))]
211    fn exit(&mut self) -> Result<(), ModuleError> {
212        self.handle = None;
213        self.data_dir = None;
214        Ok(())
215    }
216
217    fn provides(&self) -> &[&'static str] {
218        &[reovim_capabilities::SNIPPET_PROVIDER]
219    }
220
221    #[cfg_attr(coverage_nightly, coverage(off))]
222    fn keybindings(&self) -> Vec<KeybindingRegistration> {
223        // Vim-mode bindings from personality adapter (#700)
224        let mut bindings = keybinding::all();
225        // Snippet-mode bindings (module-owned, already qualified)
226        bindings.extend([
227            // Snippet navigating mode: tab stop navigation
228            KeybindingRegistration::new("<Tab>", ids::JUMP_NEXT)
229                .with_modes(&["snippet:navigating"])
230                .with_description("Jump to next tab stop")
231                .with_category("snippet"),
232            // S-Tab navigates to previous tab stop
233            KeybindingRegistration::new("<S-Tab>", ids::JUMP_PREV)
234                .with_modes(&["snippet:navigating"])
235                .with_description("Jump to previous tab stop")
236                .with_category("snippet"),
237            // Esc cancels snippet navigation
238            KeybindingRegistration::new("<Esc>", ids::CANCEL)
239                .with_modes(&["snippet:navigating"])
240                .with_description("Cancel snippet navigation")
241                .with_category("snippet"),
242        ]);
243        bindings
244    }
245}
246
247impl CommandProvider for SnippetModule {
248    fn command_handlers(&self) -> Vec<Box<dyn CommandHandler>> {
249        let handle = self
250            .handle
251            .clone()
252            .unwrap_or_else(|| SnippetRegistryHandle::new(SnippetRegistry::new()));
253        // Fallback ModeId for CommandProvider (testing/FFI only).
254        // In production, init() resolves the real mode from ModeInfoStore.
255        let fallback_mode = reovim_kernel::api::v1::ModeId::new(ModuleId::new("editor"), "insert");
256        let data_dir = self
257            .data_dir
258            .clone()
259            .unwrap_or_else(|| std::path::PathBuf::from("/tmp/reovim-snippet-fallback"));
260        command::all_commands(handle, fallback_mode, data_dir)
261    }
262}
263
264/// Resolve the parent mode for snippet:navigating from `ModeBridgeStore`.
265///
266/// Returns `None` if no personality module is loaded (reduced functionality).
267#[cfg_attr(coverage_nightly, coverage(off))]
268fn resolve_snippet_parent(
269    ctx: &ModuleContext,
270    modes: &ModeInfoStore,
271) -> Option<reovim_kernel::api::v1::ModeId> {
272    use reovim_driver_manifest::ModeBridgeStore;
273
274    let bridge_store = ctx.services.get::<ModeBridgeStore>()?;
275    let parent_str = bridge_store.find_parent("snippet:navigating")?;
276    let (module, name) = parent_str.split_once(':')?;
277
278    if let Some(mode_id) = modes.find_by_name(module, name) {
279        return Some(mode_id);
280    }
281    tracing::warn!("Mode bridge parent '{parent_str}' not found in ModeInfoStore");
282    None
283}
284
285// Generate FFI entry points for dynamic loading (only when building standalone cdylib)
286#[cfg(feature = "dynamic")]
287reovim_module_macros::declare_module!(SnippetModule);
288
289#[cfg(test)]
290#[path = "lib_tests.rs"]
291mod tests;