ad_editor/
config.rs

1//! A minimal config file format for ad
2use crate::{key::Input, mode::normal_mode, term::Color, trie::Trie};
3use std::{env, fs, io};
4
5/// Editor level configuration
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Config {
8    pub(crate) tabstop: usize,
9    pub(crate) expand_tab: bool,
10    pub(crate) auto_mount: bool,
11    pub(crate) match_indent: bool,
12    pub(crate) status_timeout: u64,
13    pub(crate) double_click_ms: u128,
14    pub(crate) minibuffer_lines: usize,
15    pub(crate) find_command: String,
16    pub(crate) colorscheme: ColorScheme,
17    pub(crate) bindings: Trie<Input, String>,
18}
19
20impl Default for Config {
21    fn default() -> Self {
22        Self {
23            tabstop: 4,
24            expand_tab: true,
25            auto_mount: false,
26            match_indent: true,
27            status_timeout: 3,
28            double_click_ms: 200,
29            minibuffer_lines: 8,
30            find_command: "fd -t f".to_string(),
31            colorscheme: ColorScheme::default(),
32            bindings: Trie::from_pairs(Vec::new()),
33        }
34    }
35}
36
37/// A colorscheme for the terminal UI
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct ColorScheme {
40    // ui
41    pub(crate) bg: Color,
42    pub(crate) fg: Color,
43    pub(crate) dot_bg: Color,
44    pub(crate) load_bg: Color,
45    pub(crate) exec_bg: Color,
46    pub(crate) bar_bg: Color,
47    pub(crate) signcol_fg: Color,
48    pub(crate) minibuffer_hl: Color,
49    // syntax
50    pub(crate) comment: Color,
51    pub(crate) keyword: Color,
52    pub(crate) control_flow: Color,
53    pub(crate) definition: Color,
54    pub(crate) punctuation: Color,
55    pub(crate) string: Color,
56}
57
58impl Default for ColorScheme {
59    fn default() -> Self {
60        Self {
61            // ui
62            bg: "#1B1720".try_into().unwrap(),
63            fg: "#E6D29E".try_into().unwrap(),
64            dot_bg: "#336677".try_into().unwrap(),
65            load_bg: "#957FB8".try_into().unwrap(),
66            exec_bg: "#Bf616A".try_into().unwrap(),
67            bar_bg: "#4E415C".try_into().unwrap(),
68            signcol_fg: "#544863".try_into().unwrap(),
69            minibuffer_hl: "#3E3549".try_into().unwrap(),
70            // syntax
71            comment: "#624354".try_into().unwrap(),
72            keyword: "#Bf616A".try_into().unwrap(),
73            control_flow: "#7E9CD8".try_into().unwrap(),
74            definition: "#957FB8".try_into().unwrap(),
75            punctuation: "#DCA561".try_into().unwrap(),
76            string: "#61DCA5".try_into().unwrap(),
77        }
78    }
79}
80
81impl Config {
82    /// Attempt to load a config file from the default location
83    pub fn try_load() -> Result<Self, String> {
84        let home = env::var("HOME").unwrap();
85
86        let s = match fs::read_to_string(format!("{home}/.ad/init.conf")) {
87            Ok(s) => s,
88            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Config::default()),
89            Err(e) => return Err(format!("Unable to load config file: {e}")),
90        };
91
92        match Config::parse(&s) {
93            Ok(cfg) => Ok(cfg),
94            Err(e) => Err(format!("Invalid config file: {e}")),
95        }
96    }
97
98    /// Attempt to parse the given file content as a Config file. If the file is invalid then an
99    /// error message for the user is returned for displaying in the status bar.
100    pub fn parse(contents: &str) -> Result<Self, String> {
101        let mut cfg = Config::default();
102        cfg.update_from(contents)?;
103
104        Ok(cfg)
105    }
106
107    pub(crate) fn update_from(&mut self, input: &str) -> Result<(), String> {
108        let mut raw_bindings = Vec::new();
109
110        for line in input.lines() {
111            let line = line.trim_end();
112            if line.starts_with('#') || line.is_empty() {
113                continue;
114            }
115
116            match line.strip_prefix("set ") {
117                Some(line) => self.try_set_prop(line)?,
118                None => match line.strip_prefix("map ") {
119                    Some(line) => raw_bindings.push(try_parse_binding(line)?),
120                    None => {
121                        return Err(format!(
122                            "'{line}' should be 'set prop=val' or 'map ... => prog'"
123                        ))
124                    }
125                },
126            }
127        }
128
129        if !raw_bindings.is_empty() {
130            // Make sure that none of the user provided bindings clash with Normal mode
131            // bindings as that will mean they never get run
132            let nm = normal_mode();
133            for (keys, _) in raw_bindings.iter() {
134                if nm.keymap.contains_key_or_prefix(keys) {
135                    let mut s = String::new();
136                    for k in keys {
137                        if let Input::Char(c) = k {
138                            s.push(*c);
139                        }
140                    }
141
142                    return Err(format!("mapping '{s}' collides with a Normal mode mapping"));
143                }
144            }
145        }
146
147        self.bindings = Trie::from_pairs(raw_bindings);
148
149        Ok(())
150    }
151
152    pub(crate) fn try_set_prop(&mut self, input: &str) -> Result<(), String> {
153        let (prop, val) = input
154            .split_once('=')
155            .ok_or_else(|| format!("'{input}' is not a 'set prop=val' statement"))?;
156
157        match prop {
158            // Strings
159            "find-command" => self.find_command = val.trim().to_string(),
160
161            // Numbers
162            "tabstop" => self.tabstop = parse_usize(prop, val)?,
163            "minibuffer-lines" => self.minibuffer_lines = parse_usize(prop, val)?,
164            "status-timeout" => self.status_timeout = parse_usize(prop, val)? as u64,
165            "double-click-ms" => self.double_click_ms = parse_usize(prop, val)? as u128,
166
167            // Flags
168            "expand-tab" => self.expand_tab = parse_bool(prop, val)?,
169            "auto-mount" => self.auto_mount = parse_bool(prop, val)?,
170            "match-indent" => self.match_indent = parse_bool(prop, val)?,
171
172            // Colors
173            "bg-color" => self.colorscheme.bg = parse_color(prop, val)?,
174            "fg-color" => self.colorscheme.fg = parse_color(prop, val)?,
175            "dot-bg-color" => self.colorscheme.dot_bg = parse_color(prop, val)?,
176            "load-bg-color" => self.colorscheme.load_bg = parse_color(prop, val)?,
177            "exec-bg-color" => self.colorscheme.exec_bg = parse_color(prop, val)?,
178            "bar-bg-color" => self.colorscheme.bar_bg = parse_color(prop, val)?,
179            "signcol-fg-color" => self.colorscheme.signcol_fg = parse_color(prop, val)?,
180            "minibuffer-hl-color" => self.colorscheme.minibuffer_hl = parse_color(prop, val)?,
181            "comment-color" => self.colorscheme.comment = parse_color(prop, val)?,
182            "keyword-color" => self.colorscheme.keyword = parse_color(prop, val)?,
183            "control-flow-color" => self.colorscheme.control_flow = parse_color(prop, val)?,
184            "definition-color" => self.colorscheme.definition = parse_color(prop, val)?,
185            "punctuation-color" => self.colorscheme.punctuation = parse_color(prop, val)?,
186            "string-color" => self.colorscheme.string = parse_color(prop, val)?,
187
188            _ => return Err(format!("'{prop}' is not a known config property")),
189        }
190
191        Ok(())
192    }
193}
194
195fn parse_usize(prop: &str, val: &str) -> Result<usize, String> {
196    match val.parse() {
197        Ok(num) => Ok(num),
198        Err(_) => Err(format!("expected number for '{prop}' but found '{val}'")),
199    }
200}
201
202fn parse_bool(prop: &str, val: &str) -> Result<bool, String> {
203    match val {
204        "true" => Ok(true),
205        "false" => Ok(false),
206        _ => Err(format!(
207            "expected true/false for '{prop}' but found '{val}'"
208        )),
209    }
210}
211
212fn parse_color(prop: &str, val: &str) -> Result<Color, String> {
213    Color::try_from(val)
214        .map_err(|_| format!("expected #RRGGBB string for '{prop}' but found '{val}'"))
215}
216
217fn try_parse_binding(input: &str) -> Result<(Vec<Input>, String), String> {
218    let (keys, prog) = input
219        .split_once("=>")
220        .ok_or_else(|| format!("'{input}' is not a 'map ... => prog' statement"))?;
221
222    let keys: Vec<Input> = keys
223        .split_whitespace()
224        .filter_map(|s| {
225            if s.len() == 1 {
226                let c = s.chars().next().unwrap();
227                if c.is_whitespace() {
228                    None
229                } else {
230                    Some(Input::Char(c))
231                }
232            } else {
233                match s {
234                    "<space>" => Some(Input::Char(' ')),
235                    _ => None,
236                }
237            }
238        })
239        .collect();
240
241    Ok((keys, prog.trim().to_string()))
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    const EXAMPLE_CONFIG: &str = include_str!("../data/init.conf");
249    const CUSTOM_CONFIG: &str = "
250# This is a comment
251
252
253# Blank lines should be skipped
254set tabstop=7
255
256set expand-tab=false
257set match-indent=false
258
259map G G => my-prog
260";
261
262    // This should be our default so we are just verifying that we have not diverged from
263    // what is in the repo.
264    #[test]
265    fn parse_of_example_config_works() {
266        let cfg = Config::parse(EXAMPLE_CONFIG).unwrap();
267        let bindings = Trie::from_pairs(vec![
268            (vec![Input::Char(' '), Input::Char('F')], "fmt".to_string()),
269            (vec![Input::Char('>')], "indent".to_string()),
270            (vec![Input::Char('<')], "unindent".to_string()),
271        ]);
272
273        let expected = Config {
274            bindings,
275            ..Default::default()
276        };
277
278        assert_eq!(cfg, expected);
279    }
280
281    #[test]
282    fn custom_vals_work() {
283        let cfg = Config::parse(CUSTOM_CONFIG).unwrap();
284
285        let expected = Config {
286            tabstop: 7,
287            expand_tab: false,
288            match_indent: false,
289            bindings: Trie::from_pairs(vec![(
290                vec![Input::Char('G'), Input::Char('G')],
291                "my-prog".to_string(),
292            )]),
293            ..Default::default()
294        };
295
296        assert_eq!(cfg, expected);
297    }
298}