Skip to main content

reovim_module_commands/
write.rs

1//! Write commands.
2
3use std::path::Path;
4
5use {
6    reovim_driver_codec::{CodecSessionState, ContentCodecFactoryStore},
7    reovim_driver_command::{
8        ArgKind, ArgSpec, Command, CommandContext, CommandHandler, CommandResult, RuntimeSignal,
9    },
10    reovim_driver_session::{BufferApi, CommandApi, ExtensionApi, SessionRuntime},
11    reovim_kernel::api::v1::{
12        CommandId, ModuleId,
13        events::kernel::{BufferSaved, BufferWillSave},
14    },
15};
16
17const COMMANDS_MODULE: ModuleId = ModuleId::new("commands");
18
19/// Write command - save the buffer to disk.
20///
21/// Behavior:
22/// - `:w` - Write current buffer to its file
23/// - `:w filename` - Write current buffer to specified file
24#[derive(Debug, Clone, Copy)]
25pub struct WriteCommand;
26
27/// Command ID for the write command (used by `WriteQuitCommand` for re-entrant call).
28pub const WRITE_CMD_ID: CommandId = CommandId::new(COMMANDS_MODULE, "write");
29
30impl Command for WriteCommand {
31    fn id(&self) -> CommandId {
32        WRITE_CMD_ID
33    }
34
35    fn description(&self) -> &'static str {
36        "Write the current buffer to disk. Use :w filename to save to a specific file."
37    }
38
39    fn args(&self) -> Vec<ArgSpec> {
40        vec![ArgSpec::optional("file", ArgKind::Rest, "File to write")]
41    }
42
43    fn names(&self) -> &[&'static str] {
44        &["w", "write"]
45    }
46}
47
48// Needs VFS + codec factories — tested by integration tests.
49#[cfg_attr(coverage_nightly, coverage(off))]
50impl CommandHandler for WriteCommand {
51    fn execute(&self, runtime: &mut SessionRuntime<'_>, ctx: &CommandContext) -> CommandResult {
52        let Some(buffer_id) = ctx.buffer_id() else {
53            return CommandResult::Error("no buffer".to_string());
54        };
55
56        // Determine target path: explicit argument or buffer's existing path
57        let explicit_file = ctx.string("file");
58        let path = if let Some(file) = explicit_file {
59            file.to_string()
60        } else if let Some(existing) = runtime.buffer_file_path(buffer_id) {
61            existing
62        } else {
63            return CommandResult::Error("No file name".to_string());
64        };
65
66        // Emit BufferWillSave so pre-save hooks (format-on-save) can modify content
67        #[allow(clippy::cast_possible_truncation)]
68        runtime.kernel().event_bus.emit(BufferWillSave {
69            buffer_id: buffer_id.as_usize() as u64,
70            path: path.clone(),
71        });
72
73        // Get buffer content AFTER pre-save hooks (formatters may have modified it)
74        let Some(content) = runtime.buffer_content(buffer_id) else {
75            return CommandResult::Error("buffer not found".to_string());
76        };
77
78        // Write via VFS, encoding through codec pipeline if metadata exists
79        let Some(vfs) = ctx.vfs() else {
80            return CommandResult::Error("VFS not available".to_string());
81        };
82        if let Err(e) = encode_and_write(runtime, &path, &content, vfs.as_ref()) {
83            return CommandResult::Error(format!("Write failed: {e}"));
84        }
85
86        // If saving to a new filename, update the buffer's file path
87        if explicit_file.is_some() {
88            runtime.rename_buffer(buffer_id, &path);
89        }
90
91        // Clear modified flag
92        runtime.set_buffer_modified(buffer_id, false);
93
94        // Emit BufferSaved event for subscribers (LSP DidSave, etc.)
95        #[allow(clippy::cast_possible_truncation)]
96        let buffer_id_raw = buffer_id.as_usize() as u64;
97        runtime.kernel().event_bus.emit(BufferSaved {
98            buffer_id: buffer_id_raw,
99            path,
100        });
101
102        CommandResult::Success
103    }
104}
105
106/// Encode content through codec pipeline and write to VFS.
107///
108/// If codec metadata exists for the buffer, uses the codec to encode
109/// back to the original format. Otherwise falls back to writing UTF-8 bytes.
110#[cfg_attr(coverage_nightly, coverage(off))]
111fn encode_and_write(
112    runtime: &mut SessionRuntime<'_>,
113    path: &str,
114    content: &str,
115    vfs: &dyn reovim_driver_vfs::VfsDriver,
116) -> Result<(), String> {
117    // Check if we have codec metadata for this buffer (shared extensions = per-buffer)
118    if let Some(buffer_id) = runtime.active_buffer() {
119        let codec_state = runtime.shared_ext_mut::<CodecSessionState>();
120        if let Some(metadata) = codec_state.and_then(|cs| cs.get(buffer_id)) {
121            // Check readonly (lossy decode means writing back would corrupt data)
122            if metadata.get("readonly") == Some("true") {
123                return Err("buffer is read-only (lossy codec decode)".to_string());
124            }
125
126            // Try to encode via codec
127            let content_type = metadata.content_type().clone();
128            let metadata_clone = metadata.clone();
129
130            let services = &runtime.kernel().services;
131            if let Some(factory_store) = services.get::<ContentCodecFactoryStore>()
132                && let Some(codec) = factory_store.find(&content_type)
133            {
134                match codec.encode(content, &metadata_clone) {
135                    Some(Ok(bytes)) => {
136                        return vfs
137                            .write(Path::new(path), &bytes)
138                            .map_err(|e| e.to_string());
139                    }
140                    Some(Err(e)) => {
141                        return Err(format!("codec encode failed: {e}"));
142                    }
143                    None => {
144                        return Err("buffer is read-only (one-way codec)".to_string());
145                    }
146                }
147            }
148        }
149    }
150
151    // Fallback: write as UTF-8
152    vfs.write_str(Path::new(path), content)
153        .map_err(|e| e.to_string())
154}
155
156/// Write and quit command - save and exit.
157///
158/// Uses re-entrant execution: calls `:write` first, then signals quit.
159#[derive(Debug, Clone, Copy)]
160pub struct WriteQuitCommand;
161
162impl Command for WriteQuitCommand {
163    fn id(&self) -> CommandId {
164        CommandId::new(COMMANDS_MODULE, "write-quit")
165    }
166
167    fn description(&self) -> &'static str {
168        "Write the current buffer and quit the editor."
169    }
170
171    fn names(&self) -> &[&'static str] {
172        &["wq"]
173    }
174}
175
176// Re-entrant command execution — tested by integration tests.
177#[cfg_attr(coverage_nightly, coverage(off))]
178impl CommandHandler for WriteQuitCommand {
179    fn execute(&self, runtime: &mut SessionRuntime<'_>, ctx: &CommandContext) -> CommandResult {
180        // Write first via re-entrant execution
181        let result = runtime.execute_command(WRITE_CMD_ID, ctx.clone());
182        if result.is_error() {
183            return result;
184        }
185
186        // Then signal quit
187        runtime.signal(RuntimeSignal::Quit);
188        CommandResult::Success
189    }
190}
191
192#[cfg(test)]
193#[path = "write_tests.rs"]
194mod tests;