Skip to main content

base/
ini.rs

1// ini -- settings files: UTF-8 "[section]" / "key=value" text              [src/core/ini.s]
2//
3//   Ini::new() / Ini::load(path) / Ini::parse(bytes)    a missing or unreadable file is empty
4//   ini.save(path) -> bool       plat::write_file, creates missing directories
5//   ini.to_bytes()               the text save writes
6//   ini.get(sec, key) -> Option<&str>   (get_bytes for values that are not UTF-8)
7//   ini.get_int(sec, key, def)   decimal (+/-), 0x hex, true/false/yes/no/on/off; else def
8//   ini.get_f64(sec, key, def)   '.' decimal point only; else def
9//   ini.set(sec, key, value) / set_int / set_f64 -> bool   add or replace (false: bad name)
10//   ini.remove(sec, key) -> bool
11//   ini.section(sec) -> Vec<(&str, &str)>   the keys of a section and their values, in order
12//   ini.sections() -> Vec<&str>   the section names in file order (each once; not "")
13//   ini.items() -> Vec<Item>      headers, keys and malformed lines with their line numbers
14//                                 (files that are checked: a theme names a mistake's line)
15//
16// Format: one entry per line; sec "" = the keys before the first [section].  Section and
17// key names compare ASCII case-insensitively; values are stored as written (blanks around
18// names and values trimmed; no quoting, no inline comments, so values may contain ';' and
19// '#').  Lines starting with ';' or '#', blank lines and malformed lines (no '=', "[" without
20// "]", NUL bytes, longer than LINE_MAX) are kept verbatim and written back unchanged, as are
21// all lines not touched by set (the original spacing survives).  New keys go after the
22// last key of their section, new sections to the end.  A UTF-8 BOM is skipped.  Files are
23// read up to FILE_MAX bytes; after LINES_MAX lines the rest is kept as one verbatim block.
24// Line ends: LF, CRLF (any number of CRs before the LF are dropped) when reading; written as
25// CRLF on Windows, LF on Linux.  Key names must not contain '=', NUL, CR or LF nor start with
26// ';' '#' '['; section names must not contain ']', NUL, CR or LF; neither may start or end with
27// a blank (set -> false).  CR / LF inside a value are written as blanks.
28
29use alloc::format;
30use alloc::vec::Vec;
31
32use crate::num::{f64_format, f64_parse, parse_i64};
33
34const FILE_MAX: u64 = 0x100_0000; // 16 MB read at most
35const LINE_MAX: usize = 0x10000; // longer lines are kept verbatim, not interpreted
36const LINES_MAX: usize = 0x40000;
37#[cfg(windows)]
38const EOL: &[u8] = b"\r\n";
39#[cfg(not(windows))]
40const EOL: &[u8] = b"\n";
41
42#[derive(Clone, Copy, PartialEq, Eq)]
43enum Kind {
44    Raw, // blank, comment, malformed
45    Sec,
46    Kv,
47}
48
49struct Line {
50    kind: Kind,
51    dirty: bool, // written as key=value / [name] instead of the raw text
52    sec: i32,    // line index of the section header, -1 = before any header
53    raw: Vec<u8>, // the line without its end
54    name: Vec<u8>, // section name / key
55    value: Vec<u8>,
56}
57
58impl Line {
59    fn new(kind: Kind, sec: i32) -> Line {
60        Line { kind, dirty: false, sec, raw: Vec::new(), name: Vec::new(), value: Vec::new() }
61    }
62}
63
64/// a settings object (one thread at a time)
65#[derive(Default)]
66pub struct Ini {
67    lines: Vec<Line>,
68}
69
70/// a line of the file as Ini::items lists it (line numbers from 1)
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum Item<'a> {
73    /// a "[name]" header
74    Section { line: usize, name: &'a str },
75    /// "key = value" of section sec ("" before the first header)
76    Key { line: usize, sec: &'a str, key: &'a str, value: &'a str },
77    /// neither blank, a comment, a header nor a key (or not UTF-8)
78    Bad { line: usize, text: &'a [u8] },
79}
80
81/// n bytes equal ignoring ASCII case (only letters may differ in case)  [ieq]
82fn ieq(a: &[u8], b: &[u8]) -> bool {
83    a.len() == b.len()
84        && a.iter().zip(b).all(|(&x, &y)| x == y || (x | 32 == y | 32 && (x | 32).wrapping_sub(b'a') <= 25))
85}
86
87fn blank(c: u8) -> bool {
88    c == b' ' || c == b'\t'
89}
90
91/// usable as a key (is_key) or section name  [name_ok]
92fn name_ok(s: &[u8], is_key: bool) -> bool {
93    let (Some(&first), Some(&last)) = (s.first(), s.last()) else { return false };
94    if blank(first) || blank(last) || (is_key && matches!(first, b';' | b'#' | b'[')) {
95        return false;
96    }
97    let forbid = if is_key { b'=' } else { b']' };
98    !s.iter().any(|&c| c == b'\r' || c == b'\n' || c == 0 || c == forbid)
99}
100
101fn trim(mut s: &[u8]) -> &[u8] {
102    while let [c, rest @ ..] = s {
103        if !blank(*c) {
104            break;
105        }
106        s = rest;
107    }
108    while let [rest @ .., c] = s {
109        if !blank(*c) {
110            break;
111        }
112        s = rest;
113    }
114    s
115}
116
117impl Ini {
118    pub fn new() -> Ini {
119        Ini { lines: Vec::new() }
120    }
121
122    /// the parsed file; empty when it is missing or unreadable  [ini_load]
123    pub fn load(path: &str) -> Ini {
124        let Some(f) = plat::File::open(path, plat::FO_READ) else { return Ini::new() };
125        let size = f.size().unwrap_or(0).min(FILE_MAX) as usize;
126        if size == 0 {
127            return Ini::new();
128        }
129        let mut data = alloc::vec![0u8; size];
130        let got = f.read_at(0, &mut data).unwrap_or(0);
131        drop(f);
132        Ini::parse(&data[..got])
133    }
134
135    /// like load, but None when the file exists and could not be read completely (unreadable
136    /// or longer than FILE_MAX); a missing or empty file gives an empty Ini
137    pub fn load_checked(path: &str) -> Option<Ini> {
138        let Some(f) = plat::File::open(path, plat::FO_READ) else {
139            return if plat::file_exists(path) == 1 { None } else { Some(Ini::new()) };
140        };
141        let full = f.size().unwrap_or(0);
142        if full > FILE_MAX {
143            return None;
144        }
145        let size = full as usize;
146        if size == 0 {
147            return Some(Ini::new());
148        }
149        let mut data = alloc::vec![0u8; size];
150        let got = f.read_at(0, &mut data)?;
151        drop(f);
152        if got != size {
153            return None;
154        }
155        Some(Ini::parse(&data))
156    }
157
158    /// parse settings text  [ini_parse]
159    pub fn parse(data: &[u8]) -> Ini {
160        let mut ini = Ini::new();
161        let mut pos = if data.starts_with(&[0xEF, 0xBB, 0xBF]) { 3 } else { 0 };
162        let end = data.len();
163        let mut cur: i32 = -1;
164        while pos < end {
165            if ini.lines.len() >= LINES_MAX - 1 {
166                // too many lines: the rest as one verbatim block (without its last line end)
167                let mut e = end;
168                if data[e - 1] == b'\n' {
169                    e -= 1;
170                }
171                while e > pos && data[e - 1] == b'\r' {
172                    e -= 1;
173                }
174                ini.classify(&data[pos..e], true, &mut cur);
175                break;
176            }
177            let lf = data[pos..].iter().position(|&c| c == b'\n').map_or(end, |k| pos + k);
178            let nul = data[pos..lf].contains(&0);
179            let mut e = lf;
180            while e > pos && data[e - 1] == b'\r' {
181                e -= 1; // every CR before the LF: a kept one would read back differently
182            }
183            ini.classify(&data[pos..e], nul, &mut cur);
184            pos = lf + 1;
185        }
186        ini
187    }
188
189    /// append one line  [classify]
190    fn classify(&mut self, raw: &[u8], verbatim: bool, cur: &mut i32) {
191        let mut ln = Line::new(Kind::Raw, *cur);
192        ln.raw = raw.to_vec();
193        let t = trim(raw);
194        if !verbatim && raw.len() <= LINE_MAX && !t.is_empty() && t[0] != b';' && t[0] != b'#' {
195            if t[0] == b'[' {
196                if let Some(close) = t.iter().position(|&c| c == b']') {
197                    let after = trim(&t[close + 1..]);
198                    let name = trim(&t[1..close]);
199                    if (after.is_empty() || after[0] == b';' || after[0] == b'#') && !name.is_empty() {
200                        ln.kind = Kind::Sec;
201                        ln.name = name.to_vec();
202                        ln.sec = self.lines.len() as i32;
203                        *cur = ln.sec; // following keys belong here
204                    }
205                }
206            } else if let Some(eq) = t.iter().position(|&c| c == b'=') {
207                let key = trim(&t[..eq]);
208                if !key.is_empty() {
209                    ln.kind = Kind::Kv;
210                    ln.name = key.to_vec();
211                    ln.value = trim(&t[eq + 1..]).to_vec();
212                }
213            }
214        }
215        self.lines.push(ln);
216    }
217
218    /// the text save writes
219    pub fn to_bytes(&self) -> Vec<u8> {
220        let mut out = Vec::new();
221        for l in &self.lines {
222            if !l.dirty {
223                out.extend_from_slice(&l.raw);
224            } else if l.kind == Kind::Sec {
225                out.push(b'[');
226                out.extend_from_slice(&l.name);
227                out.push(b']');
228            } else {
229                out.extend_from_slice(&l.name);
230                out.push(b'=');
231                out.extend_from_slice(&l.value);
232            }
233            out.extend_from_slice(EOL);
234        }
235        out
236    }
237
238    /// Write through plat::write_file; missing directories are created. [ini_save]
239    pub fn save(&self, path: &str) -> bool {
240        let text = self.to_bytes();
241        mkparent(path);
242        plat::write_file(path, &text)
243    }
244
245    /// the line of the key or None  [find_kv]
246    fn find_kv(&self, sec: &str, key: &str) -> Option<usize> {
247        let (sec, key) = (sec.as_bytes(), key.as_bytes());
248        self.lines.iter().position(|l| {
249            l.kind == Kind::Kv
250                && ieq(&l.name, key)
251                && if l.sec < 0 { sec.is_empty() } else { !sec.is_empty() && ieq(&self.lines[l.sec as usize].name, sec) }
252        })
253    }
254
255    /// the first header named sec  [find_sec]
256    fn find_sec(&self, sec: &str) -> Option<usize> {
257        self.lines.iter().position(|l| l.kind == Kind::Sec && ieq(&l.name, sec.as_bytes()))
258    }
259
260    /// where a new key of section line s (-1 = global) goes: after its last key, else right
261    /// after the header (global: before the first header)  [sec_end]
262    fn sec_end(&self, s: i32) -> usize {
263        let mut after = None;
264        let mut first_hdr = None;
265        for (i, l) in self.lines.iter().enumerate() {
266            if l.kind == Kind::Sec {
267                first_hdr.get_or_insert(i);
268            } else if l.kind == Kind::Kv && l.sec == s {
269                after = Some(i + 1);
270            }
271        }
272        match after {
273            Some(a) => a,
274            None if s >= 0 => s as usize + 1,
275            None => first_hdr.unwrap_or(self.lines.len()),
276        }
277    }
278
279    /// insert a line; section references at or behind pos move with their header
280    fn line_insert(&mut self, pos: usize, ln: Line) {
281        for l in self.lines.iter_mut() {
282            if l.sec >= pos as i32 {
283                l.sec += 1;
284            }
285        }
286        self.lines.insert(pos, ln);
287    }
288
289    /// remove a line (references to it become -1, those behind it move down)
290    fn line_delete(&mut self, pos: usize) {
291        self.lines.remove(pos);
292        for l in self.lines.iter_mut() {
293            if l.sec == pos as i32 {
294                l.sec = -1;
295            } else if l.sec > pos as i32 {
296                l.sec -= 1;
297            }
298        }
299    }
300
301    /// every key of a section with its value, in file order (entries that are not UTF-8 are
302    /// left out; sec "" = the keys before the first header)
303    pub fn section(&self, sec: &str) -> Vec<(&str, &str)> {
304        let s = sec.as_bytes();
305        self.lines
306            .iter()
307            .filter(|l| l.kind == Kind::Kv && if l.sec < 0 { s.is_empty() } else { !s.is_empty() && ieq(&self.lines[l.sec as usize].name, s) })
308            .filter_map(|l| Some((core::str::from_utf8(&l.name).ok()?, core::str::from_utf8(&l.value).ok()?)))
309            .collect()
310    }
311
312    /// the names of the sections in file order, each once (names that are not UTF-8 are left
313    /// out; the keys before the first header are not a section here)
314    pub fn sections(&self) -> Vec<&str> {
315        let mut out: Vec<&str> = Vec::new();
316        for l in self.lines.iter().filter(|l| l.kind == Kind::Sec) {
317            if let Ok(n) = core::str::from_utf8(&l.name) {
318                if !out.iter().any(|o| ieq(o.as_bytes(), n.as_bytes())) {
319                    out.push(n);
320                }
321            }
322        }
323        out
324    }
325
326    /// every header, key and malformed line in file order with its line number; blank lines
327    /// and comments are left out
328    pub fn items(&self) -> Vec<Item<'_>> {
329        let mut out = Vec::new();
330        for (i, l) in self.lines.iter().enumerate() {
331            let line = i + 1;
332            match l.kind {
333                Kind::Sec => match core::str::from_utf8(&l.name) {
334                    Ok(name) => out.push(Item::Section { line, name }),
335                    Err(_) => out.push(Item::Bad { line, text: &l.raw }),
336                },
337                Kind::Kv => {
338                    let sec = if l.sec < 0 { Ok("") } else { core::str::from_utf8(&self.lines[l.sec as usize].name) };
339                    match (sec, core::str::from_utf8(&l.name), core::str::from_utf8(&l.value)) {
340                        (Ok(sec), Ok(key), Ok(value)) => out.push(Item::Key { line, sec, key, value }),
341                        _ => out.push(Item::Bad { line, text: &l.raw }),
342                    }
343                }
344                Kind::Raw => {
345                    let t = trim(&l.raw);
346                    if !t.is_empty() && t[0] != b';' && t[0] != b'#' {
347                        out.push(Item::Bad { line, text: &l.raw });
348                    }
349                }
350            }
351        }
352        out
353    }
354
355    /// the value, or None when missing or not UTF-8  [ini_get]
356    pub fn get(&self, sec: &str, key: &str) -> Option<&str> {
357        core::str::from_utf8(self.get_bytes(sec, key)?).ok()
358    }
359
360    pub fn get_bytes(&self, sec: &str, key: &str) -> Option<&[u8]> {
361        self.find_kv(sec, key).map(|i| self.lines[i].value.as_slice())
362    }
363
364    /// [ini_get_int]
365    pub fn get_int(&self, sec: &str, key: &str, def: i64) -> i64 {
366        self.get_bytes(sec, key).and_then(parse_i64).unwrap_or(def)
367    }
368
369    /// the whole value must be the number  [ini_get_f64]
370    pub fn get_f64(&self, sec: &str, key: &str, def: f64) -> f64 {
371        match self.get_bytes(sec, key) {
372            Some(v) => match f64_parse(v) {
373                (x, used) if used > 0 && used == v.len() => x,
374                _ => def,
375            },
376            None => def,
377        }
378    }
379
380    /// add or replace (false: a bad name)  [ini_set]
381    pub fn set(&mut self, sec: &str, key: &str, value: &str) -> bool {
382        if !name_ok(key.as_bytes(), true) || (!sec.is_empty() && !name_ok(sec.as_bytes(), false)) {
383            return false;
384        }
385        let v: Vec<u8> = value.bytes().map(|c| if c == b'\r' || c == b'\n' || c == 0 { b' ' } else { c }).collect();
386        if let Some(i) = self.find_kv(sec, key) {
387            let l = &mut self.lines[i];
388            l.value = v;
389            l.dirty = true;
390            return true;
391        }
392        let mut s: i32 = -1; // section line of the new key
393        if !sec.is_empty() {
394            s = match self.find_sec(sec) {
395                Some(i) => i as i32,
396                None => {
397                    // a new section at the end, after a blank line
398                    let n = self.lines.len();
399                    if n > 0 && !(self.lines[n - 1].kind == Kind::Raw && self.lines[n - 1].raw.is_empty()) {
400                        self.line_insert(n, Line::new(Kind::Raw, -1));
401                    }
402                    let n = self.lines.len();
403                    let mut h = Line::new(Kind::Sec, n as i32);
404                    h.dirty = true;
405                    h.name = sec.as_bytes().to_vec();
406                    self.line_insert(n, h);
407                    n as i32
408                }
409            };
410        }
411        let pos = self.sec_end(s);
412        let mut k = Line::new(Kind::Kv, s);
413        k.dirty = true;
414        k.name = key.as_bytes().to_vec();
415        k.value = v;
416        self.line_insert(pos, k);
417        true
418    }
419
420    /// [ini_set_int]
421    pub fn set_int(&mut self, sec: &str, key: &str, value: i64) -> bool {
422        self.set(sec, key, &format!("{value}"))
423    }
424
425    /// the shortest text that reads back to the same double  [ini_set_f64]
426    pub fn set_f64(&mut self, sec: &str, key: &str, value: f64) -> bool {
427        self.set(sec, key, &f64_format(value))
428    }
429
430    /// false: not there  [ini_remove]
431    pub fn remove(&mut self, sec: &str, key: &str) -> bool {
432        match self.find_kv(sec, key) {
433            Some(i) => {
434                self.line_delete(i);
435                true
436            }
437            None => false,
438        }
439    }
440}
441
442/// create the directory part of path (if any)  [mkparent]
443fn mkparent(path: &str) {
444    let b = path.as_bytes();
445    let Some(sep) = b.iter().rposition(|&c| c == b'/' || c == b'\\') else { return };
446    if sep == 0 || (sep == 2 && b[1] == b':') {
447        return; // "/name", "C:/name"
448    }
449    plat::mkdir(&path[..sep]);
450}