Skip to main content

rmux_core/
command_queue.rs

1//! tmux-style command queue data model.
2//!
3//! Command parsing owns parse-time expansion such as `$VAR`, `~user`,
4//! `%if`, and `name=value`. The queue owns ordering, group IDs, insertion,
5//! wait boundaries, and group abort decisions; command handlers own
6//! execution-time expansion such as formats, target lookup, and option lookup.
7
8use std::collections::VecDeque;
9
10use crate::command_parser::{CommandGrouping, ParsedCommand, ParsedCommands};
11
12/// A tmux command queue group ID.
13///
14/// Commands in the same group are adjacent commands from one parsed command
15/// list that must be skipped together after the first execution error.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct CommandGroup(u64);
18
19impl CommandGroup {
20    /// Returns the numeric group ID.
21    #[must_use]
22    pub const fn get(self) -> u64 {
23        self.0
24    }
25}
26
27/// One parsed command plus the queue group assigned to it.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct QueuedCommand {
30    command: ParsedCommand,
31    group: CommandGroup,
32}
33
34impl QueuedCommand {
35    /// Returns the parsed command payload.
36    #[must_use]
37    pub const fn command(&self) -> &ParsedCommand {
38        &self.command
39    }
40
41    /// Consumes this item and returns the parsed command payload.
42    #[must_use]
43    pub fn into_command(self) -> ParsedCommand {
44        self.command
45    }
46
47    /// Returns this item's queue group.
48    #[must_use]
49    pub const fn group(&self) -> CommandGroup {
50        self.group
51    }
52}
53
54/// Result status for a fired queue command.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CommandQueueResult {
57    /// The command completed and the queue may advance.
58    Normal,
59    /// The command failed; remaining items in the same group must be removed.
60    Error,
61    /// The command is waiting for an asynchronous continuation before the
62    /// queue may advance.
63    Wait,
64}
65
66/// Queue state for pre-parsed tmux commands.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CommandQueue {
69    items: VecDeque<QueuedCommand>,
70    next_group: u64,
71}
72
73impl Default for CommandQueue {
74    fn default() -> Self {
75        Self {
76            items: VecDeque::new(),
77            next_group: 1,
78        }
79    }
80}
81
82impl CommandQueue {
83    /// Creates an empty command queue.
84    #[must_use]
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Builds a queue from one parsed command list.
90    #[must_use]
91    pub fn from_parsed(commands: ParsedCommands) -> Self {
92        let mut queue = Self::new();
93        queue.append_parsed(commands);
94        queue
95    }
96
97    /// Returns whether the queue currently has no pending commands.
98    #[must_use]
99    pub fn is_empty(&self) -> bool {
100        self.items.is_empty()
101    }
102
103    /// Returns the number of pending commands.
104    #[must_use]
105    pub fn len(&self) -> usize {
106        self.items.len()
107    }
108
109    /// Returns the pending commands in queue order.
110    #[must_use]
111    pub fn items(&self) -> &VecDeque<QueuedCommand> {
112        &self.items
113    }
114
115    /// Appends a parsed command list to the back of the queue.
116    pub fn append_parsed(&mut self, commands: ParsedCommands) {
117        let items = self.assign_groups(commands);
118        self.items.extend(items);
119    }
120
121    /// Inserts a parsed command list immediately after the command currently
122    /// being executed.
123    ///
124    /// Callers pop the current item before firing it, so insertion after the
125    /// current item is represented as prepending the newly built items before
126    /// the remaining queue tail.
127    pub fn insert_after_current(&mut self, commands: ParsedCommands) {
128        let items = self.assign_groups(commands);
129        for item in items.into_iter().rev() {
130            self.items.push_front(item);
131        }
132    }
133
134    /// Pops the next queue item.
135    pub fn pop_front(&mut self) -> Option<QueuedCommand> {
136        self.items.pop_front()
137    }
138
139    /// Removes all pending items from the same group.
140    ///
141    /// This matches tmux `cmdq_remove_group`: the failed item has already
142    /// fired, and only later items are eligible for removal.
143    pub fn remove_group(&mut self, group: CommandGroup) -> usize {
144        let before = self.items.len();
145        self.items.retain(|item| item.group != group);
146        before - self.items.len()
147    }
148
149    fn assign_groups(&mut self, commands: ParsedCommands) -> Vec<QueuedCommand> {
150        let grouping = commands.grouping();
151        let mut assigned = Vec::new();
152        let mut current_line = None;
153        let mut current_group = None;
154
155        for command in commands.into_commands() {
156            let group = match grouping {
157                CommandGrouping::OneGroup => {
158                    *current_group.get_or_insert_with(|| self.next_group())
159                }
160                CommandGrouping::ByLine if current_line == Some(command.line()) => {
161                    current_group.expect("line reuse requires an existing command group")
162                }
163                CommandGrouping::ByLine => {
164                    current_line = Some(command.line());
165                    let group = self.next_group();
166                    current_group = Some(group);
167                    group
168                }
169            };
170
171            assigned.push(QueuedCommand { command, group });
172        }
173
174        assigned
175    }
176
177    fn next_group(&mut self) -> CommandGroup {
178        let group = CommandGroup(self.next_group);
179        self.next_group += 1;
180        group
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use crate::command_parser::{CommandArgument, CommandParser};
187
188    use super::CommandQueue;
189
190    fn queue_groups(input: &str) -> Vec<u64> {
191        let parsed = CommandParser::new().parse(input).expect("commands parse");
192        CommandQueue::from_parsed(parsed)
193            .items()
194            .iter()
195            .map(|item| item.group().get())
196            .collect()
197    }
198
199    #[test]
200    fn same_source_line_commands_share_a_group() {
201        assert_eq!(
202            queue_groups("display-message first ; display-message second"),
203            [1, 1]
204        );
205    }
206
207    #[test]
208    fn newline_separated_commands_get_distinct_groups() {
209        assert_eq!(
210            queue_groups("display-message first\ndisplay-message second"),
211            [1, 2]
212        );
213    }
214
215    #[test]
216    fn argv_trailing_semicolon_commands_share_the_default_line_group() {
217        let parsed = CommandParser::new()
218            .parse_arguments(["display-message;", "display-message", "ok"])
219            .expect("argv commands parse");
220        let queue = CommandQueue::from_parsed(parsed);
221        let groups = queue
222            .items()
223            .iter()
224            .map(|item| item.group().get())
225            .collect::<Vec<_>>();
226
227        assert_eq!(groups, [1, 1]);
228    }
229
230    #[test]
231    fn one_group_string_mode_collapses_multiline_commands() {
232        let parsed = CommandParser::new()
233            .parse_one_group("display-message first\ndisplay-message second")
234            .expect("commands parse");
235        let groups = CommandQueue::from_parsed(parsed)
236            .items()
237            .iter()
238            .map(|item| item.group().get())
239            .collect::<Vec<_>>();
240
241        assert_eq!(groups, [1, 1]);
242    }
243
244    #[test]
245    fn brace_arguments_remain_preparsed_command_lists() {
246        let parsed = CommandParser::new()
247            .parse("if-shell -F 1 { display-message yes ; list-sessions }")
248            .expect("commands parse");
249        let queue = CommandQueue::from_parsed(parsed);
250        let arguments = queue.items()[0].command().arguments();
251        let nested = match &arguments[2] {
252            CommandArgument::Commands(nested) => nested,
253            CommandArgument::String(value) => panic!("expected parsed command list, got {value}"),
254        };
255        let names = nested
256            .commands()
257            .iter()
258            .map(|command| command.name())
259            .collect::<Vec<_>>();
260
261        assert_eq!(names, ["display-message", "list-sessions"]);
262    }
263
264    #[test]
265    fn one_group_mode_propagates_into_brace_command_lists() {
266        let parsed = CommandParser::new()
267            .parse_one_group("if-shell -F 1 { display-message yes\nlist-sessions }")
268            .expect("commands parse");
269        let queue = CommandQueue::from_parsed(parsed);
270        let arguments = queue.items()[0].command().arguments();
271        let nested = match &arguments[2] {
272            CommandArgument::Commands(nested) => nested.clone(),
273            CommandArgument::String(value) => panic!("expected parsed command list, got {value}"),
274        };
275        let groups = CommandQueue::from_parsed(nested)
276            .items()
277            .iter()
278            .map(|item| item.group().get())
279            .collect::<Vec<_>>();
280
281        assert_eq!(groups, [1, 1]);
282    }
283
284    #[test]
285    fn insert_after_current_preserves_inserted_order_and_fresh_groups() {
286        let parser = CommandParser::new();
287        let parsed = parser
288            .parse("display-message parent ; display-message tail")
289            .expect("commands parse");
290        let mut queue = CommandQueue::from_parsed(parsed);
291        let current = queue.pop_front().expect("current command");
292
293        queue.insert_after_current(
294            parser
295                .parse("display-message inserted-one\ndisplay-message inserted-two")
296                .expect("inserted commands parse"),
297        );
298
299        let queued = queue
300            .items()
301            .iter()
302            .map(|item| {
303                (
304                    item.command().arguments()[0]
305                        .as_string()
306                        .expect("display-message argument")
307                        .to_owned(),
308                    item.group().get(),
309                )
310            })
311            .collect::<Vec<_>>();
312
313        assert_eq!(
314            queued,
315            [
316                ("inserted-one".to_owned(), 2),
317                ("inserted-two".to_owned(), 3),
318                ("tail".to_owned(), current.group().get()),
319            ]
320        );
321    }
322
323    #[test]
324    fn remove_group_removes_noncontiguous_later_members() {
325        let parser = CommandParser::new();
326        let parsed = parser
327            .parse("display-message first ; display-message skipped")
328            .expect("commands parse");
329        let mut queue = CommandQueue::from_parsed(parsed);
330        let failed = queue.pop_front().expect("failed command");
331
332        queue.insert_after_current(
333            parser
334                .parse("display-message inserted")
335                .expect("inserted command parses"),
336        );
337
338        assert_eq!(queue.remove_group(failed.group()), 1);
339        assert_eq!(queue.len(), 1);
340        assert_eq!(
341            queue.items()[0].command().arguments()[0].as_string(),
342            Some("inserted")
343        );
344    }
345
346    #[test]
347    fn alias_expansion_uses_string_mode_and_inherits_source_line() {
348        let parsed = CommandParser::new()
349            .with_command_alias("q=display-message one\ndisplay-message two")
350            .expect("valid alias")
351            .parse("q\ndisplay-message kept")
352            .expect("commands parse");
353        let queue = CommandQueue::from_parsed(parsed);
354        let groups = queue
355            .items()
356            .iter()
357            .map(|item| item.group().get())
358            .collect::<Vec<_>>();
359
360        assert_eq!(groups, [1, 1, 2]);
361    }
362
363    #[test]
364    fn remove_group_preserves_commands_from_later_lines() {
365        let parsed = CommandParser::new()
366            .parse("display-message first ; display-message skipped\ndisplay-message kept")
367            .expect("commands parse");
368        let mut queue = CommandQueue::from_parsed(parsed);
369        let failed = queue.pop_front().expect("first command");
370
371        assert_eq!(queue.remove_group(failed.group()), 1);
372        assert_eq!(queue.len(), 1);
373        assert_eq!(
374            queue.items()[0].command().arguments()[0].as_string(),
375            Some("kept")
376        );
377    }
378}