Skip to main content

hk_parser/
parser.rs

1use crate::error::HkError;
2use crate::value::{HkConfig, HkValue};
3use indexmap::IndexMap;
4use std::fs::File;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7use std::str::FromStr;
8
9/// Parses a .hk file from a string input.
10pub fn parse_hk(input: &str) -> Result<HkConfig, HkError> {
11    let lines: Vec<&str> = input.lines().collect();
12    let mut config = IndexMap::new();
13    let mut i = 0;
14
15    while i < lines.len() {
16        let line = lines[i].trim_start();
17        if line.is_empty() || line.starts_with('!') {
18            i += 1;
19            continue;
20        }
21
22        if line.starts_with('[') {
23            let close = line.find(']').ok_or_else(|| HkError::Parse {
24                line: (i + 1) as u32,
25                column: line.find('[').unwrap() + 1,
26                message: "Unclosed section header".to_string(),
27            })?;
28            let section_name = line[1..close].trim();
29            if section_name.is_empty() {
30                return Err(HkError::Parse {
31                    line: (i + 1) as u32,
32                    column: close + 1,
33                    message: "Empty section name".to_string(),
34                });
35            }
36
37            // Find the end of this section (next section or EOF).
38            //
39            // FIXED (v3.2.1): this used to be a plain `next_line.starts_with('[')`
40            // check, which misfired on a multi-line array item that's itself an
41            // array, e.g.:
42            //   -> groups => [
43            //       ["admins", "root"]   <- trim_start() starts with '[' too!
44            //       ["users", "guest"]
45            //   ]
46            // ...prematurely ending the section right after `-> groups => [`, so
47            // `parse_map` below only ever saw that one line and had no closing
48            // `]` left to find ("Unclosed array"). We now track array-bracket
49            // depth (outside quotes) across the scan and only treat a `[`-led
50            // line as a new section header while that depth is back to zero —
51            // i.e. we're not currently inside someone's still-open array value.
52            let mut end = i + 1;
53            let mut array_depth: i32 = 0;
54            while end < lines.len() {
55                let next_line = lines[end];
56                let next_trimmed = next_line.trim_start();
57                if array_depth == 0 && next_trimmed.starts_with('[') {
58                    break;
59                }
60                array_depth += net_bracket_depth(next_line);
61                end += 1;
62            }
63
64            let section_lines = &lines[i + 1..end];
65            // `section_lines[0]` is `lines[i + 1]` (0-indexed), whose true
66            // 1-indexed line number is `(i + 1) + 1 = i + 2`.
67            let map = parse_map(1, section_lines, i + 2)?;
68            config.insert(section_name.to_string(), HkValue::Map(map));
69            i = end;
70        } else {
71            return Err(HkError::Parse {
72                line: (i + 1) as u32,
73                column: 1,
74                message: "Expected section header".to_string(),
75            });
76        }
77    }
78
79    Ok(config)
80}
81
82/// Parse a map from a slice of lines, starting with a given indentation level (number of dashes).
83/// level: the number of dashes expected for the current depth (e.g., 1 for "->", 2 for "-->")
84/// Returns the map and the index of the next line to process.
85fn parse_map(level: usize, lines: &[&str], start_line: usize) -> Result<IndexMap<String, HkValue>, HkError> {
86    let mut map = IndexMap::new();
87    let mut i = 0;
88
89    while i < lines.len() {
90        let line = lines[i];
91        let trimmed = line.trim_start();
92        if trimmed.is_empty() || trimmed.starts_with('!') {
93            i += 1;
94            continue;
95        }
96
97        // Count leading dashes
98        let dash_count = trimmed.chars().take_while(|c| *c == '-').count();
99        if dash_count == 0 {
100            return Err(HkError::Parse {
101                line: (start_line + i) as u32,
102                column: 1,
103                message: "Expected key or map header".to_string(),
104            });
105        }
106        if dash_count < level {
107            // Shallower level – legitimately return control to the caller.
108            break;
109        }
110        if dash_count > level {
111            // The line is MORE indented than expected at this depth. This means
112            // one or more nesting levels were skipped (e.g. jumping from "-->"
113            // straight to "---->" without a "--->" level in between). Previously
114            // this was silently treated the same as "end of this map", which made
115            // the parser drop the entire mismatched sub-tree without any warning.
116            // That is a data-loss bug, so we now report it as a proper parse error.
117            return Err(HkError::Parse {
118                line: (start_line + i) as u32,
119                column: 1,
120                message: format!(
121                    "Inconsistent nesting level: expected {} dash(es) (\"{}\") at this depth, found {} (\"{}\"). Nesting must increase by exactly one dash per level.",
122                    level,
123                    "-".repeat(level),
124                    dash_count,
125                    "-".repeat(dash_count)
126                ),
127            });
128        }
129
130        // After dashes, skip any spaces, expect '>', then skip spaces
131        let after_dashes = &trimmed[dash_count..];
132        let rest = after_dashes.trim_start();
133        if !rest.starts_with('>') {
134            return Err(HkError::Parse {
135                line: (start_line + i) as u32,
136                column: dash_count + 1,
137                message: "Expected '>' after dashes".to_string(),
138            });
139        }
140        let after_gt = &rest[1..].trim_start();
141        if after_gt.is_empty() {
142            return Err(HkError::Parse {
143                line: (start_line + i) as u32,
144                column: dash_count + 1,
145                message: "Missing key after '>'".to_string(),
146            });
147        }
148
149        // Check if it's a key-value line (contains "=>")
150        if let Some(arrow_pos) = after_gt.find("=>") {
151            let key = after_gt[..arrow_pos].trim();
152            let value_part = after_gt[arrow_pos + 2..].trim();
153            let key = unquote_key(key);
154            if key.is_empty() {
155                return Err(HkError::Parse {
156                    line: (start_line + i) as u32,
157                    column: dash_count + 1,
158                    message: "Empty key".to_string(),
159                });
160            }
161            let value_col = arrow_pos + dash_count + 2;
162
163            // Multi-line arrays.
164            //
165            // `[1, 2, 3]` on a single line already worked. This adds the
166            // second common style:
167            //
168            //   -> tags => [
169            //       "desktop"
170            //       "environment"
171            //   ]
172            //
173            // one item per line (trailing commas optional), closed by a
174            // line containing the matching `]`. Detected by: the value
175            // starts with `[` but doesn't already balance back to zero
176            // brackets on this same line.
177            if value_part.starts_with('[') && net_bracket_depth(value_part) > 0 {
178                let mut buf = value_part.to_string();
179                let mut consumed = 1usize;
180                let mut j = i + 1;
181                while net_bracket_depth(&buf) > 0 {
182                    if j >= lines.len() {
183                        return Err(HkError::Parse {
184                            line: (start_line + i) as u32,
185                            column: value_col,
186                            message: "Unclosed array: reached end of section before a matching ']'".to_string(),
187                        });
188                    }
189                    buf.push('\n');
190                    buf.push_str(lines[j]);
191                    consumed += 1;
192                    j += 1;
193                }
194                // `buf` is now e.g. "[\n    \"desktop\"\n    \"environment\"\n]".
195                let first = buf.find('[').unwrap();
196                let last = buf.rfind(']').unwrap();
197                let inner = &buf[first + 1..last];
198                let items = parse_array_inner(inner, start_line + i, value_col)?;
199                insert_key(&mut map, &key, HkValue::Array(items))?;
200                i += consumed;
201            } else {
202                let value = parse_value(value_part, start_line + i, value_col)?;
203                insert_key(&mut map, &key, value)?;
204                i += 1;
205            }
206        } else {
207            // It's a map header: "- > key" without "=>"
208            let key = after_gt.trim();
209            let key = unquote_key(key);
210            if key.is_empty() {
211                return Err(HkError::Parse {
212                    line: (start_line + i) as u32,
213                    column: dash_count + 1,
214                    message: "Empty map key".to_string(),
215                });
216            }
217
218            // Find the sub-lines that belong to this map (higher level)
219            let next_level = level + 1;
220            let mut j = i + 1;
221            while j < lines.len() {
222                let sub_line = lines[j];
223                let sub_trimmed = sub_line.trim_start();
224                if sub_trimmed.is_empty() || sub_trimmed.starts_with('!') {
225                    j += 1;
226                    continue;
227                }
228                let sub_dash_count = sub_trimmed.chars().take_while(|c| *c == '-').count();
229                if sub_dash_count < next_level {
230                    break;
231                }
232                j += 1;
233            }
234
235            let sub_lines = &lines[i + 1..j];
236            let sub_map = parse_map(next_level, sub_lines, start_line + i + 1)?;
237            insert_key(&mut map, &key, HkValue::Map(sub_map))?;
238            i = j;
239        }
240    }
241
242    Ok(map)
243}
244
245/// Insert a key (which may contain dots for nesting) into the map.
246/// Keys that start or end with a dot are treated as literal keys (no nesting).
247fn insert_key(map: &mut IndexMap<String, HkValue>, key: &str, value: HkValue) -> Result<(), HkError> {
248    // If the key contains dots but not at the start or end, split and nest.
249    if key.contains('.') && !key.starts_with('.') && !key.ends_with('.') {
250        let parts: Vec<&str> = key.split('.').collect();
251        insert_nested(map, parts, value)
252    } else {
253        // Otherwise, treat as a single key.
254        if map.contains_key(key) {
255            return Err(HkError::KeyConflict(key.to_string()));
256        }
257        map.insert(key.to_string(), value);
258        Ok(())
259    }
260}
261
262/// Insert a nested key using the split parts.
263fn insert_nested(map: &mut IndexMap<String, HkValue>, keys: Vec<&str>, value: HkValue) -> Result<(), HkError> {
264    let mut current = map;
265    for key in &keys[0..keys.len() - 1] {
266        let entry = current
267            .entry(key.to_string())
268            .or_insert(HkValue::Map(IndexMap::new()));
269        if let HkValue::Map(submap) = entry {
270            current = submap;
271        } else {
272            return Err(HkError::KeyConflict(key.to_string()));
273        }
274    }
275    if let Some(last_key) = keys.last() {
276        current.insert(last_key.to_string(), value);
277    }
278    Ok(())
279}
280
281/// Remove surrounding quotes from a key (if present) and unescape inner quotes.
282fn unquote_key(s: &str) -> String {
283    let s = s.trim();
284    if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
285        let inner = &s[1..s.len() - 1];
286        inner.replace("\\\"", "\"")
287    } else {
288        s.to_string()
289    }
290}
291
292fn parse_value(s: &str, line: usize, column: usize) -> Result<HkValue, HkError> {
293    let s = s.trim();
294    if s.is_empty() {
295        return Err(HkError::Parse {
296            line: line as u32,
297            column,
298            message: "Empty value".to_string(),
299        });
300    }
301
302    // Array (single-line form: `[1, 2, 3]`; the multi-line form is handled
303    // one level up, in `parse_map`, before `parse_value` is ever called on
304    // it — by the time we get here for an array it's always one complete,
305    // already-balanced `[...]` string, single- or multi-line alike).
306    if s.starts_with('[') && s.ends_with(']') && net_bracket_depth(s) == 0 {
307        let inner = &s[1..s.len() - 1];
308        let items = parse_array_inner(inner, line, column)?;
309        Ok(HkValue::Array(items))
310    } else {
311        parse_simple_value(s, line, column)
312    }
313}
314
315/// Net count of `[` minus `]` seen outside of quoted strings. Zero means
316/// the brackets in `s` are balanced (a complete, self-contained value);
317/// positive means an array was opened but not yet closed within `s`
318/// (used to detect that an array continues on the following lines).
319fn net_bracket_depth(s: &str) -> i32 {
320    let mut depth = 0i32;
321    let mut in_quotes = false;
322    let mut escape = false;
323    for c in s.chars() {
324        if escape {
325            escape = false;
326            continue;
327        }
328        match c {
329            '\\' if in_quotes => escape = true,
330            '"' => in_quotes = !in_quotes,
331            '[' if !in_quotes => depth += 1,
332            ']' if !in_quotes => depth -= 1,
333            _ => {}
334        }
335    }
336    depth
337}
338
339/// Parses the inside of an array (the text strictly between the outer `[`
340/// and its matching `]`) into items. Supports both separator styles so
341/// callers don't need to know which one was used:
342///   - commas, for the classic single-line style: `1, 2, "three"`
343///   - newlines, for the multi-line style (one item per line; a trailing
344///     comma on each line is fine but not required)
345/// Nested arrays (`[1, [2, 3], 4]`, on one line or spread across several)
346/// are parsed recursively — commas/newlines inside a nested `[...]` don't
347/// split the outer array. Quotes are preserved into each item's own text
348/// so `parse_value`/`parse_simple_value` can still tell a quoted string
349/// apart from a bare number/bool/word.
350fn parse_array_inner(inner: &str, line: usize, column: usize) -> Result<Vec<HkValue>, HkError> {
351    let mut items = Vec::new();
352    let mut current = String::new();
353    let mut in_quotes = false;
354    let mut escape = false;
355    let mut depth = 0i32;
356
357    macro_rules! flush_item {
358        () => {{
359            let trimmed = current.trim();
360            let trimmed = trimmed.strip_suffix(',').unwrap_or(trimmed).trim();
361            if !trimmed.is_empty() && !trimmed.starts_with('!') {
362                items.push(parse_value(trimmed, line, column)?);
363            }
364            current.clear();
365        }};
366    }
367
368    for c in inner.chars() {
369        if escape {
370            current.push(c);
371            escape = false;
372            continue;
373        }
374        match c {
375            '\\' if in_quotes => {
376                current.push(c);
377                escape = true;
378            }
379            '"' => {
380                in_quotes = !in_quotes;
381                current.push(c);
382            }
383            '[' if !in_quotes => {
384                depth += 1;
385                current.push(c);
386            }
387            ']' if !in_quotes => {
388                depth -= 1;
389                current.push(c);
390            }
391            ',' if !in_quotes && depth == 0 => flush_item!(),
392            '\n' if !in_quotes && depth == 0 => flush_item!(),
393            _ => current.push(c),
394        }
395    }
396    flush_item!();
397    Ok(items)
398}
399
400fn parse_simple_value(s: &str, line: usize, column: usize) -> Result<HkValue, HkError> {
401    let s = s.trim();
402    if s.is_empty() {
403        return Err(HkError::Parse {
404            line: line as u32,
405            column,
406            message: "Empty value".to_string(),
407        });
408    }
409
410    // Boolean
411    if s.eq_ignore_ascii_case("true") {
412        return Ok(HkValue::Bool(true));
413    }
414    if s.eq_ignore_ascii_case("false") {
415        return Ok(HkValue::Bool(false));
416    }
417
418    // Number
419    if let Ok(n) = f64::from_str(s) {
420        return Ok(HkValue::Number(n));
421    }
422
423    // Quoted string
424    if s.starts_with('"') && s.ends_with('"') {
425        let inner = &s[1..s.len() - 1];
426        let mut result = String::new();
427        let mut chars = inner.chars();
428        while let Some(c) = chars.next() {
429            if c == '\\' {
430                if let Some(next) = chars.next() {
431                    match next {
432                        'n' => result.push('\n'),
433                        'r' => result.push('\r'),
434                        't' => result.push('\t'),
435                        '"' => result.push('"'),
436                        '\\' => result.push('\\'),
437                        _ => result.push(next),
438                    }
439                }
440            } else {
441                result.push(c);
442            }
443        }
444        Ok(HkValue::String(result))
445    } else {
446        // Plain string
447        Ok(HkValue::String(s.to_string()))
448    }
449}
450
451/// Loads and parses a .hk file from the given path.
452pub fn load_hk_file<P: AsRef<Path>>(path: P) -> Result<HkConfig, HkError> {
453    let file = File::open(path)?;
454    let reader = BufReader::new(file);
455    let mut contents = String::new();
456    for line in reader.lines() {
457        let line = line?;
458        contents.push_str(&line);
459        contents.push('\n');
460    }
461    parse_hk(&contents)
462}