Skip to main content

rmux_core/options/
registry.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use rmux_proto::types::OptionScopeSelector;
5use rmux_proto::{OptionName, RmuxError};
6
7pub(crate) const SCOPE_SERVER: u8 = 0x1;
8pub(crate) const SCOPE_SESSION: u8 = 0x2;
9pub(crate) const SCOPE_WINDOW: u8 = 0x4;
10pub(crate) const SCOPE_PANE: u8 = 0x8;
11
12pub(crate) const SHOW_SERVER: u8 = 0x1;
13pub(crate) const SHOW_SESSION: u8 = 0x2;
14pub(crate) const SHOW_WINDOW: u8 = 0x4;
15pub(crate) const SHOW_PANE: u8 = 0x8;
16
17pub(crate) const EFFECT_NONE: OptionChangeMask = OptionChangeMask(0);
18pub(crate) const EFFECT_RENDER: OptionChangeMask = OptionChangeMask(0x1);
19pub(crate) const EFFECT_PANE_STYLE: OptionChangeMask = OptionChangeMask(0x2);
20pub(crate) const EFFECT_TRANSCRIPT_LIMIT: OptionChangeMask = OptionChangeMask(0x4);
21pub(crate) const EFFECT_STYLE_PARSE: OptionChangeMask = OptionChangeMask(0x8);
22pub(crate) const EFFECT_TERMINAL_FEATURES: OptionChangeMask = OptionChangeMask(0x10);
23pub(crate) const EFFECT_KEY_TABLE: OptionChangeMask = OptionChangeMask(0x20);
24pub(crate) const EFFECT_USER_KEYS: OptionChangeMask = OptionChangeMask(0x40);
25pub(crate) const EFFECT_CURSOR: OptionChangeMask = OptionChangeMask(0x80);
26pub(crate) const EFFECT_FILL_CHARACTER: OptionChangeMask = OptionChangeMask(0x100);
27pub(crate) const EFFECT_STATUS_TIMER: OptionChangeMask = OptionChangeMask(0x200);
28pub(crate) const EFFECT_ALERTS: OptionChangeMask = OptionChangeMask(0x400);
29pub(crate) const EFFECT_LAYOUT: OptionChangeMask = OptionChangeMask(0x800);
30pub(crate) const EFFECT_SCROLLBAR_STYLE: OptionChangeMask = OptionChangeMask(0x1000);
31pub(crate) const EFFECT_CODEPOINT_WIDTHS: OptionChangeMask = OptionChangeMask(0x2000);
32pub(crate) const EFFECT_INPUT_BUFFER_SIZE: OptionChangeMask = OptionChangeMask(0x4000);
33pub(crate) const EFFECT_PANE_COLOURS: OptionChangeMask = OptionChangeMask(0x8000);
34pub(crate) const EFFECT_AUTOMATIC_RENAME: OptionChangeMask = OptionChangeMask(0x10000);
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub(crate) enum GlobalRoot {
38    Server,
39    Session,
40    Window,
41}
42
43#[allow(dead_code)]
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub(crate) enum OptionValueType {
46    String,
47    Number { minimum: u32 },
48    Key,
49    Colour,
50    Flag,
51    Choice(&'static [&'static str]),
52    Command,
53}
54
55#[allow(dead_code)]
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub(crate) enum DefaultValue {
58    Scalar(&'static str),
59    Array(&'static [&'static str]),
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct OptionChangeMask(u32);
64
65impl OptionChangeMask {
66    pub const fn contains(self, other: Self) -> bool {
67        (self.0 & other.0) == other.0
68    }
69
70    pub const fn union(self, other: Self) -> Self {
71        Self(self.0 | other.0)
72    }
73
74    pub const fn is_empty(self) -> bool {
75        self.0 == 0
76    }
77
78    /// Returns whether the mask includes alert-timer side effects.
79    #[must_use]
80    pub const fn affects_alerts(self) -> bool {
81        self.contains(EFFECT_ALERTS)
82    }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub(crate) struct OptionMetadata {
87    option: OptionName,
88    name: &'static str,
89    aliases: &'static [&'static str],
90    show_mask: u8,
91    scope_mask: u8,
92    global_root: GlobalRoot,
93    value_type: OptionValueType,
94    default: DefaultValue,
95    separator: &'static str,
96    is_array: bool,
97    effects: OptionChangeMask,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct OptionQuery {
102    name: String,
103    metadata: Option<&'static OptionMetadata>,
104    index: Option<u32>,
105}
106
107/// Error returned when resolving an option name before validating its value.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum OptionLookupError {
110    /// The option name does not match any known option or user option.
111    InvalidName(String),
112    /// The option prefix matches more than one known option.
113    AmbiguousPrefix(String),
114    /// The option array index syntax is malformed.
115    InvalidIndexSyntax(String),
116    /// User options do not support array indexes.
117    UserOptionArrayIndex(String),
118}
119
120impl OptionLookupError {
121    /// Returns whether tmux `set-option -q` should suppress this lookup error.
122    #[must_use]
123    pub const fn is_quiet_set_option_lookup_error(&self) -> bool {
124        matches!(self, Self::InvalidName(_) | Self::AmbiguousPrefix(_))
125    }
126
127    /// Converts the typed lookup error into the existing user-facing RMUX error.
128    #[must_use]
129    pub fn into_rmux_error(self) -> RmuxError {
130        match self {
131            Self::InvalidName(name) => RmuxError::Server(format!("invalid option: {name}")),
132            Self::AmbiguousPrefix(name) => RmuxError::Server(format!("ambiguous option: {name}")),
133            Self::InvalidIndexSyntax(name) => {
134                RmuxError::Server(format!("invalid option index syntax: {name}"))
135            }
136            Self::UserOptionArrayIndex(name) => RmuxError::Server(format!(
137                "user option does not support array indexes: {name}"
138            )),
139        }
140    }
141}
142
143#[path = "table.rs"]
144mod table;
145use table::OPTIONS;
146
147static OPTION_LOOKUP: OnceLock<OptionLookup> = OnceLock::new();
148
149struct OptionLookup {
150    by_option: HashMap<OptionName, &'static OptionMetadata>,
151    by_name: HashMap<&'static str, &'static OptionMetadata>,
152    by_alias: HashMap<&'static str, &'static OptionMetadata>,
153}
154
155impl OptionMetadata {
156    pub(crate) fn option(&self) -> OptionName {
157        self.option
158    }
159
160    pub(crate) const fn name(&self) -> &'static str {
161        self.name
162    }
163
164    pub(crate) const fn aliases(&self) -> &'static [&'static str] {
165        self.aliases
166    }
167
168    pub(crate) const fn scope_mask(&self) -> u8 {
169        self.scope_mask
170    }
171
172    pub(crate) const fn global_root(&self) -> GlobalRoot {
173        self.global_root
174    }
175
176    pub(crate) const fn value_type(&self) -> OptionValueType {
177        self.value_type
178    }
179
180    pub(crate) const fn default_value(&self) -> DefaultValue {
181        self.default
182    }
183
184    pub(crate) const fn separator(&self) -> &'static str {
185        self.separator
186    }
187
188    pub(crate) const fn is_array(&self) -> bool {
189        self.is_array
190    }
191
192    pub(crate) const fn effects(&self) -> OptionChangeMask {
193        self.effects
194    }
195
196    pub(crate) const fn visible_in(&self, scope: u8) -> bool {
197        (self.show_mask & scope) != 0
198    }
199
200    pub(crate) const fn supports_scope(&self, scope: &OptionScopeSelector) -> bool {
201        let mask = match scope {
202            OptionScopeSelector::ServerGlobal => SCOPE_SERVER,
203            OptionScopeSelector::SessionGlobal | OptionScopeSelector::Session(_) => SCOPE_SESSION,
204            OptionScopeSelector::WindowGlobal | OptionScopeSelector::Window(_) => SCOPE_WINDOW,
205            OptionScopeSelector::Pane(_) => SCOPE_PANE,
206        };
207        (self.scope_mask & mask) != 0
208    }
209}
210
211impl OptionQuery {
212    pub(crate) fn known(option: OptionName) -> Self {
213        let metadata = option_metadata(option);
214        Self {
215            name: metadata.name().to_owned(),
216            metadata: Some(metadata),
217            index: None,
218        }
219    }
220
221    pub(crate) fn metadata(&self) -> Option<&'static OptionMetadata> {
222        self.metadata
223    }
224
225    pub(crate) fn canonical_name(&self) -> &str {
226        &self.name
227    }
228
229    pub(crate) fn index(&self) -> Option<u32> {
230        self.index
231    }
232
233    pub(crate) fn known_option(&self) -> Option<OptionName> {
234        self.metadata.map(OptionMetadata::option)
235    }
236
237    pub fn is_user(&self) -> bool {
238        self.metadata.is_none()
239    }
240
241    #[must_use]
242    pub fn supports_scope(&self, scope: &OptionScopeSelector) -> bool {
243        self.metadata
244            .map(|metadata| metadata.supports_scope(scope))
245            .unwrap_or(true)
246    }
247
248    pub(crate) fn is_array(&self) -> bool {
249        self.metadata.is_some_and(OptionMetadata::is_array)
250    }
251
252    pub(crate) fn value_type(&self) -> OptionValueType {
253        self.metadata
254            .map(OptionMetadata::value_type)
255            .unwrap_or(OptionValueType::String)
256    }
257
258    pub(crate) fn separator(&self) -> &'static str {
259        self.metadata
260            .map(OptionMetadata::separator)
261            .unwrap_or_default()
262    }
263
264    pub(crate) fn default_value(&self) -> Option<DefaultValue> {
265        self.metadata.map(OptionMetadata::default_value)
266    }
267
268    pub(crate) fn effects(&self) -> OptionChangeMask {
269        self.metadata
270            .map(OptionMetadata::effects)
271            .unwrap_or(EFFECT_PANE_STYLE)
272    }
273}
274
275/// Returns the known option registry.
276pub(crate) fn registry() -> &'static [OptionMetadata] {
277    OPTIONS
278}
279
280pub(crate) fn option_metadata(option: OptionName) -> &'static OptionMetadata {
281    option_lookup()
282        .by_option
283        .get(&option)
284        .copied()
285        .expect("every OptionName variant must have registry metadata")
286}
287
288/// Resolves a known option name using tmux-style aliasing and prefix matching.
289pub fn resolve_option_name(name: &str) -> Result<OptionQuery, RmuxError> {
290    resolve_option_name_typed(name).map_err(OptionLookupError::into_rmux_error)
291}
292
293/// Resolves an option name and preserves typed lookup failures.
294pub fn resolve_option_name_typed(name: &str) -> Result<OptionQuery, OptionLookupError> {
295    let (base_name, index) = split_array_index_typed(name)?;
296    let lookup = option_lookup();
297
298    if base_name.starts_with('@') {
299        if index.is_some() {
300            return Err(OptionLookupError::UserOptionArrayIndex(name.to_owned()));
301        }
302        return Ok(OptionQuery {
303            name: base_name.to_owned(),
304            metadata: None,
305            index: None,
306        });
307    }
308
309    if let Some(metadata) = lookup.by_name.get(base_name).copied() {
310        return Ok(OptionQuery {
311            name: metadata.name().to_owned(),
312            metadata: Some(metadata),
313            index,
314        });
315    }
316
317    if let Some(metadata) = lookup.by_alias.get(base_name).copied() {
318        return Ok(OptionQuery {
319            name: metadata.name().to_owned(),
320            metadata: Some(metadata),
321            index,
322        });
323    }
324
325    // Prefix matching: check canonical names and aliases for unambiguous prefix.
326    let mut matches: Vec<&OptionMetadata> = OPTIONS
327        .iter()
328        .filter(|metadata| {
329            metadata.name().starts_with(base_name)
330                || metadata
331                    .aliases()
332                    .iter()
333                    .any(|alias| alias.starts_with(base_name))
334        })
335        .collect();
336    matches.dedup_by_key(|metadata| metadata.name());
337    match matches.as_slice() {
338        [metadata] => Ok(OptionQuery {
339            name: metadata.name().to_owned(),
340            metadata: Some(metadata),
341            index,
342        }),
343        [] => Err(OptionLookupError::InvalidName(base_name.to_owned())),
344        _ => Err(OptionLookupError::AmbiguousPrefix(base_name.to_owned())),
345    }
346}
347
348/// Resolves a known option name using only exact canonical names and aliases.
349pub(crate) fn resolve_exact_option_name(name: &str) -> Result<OptionQuery, RmuxError> {
350    let (base_name, index) = split_array_index(name)?;
351    let lookup = option_lookup();
352
353    if base_name.starts_with('@') {
354        if index.is_some() {
355            return Err(RmuxError::Server(format!(
356                "user option does not support array indexes: {name}"
357            )));
358        }
359        return Ok(OptionQuery {
360            name: base_name.to_owned(),
361            metadata: None,
362            index: None,
363        });
364    }
365
366    if let Some(metadata) = lookup.by_name.get(base_name).copied() {
367        return Ok(OptionQuery {
368            name: metadata.name().to_owned(),
369            metadata: Some(metadata),
370            index,
371        });
372    }
373
374    if let Some(metadata) = lookup.by_alias.get(base_name).copied() {
375        return Ok(OptionQuery {
376            name: metadata.name().to_owned(),
377            metadata: Some(metadata),
378            index,
379        });
380    }
381
382    Err(RmuxError::Server(format!("invalid option: {base_name}")))
383}
384
385/// Resolves a known option by name when the caller only accepts registered options.
386#[must_use]
387pub fn option_name_by_name(name: &str) -> Option<OptionName> {
388    resolve_option_name(name).ok()?.known_option()
389}
390
391/// Returns whether a known option change may affect attached rendering.
392#[must_use]
393pub fn option_affects_rendering(option: OptionName) -> bool {
394    let effects = option_metadata(option).effects();
395    effects.contains(EFFECT_RENDER)
396        || effects.contains(EFFECT_PANE_STYLE)
397        || effects.contains(EFFECT_STYLE_PARSE)
398        || effects.contains(EFFECT_CODEPOINT_WIDTHS)
399        || effects.contains(EFFECT_CURSOR)
400        || effects.contains(EFFECT_FILL_CHARACTER)
401        || effects.contains(EFFECT_STATUS_TIMER)
402        || effects.contains(EFFECT_LAYOUT)
403        || effects.contains(EFFECT_SCROLLBAR_STYLE)
404        || effects.contains(EFFECT_PANE_COLOURS)
405}
406
407/// Returns whether a known option change may affect alert runtime state.
408#[must_use]
409pub fn option_affects_alerts(option: OptionName) -> bool {
410    option_metadata(option).effects().affects_alerts()
411}
412
413fn option_lookup() -> &'static OptionLookup {
414    OPTION_LOOKUP.get_or_init(|| {
415        let mut by_option = HashMap::with_capacity(OPTIONS.len());
416        let mut by_name = HashMap::with_capacity(OPTIONS.len());
417        let alias_count = OPTIONS
418            .iter()
419            .map(|metadata| metadata.aliases().len())
420            .sum();
421        let mut by_alias = HashMap::with_capacity(alias_count);
422
423        for metadata in OPTIONS {
424            by_option.insert(metadata.option(), metadata);
425            by_name.insert(metadata.name(), metadata);
426            for alias in metadata.aliases() {
427                by_alias.insert(*alias, metadata);
428            }
429        }
430
431        OptionLookup {
432            by_option,
433            by_name,
434            by_alias,
435        }
436    })
437}
438
439fn split_array_index(name: &str) -> Result<(&str, Option<u32>), RmuxError> {
440    split_array_index_typed(name).map_err(OptionLookupError::into_rmux_error)
441}
442
443fn split_array_index_typed(name: &str) -> Result<(&str, Option<u32>), OptionLookupError> {
444    let Some(start) = name.rfind('[') else {
445        if name.contains(']') {
446            return Err(OptionLookupError::InvalidIndexSyntax(name.to_owned()));
447        }
448        return Ok((name, None));
449    };
450
451    if !name.ends_with(']') {
452        return Err(OptionLookupError::InvalidIndexSyntax(name.to_owned()));
453    }
454
455    let base_name = &name[..start];
456    let index_text = &name[start + 1..name.len() - 1];
457    if base_name.is_empty() || index_text.is_empty() {
458        return Err(OptionLookupError::InvalidIndexSyntax(name.to_owned()));
459    }
460
461    let index = index_text
462        .parse::<u32>()
463        .map_err(|_| OptionLookupError::InvalidIndexSyntax(name.to_owned()))?;
464    Ok((base_name, Some(index)))
465}