alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Vim command-line state and parsing.

use bevy::prelude::Resource;

use super::error::VimError;
use crate::{StatusInfoText, presentation::TransparencyMode};

/// Maximum accepted `:` command length in Unicode scalar values.
const MAX_COMMAND_CHARS: usize = 256;

/// Command-line state for Vim `:` commands.
#[derive(Clone, Debug, Default, Eq, PartialEq, Resource)]
pub struct VimCommandState {
    /// Command text currently being edited after `:`.
    active_command: Option<VimCommandText>,
}

impl VimCommandState {
    /// Starts editing a `:` command.
    pub fn start(&mut self) {
        self.active_command = Some(VimCommandText::new());
    }

    /// Starts editing a `:` command prefilled with text.
    pub fn start_with(&mut self, text: &str) {
        let mut command = VimCommandText::new();
        command.push_str(text);
        self.active_command = Some(command);
    }

    /// Cancels active command editing.
    pub fn cancel(&mut self) {
        self.active_command = None;
    }

    /// Returns whether a command is being edited.
    #[must_use]
    pub const fn is_active(&self) -> bool {
        self.active_command.is_some()
    }

    /// Reads the active command, if any.
    #[must_use]
    pub const fn active_command(&self) -> Option<&VimCommandText> {
        self.active_command.as_ref()
    }

    /// Appends text to the active command.
    pub fn push_text(&mut self, text: &str) {
        if let Some(command) = &mut self.active_command {
            command.push_str(text);
        }
    }

    /// Deletes the previous scalar from the active command.
    pub fn backspace(&mut self) {
        if let Some(command) = &mut self.active_command {
            command.pop_char();
        }
    }

    /// Submits the active command for parsing.
    ///
    /// # Errors
    ///
    /// Returns [`VimError::NotAnEditorCommand`] when no command is active or when the command text
    /// is not one of the supported editor commands.
    pub fn submit(&mut self) -> Result<VimCommand, VimError> {
        let Some(command_text) = self.active_command.take() else {
            return Err(VimError::NotAnEditorCommand);
        };

        command_text.parse()
    }
}

/// User-provided text following a Vim `:` prompt.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct VimCommandText {
    /// Raw command text, without the leading colon.
    text: String,
}

impl VimCommandText {
    /// Creates an empty command text value.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            text: String::new(),
        }
    }

    /// Reads the command text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.text
    }

    /// Returns command text escaped for one-line status prompt rendering.
    #[must_use]
    pub fn status_text(&self) -> StatusInfoText {
        StatusInfoText::escaped_display(&self.text)
    }

    /// Appends text, truncating at the configured scalar limit.
    pub fn push_str(&mut self, text: &str) {
        let remaining = MAX_COMMAND_CHARS.saturating_sub(self.text.chars().count());
        self.text.extend(text.chars().take(remaining));
    }

    /// Removes the previous Unicode scalar value.
    pub fn pop_char(&mut self) {
        let _removed = self.text.pop();
    }

    /// Parses this command text as a supported Vim editor command.
    fn parse(self) -> Result<VimCommand, VimError> {
        parse_command(self.text.trim())
    }
}

/// Supported Vim editor commands.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VimCommand {
    /// `:write` / `:w`.
    Write,
    /// `:quit` / `:q`.
    Quit(QuitPolicy),
    /// Write the file and quit.
    WriteQuit(WriteQuitPolicy),
    /// Change window and surface transparency mode.
    SetTransparency(TransparencyMode),
}

/// Quit behavior for commands that may discard changes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QuitPolicy {
    /// Refuse to quit when the buffer is modified.
    PreserveChanges,
    /// Quit even when the buffer is modified.
    Force,
}

/// Write behavior for commands that write and quit.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteQuitPolicy {
    /// Always write before quitting, as `:wq` does.
    AlwaysWrite,
    /// Write only when the buffer is modified, as `:x` does.
    WriteIfModified,
}

/// Stable command identity used by the command catalog.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandName {
    /// Write the current buffer.
    Write,
    /// Quit the editor.
    Quit,
    /// Write and quit.
    WriteQuit,
    /// Write-if-modified and quit.
    Exit,
    /// Change a typed editor option.
    Set,
}

/// Argument shape accepted by a catalog entry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandArgSchema {
    /// No arguments are accepted.
    None,
    /// One typed `:set` option value is accepted.
    SetTransparency,
}

/// Authority required to execute a parsed command.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandAuthority {
    /// Writes through the persistence owner boundary and filesystem policy.
    BufferWrite,
    /// Requests lifecycle shutdown.
    Lifecycle,
    /// Writes through persistence policy and then requests lifecycle shutdown.
    BufferWriteAndLifecycle,
    /// Mutates presentation state.
    Presentation,
}

/// One catalog entry for a supported Vim command name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CommandSpec {
    /// Canonical command identity.
    name: CommandName,
    /// Accepted aliases, excluding generated policy variants such as `q!`.
    aliases: &'static [&'static str],
    /// Argument schema.
    args: CommandArgSchema,
    /// Authority required for execution.
    authority: CommandAuthority,
}

impl CommandSpec {
    /// Returns the canonical command identity.
    #[must_use]
    pub const fn name(self) -> CommandName {
        self.name
    }

    /// Returns accepted aliases.
    #[must_use]
    pub const fn aliases(self) -> &'static [&'static str] {
        self.aliases
    }

    /// Returns the argument schema.
    #[must_use]
    pub const fn args(self) -> CommandArgSchema {
        self.args
    }

    /// Returns the required authority class.
    #[must_use]
    pub const fn authority(self) -> CommandAuthority {
        self.authority
    }
}

/// Typed command catalog.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct CommandCatalog;

impl CommandCatalog {
    /// Returns all supported command specs.
    #[must_use]
    pub const fn specs(&self) -> &'static [CommandSpec] {
        COMMAND_SPECS
    }

    /// Parses command text through the catalog.
    ///
    /// # Errors
    ///
    /// Returns [`VimError::NotAnEditorCommand`] when the name or argument
    /// shape is unsupported.
    pub fn parse(&self, text: &str) -> Result<VimCommand, VimError> {
        let text = text.trim();
        parse_force_variant(text)
            .or_else(|| parse_catalog_entry(text))
            .ok_or(VimError::NotAnEditorCommand)
    }
}

/// Built-in command catalog.
const COMMAND_SPECS: &[CommandSpec] = &[
    CommandSpec {
        name: CommandName::Write,
        aliases: &["w", "write"],
        args: CommandArgSchema::None,
        authority: CommandAuthority::BufferWrite,
    },
    CommandSpec {
        name: CommandName::Quit,
        aliases: &["q", "quit"],
        args: CommandArgSchema::None,
        authority: CommandAuthority::Lifecycle,
    },
    CommandSpec {
        name: CommandName::WriteQuit,
        aliases: &["wq"],
        args: CommandArgSchema::None,
        authority: CommandAuthority::BufferWriteAndLifecycle,
    },
    CommandSpec {
        name: CommandName::Exit,
        aliases: &["x", "xit", "exit"],
        args: CommandArgSchema::None,
        authority: CommandAuthority::BufferWriteAndLifecycle,
    },
    CommandSpec {
        name: CommandName::Set,
        aliases: &["set"],
        args: CommandArgSchema::SetTransparency,
        authority: CommandAuthority::Presentation,
    },
];

/// Parses supported Vim command names.
fn parse_command(text: &str) -> Result<VimCommand, VimError> {
    CommandCatalog.parse(text)
}

/// Parses force-style aliases that alter command policy.
fn parse_force_variant(text: &str) -> Option<VimCommand> {
    match text {
        "q!" | "quit!" => Some(VimCommand::Quit(QuitPolicy::Force)),
        "wq!" => Some(VimCommand::WriteQuit(WriteQuitPolicy::AlwaysWrite)),
        _ => None,
    }
}

/// Parses one catalog entry.
fn parse_catalog_entry(text: &str) -> Option<VimCommand> {
    let (name, argument) = split_command_name(text);
    let spec = COMMAND_SPECS
        .iter()
        .copied()
        .find(|spec| spec.aliases.contains(&name))?;

    match spec.name {
        CommandName::Write if argument.is_empty() => Some(VimCommand::Write),
        CommandName::Quit if argument.is_empty() => {
            Some(VimCommand::Quit(QuitPolicy::PreserveChanges))
        }
        CommandName::WriteQuit if argument.is_empty() => {
            Some(VimCommand::WriteQuit(WriteQuitPolicy::AlwaysWrite))
        }
        CommandName::Exit if argument.is_empty() => {
            Some(VimCommand::WriteQuit(WriteQuitPolicy::WriteIfModified))
        }
        CommandName::Set => parse_set_argument(argument),
        _ => None,
    }
}

/// Splits a command into name and untrusted argument tail.
fn split_command_name(text: &str) -> (&str, &str) {
    text.split_once(char::is_whitespace)
        .map_or((text, ""), |(name, argument)| (name, argument.trim()))
}

/// Parses supported `:set` arguments.
fn parse_set_argument(text: &str) -> Option<VimCommand> {
    parse_transparency_mode(text).map(VimCommand::SetTransparency)
}

/// Parses a user-facing transparency mode.
const fn parse_transparency_mode(text: &str) -> Option<TransparencyMode> {
    match text.as_bytes() {
        b"opaque" => Some(TransparencyMode::Opaque),
        b"transparent" => Some(TransparencyMode::Transparent),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        CommandArgSchema, CommandAuthority, CommandCatalog, CommandName, QuitPolicy, VimCommand,
        VimCommandState, VimCommandText, WriteQuitPolicy,
    };
    use crate::presentation::TransparencyMode;
    use crate::vim::VimError;

    #[test]
    fn parses_common_write_and_quit_commands() {
        assert_eq!(
            VimCommandText::new().parse(),
            Err(VimError::NotAnEditorCommand)
        );

        let mut command = VimCommandText::new();
        command.push_str("wq");
        assert_eq!(
            command.parse(),
            Ok(VimCommand::WriteQuit(WriteQuitPolicy::AlwaysWrite))
        );

        let mut command = VimCommandText::new();
        command.push_str("q!");
        assert_eq!(command.parse(), Ok(VimCommand::Quit(QuitPolicy::Force)));
    }

    #[test]
    fn command_state_submits_and_clears_active_command() {
        let mut state = VimCommandState::default();

        state.start();
        state.push_text("write");

        assert_eq!(state.submit(), Ok(VimCommand::Write));
        assert!(!state.is_active());
    }

    #[test]
    fn parses_transparency_commands() {
        let mut command = VimCommandText::new();
        command.push_str("set transparent");
        assert_eq!(
            command.parse(),
            Ok(VimCommand::SetTransparency(TransparencyMode::Transparent))
        );

        let mut command = VimCommandText::new();
        command.push_str("set opaque");
        assert_eq!(
            command.parse(),
            Ok(VimCommand::SetTransparency(TransparencyMode::Opaque))
        );

        let mut command = VimCommandText::new();
        command.push_str("set glass");
        assert_eq!(command.parse(), Err(VimError::NotAnEditorCommand));
    }

    #[test]
    fn command_catalog_exposes_typed_specs_and_authority() {
        let catalog = CommandCatalog;

        let write = catalog
            .specs()
            .iter()
            .find(|spec| spec.name() == CommandName::Write)
            .unwrap();
        assert_eq!(write.aliases(), &["w", "write"]);
        assert_eq!(write.args(), CommandArgSchema::None);
        assert_eq!(write.authority(), CommandAuthority::BufferWrite);

        let set = catalog
            .specs()
            .iter()
            .find(|spec| spec.name() == CommandName::Set)
            .unwrap();
        assert_eq!(set.args(), CommandArgSchema::SetTransparency);
        assert_eq!(set.authority(), CommandAuthority::Presentation);
    }
}